iuna

iuna

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

commit 1ca17903325434cdd912070a409d6db3a7cbbbd9
parent 4240b86752e13b584fa0f64649b256e22ff80e53
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  8 Sep 2026 20:54:39 +0200

Show complete reward fee flow in wallet

Diffstat:
Msrc/adapters/http.rs | 4++--
Msrc/adapters/http/api.rs | 20++++++++++++++++++--
Msrc/adapters/http/types.rs | 20++++++++++++++++++++
Msrc/adapters/http/ui.rs | 209++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/adapters/ui_data_store.rs | 8++++++--
Msrc/main_tests.rs | 10++++++++++
Mwww/assets/iuna-ui.js | 13+++++++++++++
7 files changed, 251 insertions(+), 33 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -59,8 +59,8 @@ pub use state::ServeOptions; use state::{AuthClientKey, AuthSession, HttpState}; use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ - add_pending_outputs, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, - wallet_transaction_rows, + add_pending_outputs, populate_wallet_reward_flow, ui_blocks_from_indexes, ui_transaction, + wallet_transaction_row, wallet_transaction_rows, }; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -21,8 +21,9 @@ use super::{ }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, - add_pending_outputs, metrics_response, network_health, top_mine_proofs, ui_blocks_from_indexes, - ui_transaction, wallet_transaction_row, wallet_transaction_rows, + add_pending_outputs, metrics_response, network_health, populate_wallet_reward_flow, + top_mine_proofs, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, + wallet_transaction_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -161,6 +162,17 @@ pub(super) async fn api_wallet_transactions( let confirmed_outputs = load_outputs_for_outpoints(&state, confirmed_required_outputs) .await .unwrap_or_default(); + let reward_blocks = { + let node = state.node.lock().await; + confirmed_rows + .iter() + .filter(|row| row.kind == "reward") + .filter_map(|row| { + let block = node.chain().get(row.block_height as usize)?.clone(); + Some((row.block_height, block)) + }) + .collect::<BTreeMap<_, _>>() + }; items.extend(confirmed_rows.into_iter().filter_map(|row| { let is_reward = row.kind == "reward"; let mut item = wallet_transaction_row( @@ -177,7 +189,11 @@ pub(super) async fn api_wallet_transactions( if is_reward { item.kind = "reward"; item.from = "fees".to_string(); + item.to = Some(wallet.clone()); item.direction = "reward"; + if let Some(block) = reward_blocks.get(&row.block_height) { + populate_wallet_reward_flow(&mut item, block); + } } Some(item) })); diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -393,6 +393,26 @@ pub(super) struct WalletTransactionRow { pub(super) difficulty_bits: Option<u32>, pub(super) proof_bits: Option<u32>, pub(super) proof_hash: Option<String>, + pub(super) reward_total: Option<Amount>, + pub(super) reward_fee_inputs: Vec<UiRewardFeeInput>, + pub(super) reward_outputs: Vec<UiRewardOutput>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UiRewardFeeInput { + pub(super) transaction_kind: &'static str, + pub(super) amount: Amount, + pub(super) owner: String, + pub(super) signature: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UiRewardOutput { + pub(super) label: String, + pub(super) amount: Amount, + pub(super) address: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -7,8 +7,9 @@ use crate::domain::{ }; use super::types::{ - UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiTransaction, UiTxInput, - WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, + UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiRewardFeeInput, UiRewardOutput, + UiTransaction, UiTxInput, WalletTransactionContext, WalletTransactionFilters, + WalletTransactionRow, }; pub(super) fn wallet_transaction_rows( @@ -72,29 +73,39 @@ pub(super) fn wallet_transaction_row( outputs, fee, signature, - } if tx.sender() == wallet || tx.to() == Some(wallet) => Some(WalletTransactionRow { - kind: "transfer", - from: tx.sender().to_string(), - to: tx.to().map(str::to_string), - amount: tx.amount(), - fee: *fee, - inputs: ui_inputs(inputs, outputs_by_outpoint), - outputs: outputs.clone(), - change: Vec::new(), - signature: signature.clone(), - status: context.status, - block_height: context.block_height, - timestamp_ms: context.timestamp_ms, - block_finalizer: context.block_finalizer.clone(), - direction: if tx.to() == Some(wallet) { - "received" + } if tx.sender() == wallet || outputs.iter().any(|output| output.address == wallet) => { + let sent = tx.sender() == wallet; + let amount = if sent { + tx.amount() } else { - "sent" - }, - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - }), + outputs + .iter() + .filter(|output| output.address == wallet) + .fold(0_u64, |total, output| total.saturating_add(output.amount)) + }; + Some(WalletTransactionRow { + kind: "transfer", + from: tx.sender().to_string(), + to: tx.to().map(str::to_string), + amount, + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: outputs.clone(), + change: Vec::new(), + signature: signature.clone(), + status: context.status, + block_height: context.block_height, + timestamp_ms: context.timestamp_ms, + block_finalizer: context.block_finalizer.clone(), + direction: if sent { "sent" } else { "received" }, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + reward_total: None, + reward_fee_inputs: Vec::new(), + reward_outputs: Vec::new(), + }) + } Transaction::Burn { inputs, change, @@ -120,6 +131,9 @@ pub(super) fn wallet_transaction_row( difficulty_bits: None, proof_bits: None, proof_hash: None, + reward_total: None, + reward_fee_inputs: Vec::new(), + reward_outputs: Vec::new(), }), Transaction::Mine { recipient, @@ -147,11 +161,66 @@ pub(super) fn wallet_transaction_row( difficulty_bits: Some(*difficulty_bits), proof_bits: Some(proof_bits(signature)), proof_hash: Some(signature.clone()), + reward_total: None, + reward_fee_inputs: Vec::new(), + reward_outputs: Vec::new(), }), _ => None, } } +pub(super) fn populate_wallet_reward_flow(row: &mut WalletTransactionRow, block: &Block) { + row.reward_total = Some(block.reward); + row.reward_fee_inputs = block + .transactions + .iter() + .filter(|transaction| transaction.fee() > 0) + .map(|transaction| UiRewardFeeInput { + transaction_kind: transaction_kind(transaction), + amount: transaction.fee(), + owner: match transaction { + Transaction::Mine { .. } => "pow".to_string(), + _ => transaction.sender().to_string(), + }, + signature: transaction.signature().to_string(), + }) + .collect(); + + let committee_slots = block + .burn_bundle_section + .signatures + .iter() + .filter(|signature| signature.member != block.miner) + .map(|signature| (signature.member.as_str(), signature.slot)) + .collect::<BTreeMap<_, _>>(); + + row.reward_outputs = row + .outputs + .iter() + .cloned() + .enumerate() + .map(|(index, output)| UiRewardOutput { + label: if index == 0 { + "Finalizer reward".to_string() + } else if let Some(slot) = committee_slots.get(output.address.as_str()) { + format!("Committee reward (slot {slot})") + } else { + format!("Committee reward {index}") + }, + amount: output.amount, + address: output.address, + }) + .collect(); +} + +fn transaction_kind(transaction: &Transaction) -> &'static str { + match transaction { + Transaction::Transfer { .. } => "transfer", + Transaction::Burn { .. } => "burn", + Transaction::Mine { .. } => "mine", + } +} + pub(super) fn ui_blocks_from_indexes( blocks: Vec<Block>, outputs: &BTreeMap<OutPoint, TxOutput>, @@ -489,11 +558,12 @@ mod tests { use crate::compact::CompactBlockSizeBreakdown; use crate::domain::{ - Amount, Block, BurnBundleSection, BurnLeaderRank, FinalizerMode, MaskedBurn, OutPoint, - Transaction, TxInput, TxOutput, + Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, FinalizerMode, + MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, }; - use super::{block_lost_iuna, ui_block}; + use super::{block_lost_iuna, populate_wallet_reward_flow, ui_block, wallet_transaction_row}; + use crate::adapters::http::types::WalletTransactionContext; fn burn(signature: &str) -> Transaction { Transaction::Burn { @@ -536,6 +606,91 @@ mod tests { } #[test] + fn wallet_reward_flow_contains_every_fee_and_payout() { + let reward_projection = Transaction::Transfer { + inputs: Vec::new(), + outputs: vec![ + TxOutput { + address: "finalizer".to_string(), + amount: 3, + }, + TxOutput { + address: "committee".to_string(), + amount: 2, + }, + ], + fee: 0, + signature: "reward:block:0".to_string(), + }; + let mut row = wallet_transaction_row( + "finalizer", + &reward_projection, + &BTreeMap::new(), + &WalletTransactionContext { + status: "confirmed", + block_height: Some(1), + timestamp_ms: Some(1_000), + block_finalizer: Some("finalizer".to_string()), + }, + ) + .unwrap(); + row.kind = "reward"; + let committee_row = wallet_transaction_row( + "committee", + &reward_projection, + &BTreeMap::new(), + &WalletTransactionContext { + status: "confirmed", + block_height: Some(1), + timestamp_ms: Some(1_000), + block_finalizer: Some("finalizer".to_string()), + }, + ) + .unwrap(); + assert_eq!(committee_row.amount, 2); + assert_eq!(committee_row.direction, "received"); + + let block = Block { + height: 1, + prev_hash: "parent".to_string(), + timestamp_ms: 1_000, + miner: "finalizer".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 5, + vdf_rounds: 1, + vdf_output: "vdf".to_string(), + leader_proof: None, + burn_bundle_section: BurnBundleSection { + signatures: vec![BurnBundleSignature { + slot: 2, + member: "committee".to_string(), + signature: "attestation".to_string(), + }], + burns: Vec::new(), + }, + transactions: vec![transfer("transfer", 2), burn("burn")], + hash: "block".to_string(), + }; + + populate_wallet_reward_flow(&mut row, &block); + + assert_eq!(row.reward_fee_inputs.len(), 2); + assert_eq!(row.reward_total, Some(5)); + assert_eq!(row.reward_fee_inputs[0].transaction_kind, "transfer"); + assert_eq!(row.reward_fee_inputs[0].amount, 2); + assert_eq!(row.reward_fee_inputs[1].transaction_kind, "burn"); + assert_eq!(row.reward_fee_inputs[1].amount, 1); + assert_eq!(row.reward_outputs.len(), 2); + assert_eq!(row.reward_outputs[0].label, "Finalizer reward"); + assert_eq!(row.reward_outputs[0].address, "finalizer"); + assert_eq!(row.reward_outputs[0].amount, 3); + assert_eq!(row.reward_outputs[1].label, "Committee reward (slot 2)"); + assert_eq!(row.reward_outputs[1].address, "committee"); + assert_eq!(row.reward_outputs[1].amount, 2); + } + + #[test] fn block_lost_iuna_counts_burns_without_rewarded_fees() { let burn = Transaction::Burn { inputs: Vec::new(), diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -142,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 = 4; +const UI_CACHE_SCHEMA_VERSION: u32 = 5; const METRICS_CACHE_SCHEMA_VERSION: u32 = 1; #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -1419,6 +1419,10 @@ fn wallet_transactions_from_snapshot( } else { Vec::new() }; + let all_reward_outputs = reward_outputs + .iter() + .map(|(_, output)| output.clone()) + .collect::<Vec<_>>(); for (index, (outpoint, output)) in reward_outputs.into_iter().enumerate() { let signature = format!("reward:{}:{}", outpoint.txid, outpoint.index); rows.push(( @@ -1432,7 +1436,7 @@ fn wallet_transactions_from_snapshot( block_finalizer: block.miner.clone(), transaction: Transaction::Transfer { inputs: Vec::new(), - outputs: vec![output], + outputs: all_reward_outputs.clone(), fee: 0, signature, }, diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -201,6 +201,16 @@ fn management_ui_block_reward_opens_a_fee_flow_transaction() { assert!(javascript.contains("label: `Committee reward (slot ${bundle.slot})`")); } +#[test] +fn management_ui_wallet_transactions_share_the_generic_detail_flow() { + let html = include_str!("adapters/http/index_html.rs"); + let javascript = include_str!("../www/assets/iuna-ui.js"); + assert!(html.contains("openTransactionModal(tx, { source: 'Wallet' })")); + assert!(javascript.contains("Array.isArray(tx.rewardFeeInputs)")); + assert!(javascript.contains("Array.isArray(tx.rewardOutputs)")); + assert!(javascript.contains("outpoint: { txid: input.signature, index: \"fee\" }")); +} + fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { let mut genesis = BTreeMap::new(); genesis.insert(wallet.address().to_string(), 3); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -3041,6 +3041,9 @@ window.iunaApp = function iunaApp() { }, txAmount(tx) { + if (tx?.kind === "reward" && tx.rewardTotal !== null && tx.rewardTotal !== undefined) { + return tx.rewardTotal; + } return tx.amount ?? tx.outputs?.[0]?.amount ?? 0; }, @@ -3077,10 +3080,20 @@ window.iunaApp = function iunaApp() { }, txInputs(tx) { + if (tx?.kind === "reward" && Array.isArray(tx.rewardFeeInputs)) { + return tx.rewardFeeInputs.map((input) => ({ + ...input, + rewardFee: true, + outpoint: { txid: input.signature, index: "fee" }, + })); + } return Array.isArray(tx.inputs) ? tx.inputs : []; }, txVisualOutputs(tx) { + if (tx?.kind === "reward" && Array.isArray(tx.rewardOutputs)) { + return tx.rewardOutputs.map((output) => ({ ...output, kind: "reward" })); + } const rows = []; if (tx.kind === "burn" && Number(tx.amount || 0) > 0) { rows.push({