commit 26cd4ceed9f2afc90445f92eb669138ab196e900
parent 79541ed5e00f8036857d0f92907abccf4feb988b
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 8 Sep 2026 01:05:05 +0200
feat: expose wallet rewards and dedicated host
Diffstat:
10 files changed, 142 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
@@ -275,11 +275,10 @@ configuration, or authentication routes. JSON request bodies are capped at 64
KiB. The production manifest exposes this API as `https://iuna.jhx.app/v1` while
`https://admin.iuna.jhx.app/` remains the separately protected management UI.
-The lightweight browser wallet MVP lives in `wallet/` and is copied to
-`/wallet/` in the public site image. It keeps the seed encrypted in browser
+The lightweight browser wallet lives in `wallet/` and is served at
+`https://wallet.getiuna.org/`. It keeps the seed encrypted in browser
`localStorage`, signs transfers locally, and talks only to the public v1 wallet
-endpoint. A separate host such as `wallet.getiuna.org` can point its document
-root at that same `/wallet/` directory.
+endpoint. Requests to the old `/wallet/` path redirect to the dedicated host.
## Optional: Stratum Mining
diff --git a/config/deployment.yml b/config/deployment.yml
@@ -73,10 +73,21 @@ spec:
name: iuna-www
port:
number: 80
+ - host: wallet.getiuna.org
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: iuna-www
+ port:
+ number: 80
tls:
- secretName: iuna-tls
hosts:
- getiuna.org
+ - wallet.getiuna.org
---
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
diff --git a/nginx.conf b/nginx.conf
@@ -5,6 +5,14 @@ server {
root /usr/share/nginx/html;
index index.html;
+ location = /wallet {
+ return 301 https://wallet.getiuna.org/;
+ }
+
+ location /wallet/ {
+ rewrite ^/wallet/(.*)$ https://wallet.getiuna.org/$1 permanent;
+ }
+
location / {
try_files $uri $uri/ =404;
}
@@ -24,3 +32,15 @@ server {
try_files $uri $uri/ =404;
}
}
+
+server {
+ listen 80;
+ server_name wallet.getiuna.org;
+
+ root /usr/share/nginx/html/wallet;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs
@@ -162,7 +162,8 @@ pub(super) async fn api_wallet_transactions(
.await
.unwrap_or_default();
items.extend(confirmed_rows.into_iter().filter_map(|row| {
- wallet_transaction_row(
+ let is_reward = row.kind == "reward";
+ let mut item = wallet_transaction_row(
&wallet,
&row.transaction,
&confirmed_outputs,
@@ -172,7 +173,13 @@ pub(super) async fn api_wallet_transactions(
timestamp_ms: Some(row.timestamp_ms),
block_finalizer: Some(row.block_finalizer),
},
- )
+ )?;
+ if is_reward {
+ item.kind = "reward";
+ item.from = "protocol".to_string();
+ item.direction = "reward";
+ }
+ Some(item)
}));
let total = pending_total + confirmed_total;
let next_offset = offset + items.len();
@@ -299,6 +306,9 @@ fn wallet_transaction_filter_kinds(filters: WalletTransactionFilters) -> Vec<&'s
if filters.burn {
kinds.push("burn");
}
+ if filters.reward {
+ kinds.push("reward");
+ }
kinds
}
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -809,6 +809,10 @@ pub(super) const INDEX_HTML: &str = concat!(
<input type="checkbox" x-model="walletTxFilters.burn" @change="refreshWalletTransactions()">
<span>Burn</span>
</label>
+ <label class="tx-filter" :class="{ active: walletTxFilters.reward }">
+ <input type="checkbox" x-model="walletTxFilters.reward" @change="refreshWalletTransactions()">
+ <span>Reward</span>
+ </label>
</div>
</div>
<div class="wallet-tx-list">
diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs
@@ -222,6 +222,7 @@ pub(super) struct WalletTransactionsQuery {
pub(super) tx: Option<bool>,
pub(super) mine: Option<bool>,
pub(super) burn: Option<bool>,
+ pub(super) reward: Option<bool>,
pub(super) offset: Option<usize>,
pub(super) limit: Option<usize>,
}
@@ -240,6 +241,7 @@ pub(super) struct WalletTransactionFilters {
pub(super) transfer: bool,
pub(super) mine: bool,
pub(super) burn: bool,
+ pub(super) reward: bool,
}
impl Default for WalletTransactionFilters {
@@ -248,6 +250,7 @@ impl Default for WalletTransactionFilters {
transfer: true,
mine: false,
burn: false,
+ reward: true,
}
}
}
@@ -258,6 +261,7 @@ impl WalletTransactionFilters {
transfer: query.tx.unwrap_or(true),
mine: query.mine.unwrap_or(false),
burn: query.burn.unwrap_or(false),
+ reward: query.reward.unwrap_or(true),
}
}
diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs
@@ -15,6 +15,7 @@ use crate::{
domain::{
Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_RETARGET_WINDOW_BLOCKS,
MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, retarget_mine_difficulty_bits,
+ reward_outputs_for_block,
},
};
@@ -141,7 +142,7 @@ DROP TABLE IF EXISTS ui_burn_leader_rank_blocks;
"#;
const UI_DATA_SCHEMA_VERSION: u32 = 1;
-const UI_CACHE_SCHEMA_VERSION: u32 = 3;
+const UI_CACHE_SCHEMA_VERSION: u32 = 4;
const METRICS_CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -1247,7 +1248,7 @@ LIMIT ? OFFSET ?
}
fn wallet_transaction_kinds_cover_all(kinds: &[&str]) -> bool {
- ["transfer", "mine", "burn"]
+ ["transfer", "mine", "burn", "reward"]
.into_iter()
.all(|kind| kinds.contains(&kind))
}
@@ -1387,6 +1388,15 @@ fn wallet_transactions_from_snapshot(
snapshot: &ChainSnapshot,
) -> Vec<(String, WalletTransactionProjection)> {
let mut rows = Vec::new();
+ let mut running_ledger = snapshot.blocks.first().cloned().and_then(|genesis| {
+ Ledger::from_preverified_snapshot(ChainSnapshot {
+ genesis_allocations: snapshot.genesis_allocations.clone(),
+ vdf_rounds: snapshot.vdf_rounds,
+ launch_profile: snapshot.launch_profile.clone(),
+ blocks: vec![genesis],
+ })
+ .ok()
+ });
for block in &snapshot.blocks {
for (index, transaction) in block.transactions.iter().rev().enumerate() {
push_wallet_transaction_projection(
@@ -1396,6 +1406,44 @@ fn wallet_transactions_from_snapshot(
block.height as u128 * 10_000 + index as u128,
);
}
+ let reward_committee = if block.height == 0 {
+ Vec::new()
+ } else {
+ running_ledger
+ .as_ref()
+ .map(|ledger| ledger.burn_committee_for_block(block))
+ .unwrap_or_default()
+ };
+ let reward_outputs = if block.height > 0 {
+ reward_outputs_for_block(block, &reward_committee)
+ } else {
+ Vec::new()
+ };
+ for (index, (outpoint, output)) in reward_outputs.into_iter().enumerate() {
+ let signature = format!("reward:{}:{}", outpoint.txid, outpoint.index);
+ rows.push((
+ output.address.clone(),
+ WalletTransactionProjection {
+ sort_key: (block.height as u128 * 10_000 + 9_999 - index as u128)
+ .min(u128::from(u64::MAX)) as u64,
+ kind: "reward".to_string(),
+ block_height: block.height,
+ timestamp_ms: block.timestamp_ms,
+ block_finalizer: block.miner.clone(),
+ transaction: Transaction::Transfer {
+ inputs: Vec::new(),
+ outputs: vec![output],
+ fee: 0,
+ signature,
+ },
+ },
+ ));
+ }
+ if block.height > 0 {
+ if let Some(ledger) = running_ledger.as_mut() {
+ let _ = ledger.apply_preverified_block_at(block.clone(), u64::MAX);
+ }
+ }
}
rows
}
@@ -1675,6 +1723,26 @@ mod tests {
}
#[test]
+ fn wallet_projection_includes_block_rewards() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let (mut ledger, wallet) = test_ledger("wallet-reward-history");
+ append_test_block(&mut ledger, &wallet, 1_234);
+
+ store.project_snapshot(&ledger.snapshot(), false).unwrap();
+ let (rows, total) = store
+ .load_wallet_transactions(wallet.address(), &["reward"], 0, 10)
+ .unwrap();
+
+ assert_eq!(total, 1);
+ assert_eq!(rows[0].kind, "reward");
+ assert_eq!(rows[0].block_height, 1);
+ assert_eq!(rows[0].timestamp_ms, 1_234);
+ assert_eq!(rows[0].transaction.to(), Some(wallet.address()));
+ assert!(rows[0].transaction.amount() > 0);
+ }
+
+ #[test]
fn opening_legacy_ui_schema_rebuilds_the_derived_cache() {
let dir = tempdir().unwrap();
let path = dir.path().join("ui_data.sqlite3");
diff --git a/src/adapters/wallet_endpoint.rs b/src/adapters/wallet_endpoint.rs
@@ -114,6 +114,7 @@ struct TransactionsResponse {
#[derive(Debug, Serialize)]
struct AddressTransaction {
+ kind: String,
status: &'static str,
block_height: Option<u64>,
timestamp_ms: Option<u64>,
@@ -293,6 +294,7 @@ async fn address_transactions(
.skip(offset.min(pending_total))
.take(limit)
.map(|transaction| AddressTransaction {
+ kind: transaction_kind(&transaction).to_string(),
status: "pending",
block_height: None,
timestamp_ms: None,
@@ -306,7 +308,7 @@ async fn address_transactions(
let confirmed = tokio::task::spawn_blocking(move || {
store.load_wallet_transactions(
&public_key,
- &["transfer", "mine", "burn"],
+ &["transfer", "mine", "burn", "reward"],
confirmed_offset,
remaining,
)
@@ -316,6 +318,7 @@ async fn address_transactions(
.map_err(internal_error)?;
let confirmed_total = confirmed.1;
items.extend(confirmed.0.into_iter().map(|row| AddressTransaction {
+ kind: row.kind,
status: "confirmed",
block_height: Some(row.block_height),
timestamp_ms: Some(row.timestamp_ms),
@@ -349,6 +352,14 @@ fn transaction_mentions_address(transaction: &Transaction, address: &str) -> boo
}
}
+fn transaction_kind(transaction: &Transaction) -> &'static str {
+ match transaction {
+ Transaction::Transfer { .. } => "transfer",
+ Transaction::Burn { .. } => "burn",
+ Transaction::Mine { .. } => "mine",
+ }
+}
+
async fn submit_transaction(
State(state): State<WalletEndpointState>,
Json(transaction): Json<Transaction>,
diff --git a/wallet/app.js b/wallet/app.js
@@ -121,6 +121,10 @@ function nav() {
function transactionInfo(item) {
const tx = item.transaction || {};
const owner = state.wallet.publicKeyHex;
+ if (item.kind === "reward") {
+ const amount = tx.outputs?.find((output) => output.address === owner)?.amount || 0;
+ return { title: "Block reward", incoming: true, amount, symbol: "★" };
+ }
if (tx.kind === "mine") return { title: "Mining reward", incoming: true, amount: 1_000_000, symbol: "✦" };
if (tx.kind === "burn") return { title: "Burn", incoming: false, amount: tx.amount || 0, symbol: "×" };
const sent = tx.inputs?.some((input) => input.owner === owner);
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -125,7 +125,7 @@ window.iunaApp = function iunaApp() {
selectedTransferUtxos: [],
selectedTransferUtxoAmounts: {},
lastSelectedTransferUtxo: null,
- walletTxFilters: { transfer: true, mine: false, burn: false },
+ walletTxFilters: { transfer: true, mine: false, burn: false, reward: true },
setupPeerAddress: "iuna.jhx.app:9444",
peerAddress: "",
flash: null,
@@ -1151,6 +1151,7 @@ window.iunaApp = function iunaApp() {
tx: String(this.walletTxFilters.transfer),
mine: String(this.walletTxFilters.mine),
burn: String(this.walletTxFilters.burn),
+ reward: String(this.walletTxFilters.reward),
});
return `/api/wallet/transactions?${params.toString()}`;
},