iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
MREADME.md | 7+++----
Mconfig/deployment.yml | 11+++++++++++
Mnginx.conf | 20++++++++++++++++++++
Msrc/adapters/http/api.rs | 14++++++++++++--
Msrc/adapters/http/index_html.rs | 4++++
Msrc/adapters/http/types.rs | 4++++
Msrc/adapters/ui_data_store.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/wallet_endpoint.rs | 13++++++++++++-
Mwallet/app.js | 4++++
Mwww/assets/iuna-ui.js | 3++-
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()}`; },