commit 08da719266fdabc8e64527ff342dc9e006b65328
parent 1c8bdef605a4ab54b76d093f12087041749b2d12
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 25 Aug 2026 00:35:05 +0200
Show block reward fee flow
Diffstat:
3 files changed, 102 insertions(+), 11 deletions(-)
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -432,6 +432,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
.pill.burn { background: #332918; color: #ffd070; }
.pill.transfer { background: #17312a; color: #8de9cd; }
.pill.mine { background: #172a34; color: #8bdcff; }
+ .pill.reward { background: #263016; color: #d5f55f; }
.pill.error { background: #341918; color: #ffb1a8; }
.mempool-panel { min-width: 0; overflow: hidden; }
.mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; }
@@ -447,6 +448,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
.utxo-node { display: grid; gap: 5px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; min-width: 0; }
.utxo-node.burned { border-color: #5e4821; background: #1f1a12; }
.utxo-node.fee { border-color: #4b5260; background: #171a20; }
+ .utxo-node.reward { border-color: #526329; background: #1a2013; }
.utxo-node-label { display: flex; justify-content: space-between; gap: 8px; color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.utxo-node-amount { color: #d5f55f; font-weight: 850; font-variant-numeric: tabular-nums; }
.utxo-node-address, .utxo-node-ref { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
@@ -481,7 +483,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=111"></script>
+ <script defer src="/assets/iuna-ui.js?v=112"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -989,7 +991,12 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
</button>
</div>
<div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div>
- <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div>
+ <div class="detail-kv">
+ <div class="key">Reward</div>
+ <button class="detail-link" type="button" @click="openBlockRewardModal(selectedBlock)" title="Show paid and distributed fees" aria-label="Show block reward fee flow">
+ <span class="link-value">IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></span>
+ </button>
+ </div>
<div class="detail-kv">
<div class="key">Burn Bundles</div>
<button class="detail-link" type="button" @click="openBurnBundleModal(selectedBlock)" title="How the burn bundle attestations were selected" aria-label="Show burn bundle selection details">
@@ -1500,9 +1507,9 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
<div class="tx-modal-summary">
<div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div>
<div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div>
- <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div>
- <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div>
- <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div>
+ <div class="tx-field" x-show="!isRewardTx(selectedTransaction?.tx)"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div>
+ <div class="tx-field" x-show="!isRewardTx(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div>
+ <div class="tx-field" x-show="!isRewardTx(selectedTransaction?.tx) && txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div>
<div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div>
<div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div>
<div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div>
@@ -1512,11 +1519,11 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
<h3>Inputs</h3>
<template x-for="(input, index) in txInputs(selectedTransaction?.tx || {})" :key="txInputKey(input, index)">
<div class="utxo-node">
- <div class="utxo-node-label"><span>Input <span x-text="index + 1"></span></span><span>spent</span></div>
+ <div class="utxo-node-label"><span x-text="input.rewardFee ? `Paid fee ${index + 1}` : `Input ${index + 1}`"></span><span x-text="input.rewardFee ? input.transactionKind : 'spent'"></span></div>
<div class="utxo-node-ref" x-text="txInputOutpoint(input)"></div>
<div class="tx-field"><span class="tx-label">Value</span><span class="tx-value money" x-text="txInputAmountLabel(input)"></span></div>
- <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(input.owner)"></code></div>
- <div class="tx-field"><span class="tx-label">Sig</span><code class="tx-value hash" x-text="short(input.signature)"></code></div>
+ <div class="tx-field" x-show="input.owner"><span class="tx-label" x-text="input.rewardFee ? 'Paid by' : 'Owner'"></span><code class="tx-value hash" x-text="addressLabel(input.owner)"></code></div>
+ <div class="tx-field"><span class="tx-label" x-text="input.rewardFee ? 'Transaction' : 'Sig'"></span><code class="tx-value hash" x-text="short(input.signature)"></code></div>
</div>
</template>
<div class="tx-modal-empty" x-show="txInputs(selectedTransaction?.tx || {}).length === 0">No inputs</div>
@@ -1525,7 +1532,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html>
<div class="utxo-column">
<h3>Outputs</h3>
<template x-for="(output, index) in txVisualOutputs(selectedTransaction?.tx || {})" :key="txOutputKey(output, index)">
- <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }">
+ <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee', reward: output.kind === 'reward' }">
<div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div>
<div class="utxo-node-amount">IUNA <span x-text="amountLabel(output.amount)"></span></div>
<template x-if="output.address">
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -95,6 +95,18 @@ fn management_ui_blocks_interaction_while_the_node_is_syncing() {
assert!(html.contains("@click=\"openChainResetModal\""));
}
+#[test]
+fn management_ui_block_reward_opens_a_fee_flow_transaction() {
+ let html = include_str!("adapters/http/index_html.rs");
+ let javascript = include_str!("../www/assets/iuna-ui.js");
+ assert!(html.contains("@click=\"openBlockRewardModal(selectedBlock)\""));
+ assert!(html.contains("input.rewardFee ? `Paid fee ${index + 1}`"));
+ assert!(javascript.contains("blockRewardTransaction(block)"));
+ assert!(javascript.contains("blockRewardOutputs(block)"));
+ assert!(javascript.contains("label: \"Finalizer reward\""));
+ assert!(javascript.contains("label: `Committee reward (slot ${bundle.slot})`"));
+}
+
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
@@ -1246,6 +1246,15 @@ window.iunaApp = function iunaApp() {
this.selectedTransaction = { tx, context };
},
+ openBlockRewardModal(block) {
+ this.openTransactionModal(this.blockRewardTransaction(block), {
+ source: "Block reward",
+ blockHeight: block.height,
+ blockFinalizer: block.miner,
+ reward: true,
+ });
+ },
+
closeTransactionModal() {
this.selectedTransaction = null;
},
@@ -2634,6 +2643,10 @@ window.iunaApp = function iunaApp() {
return tx?.kind === "mine";
},
+ isRewardTx(tx) {
+ return tx?.kind === "reward";
+ },
+
txFeeLabel(tx) {
return `IUNA ${this.amountLabel(tx?.fee ?? 0)}`;
},
@@ -2685,8 +2698,8 @@ window.iunaApp = function iunaApp() {
const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : [];
for (const [index, output] of directOutputs.entries()) {
rows.push({
- kind: "output",
- label: `Output ${index + 1}`,
+ kind: output.kind || "output",
+ label: output.label || `Output ${index + 1}`,
amount: output.amount,
address: output.address,
});
@@ -2837,6 +2850,7 @@ window.iunaApp = function iunaApp() {
selectedTransactionLabel() {
if (!this.selectedTransaction) return "-";
const { tx, context } = this.selectedTransaction;
+ if (context.reward && context.blockHeight !== undefined) return `Block ${context.blockHeight} reward`;
if (context.blockHeight !== undefined) return `Block ${context.blockHeight}`;
if (tx?.status === "pending") return "Wallet pending";
if (tx?.blockHeight !== null && tx?.blockHeight !== undefined) {
@@ -2859,6 +2873,64 @@ window.iunaApp = function iunaApp() {
return this.blockTransactions(block).reduce((sum, tx) => sum + Number(tx.fee || 0), 0);
},
+ blockRewardTransaction(block) {
+ const inputs = this.blockTransactions(block)
+ .filter((tx) => Number(tx.fee || 0) > 0)
+ .map((tx) => ({
+ rewardFee: true,
+ transactionKind: tx.kind || "transaction",
+ amount: tx.fee,
+ owner: this.txFrom(tx) || this.txTo(tx) || null,
+ signature: tx.signature,
+ outpoint: { txid: tx.signature, index: "fee" },
+ }));
+ return {
+ kind: "reward",
+ signature: block.hash,
+ amount: block.reward,
+ fee: 0,
+ inputs,
+ outputs: this.blockRewardOutputs(block),
+ };
+ },
+
+ blockRewardOutputs(block) {
+ const reward = Math.max(0, Math.trunc(Number(block?.reward || 0)));
+ if (reward === 0) return [];
+
+ const bundles = Array.isArray(block?.burn_bundles)
+ ? block.burn_bundles
+ : (Array.isArray(block?.burnBundles) ? block.burnBundles : []);
+ const committee = block?.finalizer_mode === "ticket" && Number(block?.finalizer_rank || 0) <= 1
+ ? [...bundles]
+ .sort((left, right) => Number(left.slot || 0) - Number(right.slot || 0))
+ .filter((bundle) => bundle.member && bundle.member !== block.miner)
+ : [];
+ const committeePool = committee.length > 0 ? Math.floor(reward / 2) : 0;
+ const outputs = [{
+ kind: "reward",
+ label: "Finalizer reward",
+ amount: reward - committeePool,
+ address: block.miner,
+ }];
+
+ let remaining = committeePool;
+ committee.forEach((bundle, index) => {
+ const membersLeft = committee.length - index;
+ const amount = membersLeft === 1 ? remaining : Math.floor(remaining / membersLeft);
+ remaining -= amount;
+ if (amount > 0) {
+ outputs.push({
+ kind: "reward",
+ label: `Committee reward (slot ${bundle.slot})`,
+ amount,
+ address: bundle.member,
+ });
+ }
+ });
+ return outputs;
+ },
+
blockTimestampLabel(block) {
const timestamp = Number(block?.timestamp_ms ?? block?.timestampMs);
if (Number(block?.height) === 0) return "Genesis";