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:
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({