iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 0fb58688671abdbb19dc5fbcce53071089ce9d5d
parent 8cd9c394f95d3e5079ebe4f4a56380a4f8c4a162
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 09:09:35 +0200

Improve transaction visibility and p2p delivery

Diffstat:
Massets/luun-ui.js | 40++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/http.rs | 354++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/adapters/p2p.rs | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app.rs | 47+++++++++++++++++++++++++++++------------------
Msrc/domain.rs | 39+++++++++++++++++++++++++++++++++++++++
Mtests/luun.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 889 insertions(+), 47 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -8,6 +8,7 @@ window.luunApp = function luunApp() { loadingOlder: false, hasMoreBlocks: true, walletTxs: [], + walletUtxos: [], mempool: [], peers: [], p2pMetrics: {}, @@ -32,6 +33,7 @@ window.luunApp = function luunApp() { peerAddress: "", flash: null, flashTimer: null, + showWalletUtxos: false, lastUpdated: null, pollHandle: null, newBlockHashes: new Set(), @@ -250,11 +252,12 @@ window.luunApp = function luunApp() { async refresh() { try { - const [config, status, blocks, walletTxs, mempool, peers, p2pMetrics] = await Promise.all([ + const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics] = await Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), this.fetchJson("/api/blocks"), this.fetchJson("/api/wallet/transactions"), + this.fetchJson("/api/wallet/utxos"), this.fetchJson("/api/mempool"), this.fetchJson("/api/peers"), this.fetchJson("/api/p2p/metrics"), @@ -266,6 +269,7 @@ window.luunApp = function luunApp() { this.status = status; this.mergeFreshBlocks(blocks, { animateHead: true }); this.walletTxs = walletTxs; + this.walletUtxos = walletUtxos; this.mempool = mempool; this.peers = peers; this.p2pMetrics = p2pMetrics; @@ -362,6 +366,19 @@ window.luunApp = function luunApp() { this.selectedTransaction = null; }, + openWalletUtxosModal() { + this.showWalletUtxos = true; + }, + + closeWalletUtxosModal() { + this.showWalletUtxos = false; + }, + + closeModals() { + this.closeTransactionModal(); + this.closeWalletUtxosModal(); + }, + async loadOlderBlocks() { if (this.loadingOlder || !this.hasMoreBlocks || this.blocks.length === 0) return; const oldest = Math.min(...this.blocks.map((block) => block.height)); @@ -583,6 +600,16 @@ window.luunApp = function luunApp() { address: null, }); } + if (Number(tx.fee || 0) > 0) { + rows.push({ + kind: "fee", + label: "Fee", + amount: tx.fee, + address: null, + detailLabel: "To", + detail: this.txFeeRecipient(tx), + }); + } const directOutputs = Array.isArray(tx.outputs) ? tx.outputs : []; for (const [index, output] of directOutputs.entries()) { rows.push({ @@ -609,7 +636,7 @@ window.luunApp = function luunApp() { }, txOutputKey(output, index) { - return `${output.kind}:${output.address || "burn"}:${output.amount}:${index}`; + return `${output.kind}:${output.address || output.kind}:${output.amount}:${index}`; }, txInputOutpoint(input) { @@ -618,6 +645,15 @@ window.luunApp = function luunApp() { return `${txid}:${index}`; }, + txInputAmountLabel(input) { + return input.amount === null || input.amount === undefined ? "-" : `LUUN ${input.amount}`; + }, + + txFeeRecipient(tx) { + const context = this.selectedTransaction?.context || {}; + return tx.blockMiner ?? context.blockMiner ?? "future block miner"; + }, + selectedTransactionLabel() { if (!this.selectedTransaction) return "-"; const { tx, context } = this.selectedTransaction; diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, net::SocketAddr, path::{Path, PathBuf}, sync::Arc, @@ -23,7 +24,7 @@ use crate::{ wallet_store, }, app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook}, - domain::{Amount, Block, Transaction, TxInput, TxOutput}, + domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash}, }; const EXPLORER_LIMIT: usize = 50; @@ -96,15 +97,60 @@ struct WalletTransactionRow { to: Option<String>, amount: Amount, fee: Amount, - inputs: Vec<TxInput>, + inputs: Vec<UiTxInput>, outputs: Vec<TxOutput>, change: Vec<TxOutput>, signature: String, status: &'static str, block_height: Option<u64>, + block_miner: Option<String>, direction: &'static str, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct WalletUtxoRow { + outpoint: OutPoint, + address: String, + amount: Amount, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct UiBlock { + height: u64, + prev_hash: String, + timestamp_ms: u64, + miner: String, + reward: Amount, + vdf_rounds: u32, + vdf_output: String, + leader_proof: Option<crate::domain::LeaderProof>, + transactions: Vec<UiTransaction>, + hash: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct UiTransaction { + kind: &'static str, + from: String, + to: Option<String>, + amount: Amount, + fee: Amount, + inputs: Vec<UiTxInput>, + outputs: Vec<TxOutput>, + change: Vec<TxOutput>, + signature: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct UiTxInput { + outpoint: OutPoint, + owner: String, + signature: String, + amount: Option<Amount>, + address: Option<String>, +} + pub async fn serve( node: SharedNode, peers: SharedPeerBook, @@ -133,6 +179,7 @@ pub async fn serve( .route("/api/wallet/generate", post(api_wallet_generate_form)) .route("/api/wallet/import", post(api_wallet_import_form)) .route("/api/wallet/transactions", get(api_wallet_transactions)) + .route("/api/wallet/utxos", get(api_wallet_utxos)) .route("/api/mempool", get(api_mempool)) .route("/api/peers", get(api_peers).post(api_peer_form)) .route("/api/p2p/metrics", get(api_p2p_metrics)) @@ -185,17 +232,24 @@ async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { async fn api_blocks( State(state): State<HttpState>, Query(query): Query<BlocksQuery>, -) -> Json<Vec<Block>> { +) -> Json<Vec<UiBlock>> { let limit = query .limit .unwrap_or(EXPLORER_PAGE_LIMIT) .min(EXPLORER_LIMIT); let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); let blocks = match query.before_height { Some(before_height) => node.blocks_before(before_height, limit), None => node.recent_blocks(limit), }; - Json(blocks) + Json(ui_blocks( + blocks, + &snapshot.genesis_allocations, + &snapshot.blocks, + &pending, + )) } async fn api_config(State(state): State<HttpState>) -> Json<UiConfig> { @@ -206,21 +260,57 @@ async fn api_wallet_setup(State(state): State<HttpState>) -> Json<WalletSetupRes wallet_setup_json(wallet_setup_response(&state).await) } -async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<Transaction>> { - Json(state.node.lock().await.pending_transactions()) +async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<UiTransaction>> { + let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending); + Json( + pending + .iter() + .map(|tx| ui_transaction(tx, &outputs)) + .collect(), + ) } async fn api_wallet_transactions( State(state): State<HttpState>, ) -> Json<Vec<WalletTransactionRow>> { let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending); Json(wallet_transaction_rows( node.wallet_address(), - node.pending_transactions(), - node.chain(), + pending, + &snapshot.blocks, + &outputs, )) } +async fn api_wallet_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxoRow>> { + let node = state.node.lock().await; + let wallet = node.wallet_address().to_string(); + let mut utxos = node + .ledger() + .utxos_for_address(&wallet) + .into_iter() + .map(|(outpoint, output)| WalletUtxoRow { + outpoint, + address: output.address, + amount: output.amount, + }) + .collect::<Vec<_>>(); + utxos.sort_by(|left, right| { + right + .amount + .cmp(&left.amount) + .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) + .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) + }); + Json(utxos) +} + async fn api_peers(State(state): State<HttpState>) -> Json<Vec<PeerInfo>> { Json(state.peers.lock().await.list()) } @@ -358,18 +448,26 @@ fn wallet_transaction_rows( wallet: &str, pending: Vec<Transaction>, chain: &[Block], + outputs: &BTreeMap<OutPoint, TxOutput>, ) -> Vec<WalletTransactionRow> { let mut rows = Vec::new(); for (index, tx) in pending.iter().enumerate() { - if let Some(row) = wallet_transaction_row(wallet, tx, "pending", None) { + if let Some(row) = wallet_transaction_row(wallet, tx, outputs, "pending", None, None) { rows.push((u128::MAX - index as u128, row)); } } for block in chain { for (index, tx) in block.transactions.iter().rev().enumerate() { - if let Some(row) = wallet_transaction_row(wallet, tx, "confirmed", Some(block.height)) { + if let Some(row) = wallet_transaction_row( + wallet, + tx, + outputs, + "confirmed", + Some(block.height), + Some(block.miner.clone()), + ) { rows.push((block.height as u128 * 10_000 + index as u128, row)); } } @@ -382,8 +480,10 @@ fn wallet_transaction_rows( fn wallet_transaction_row( wallet: &str, tx: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, status: &'static str, block_height: Option<u64>, + block_miner: Option<String>, ) -> Option<WalletTransactionRow> { match tx { Transaction::Transfer { @@ -397,12 +497,13 @@ fn wallet_transaction_row( to: tx.to().map(str::to_string), amount: tx.amount(), fee: *fee, - inputs: inputs.clone(), + inputs: ui_inputs(inputs, outputs_by_outpoint), outputs: outputs.clone(), change: Vec::new(), signature: signature.clone(), status, block_height, + block_miner, direction: if tx.to() == Some(wallet) { "received" } else { @@ -413,6 +514,169 @@ fn wallet_transaction_row( } } +fn ui_blocks( + blocks: Vec<Block>, + genesis_allocations: &BTreeMap<String, Amount>, + chain: &[Block], + pending: &[Transaction], +) -> Vec<UiBlock> { + let outputs = known_output_index(genesis_allocations, chain, pending); + blocks + .into_iter() + .map(|block| ui_block(block, &outputs)) + .collect() +} + +fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock { + UiBlock { + height: block.height, + prev_hash: block.prev_hash, + timestamp_ms: block.timestamp_ms, + miner: block.miner, + reward: block.reward, + vdf_rounds: block.vdf_rounds, + vdf_output: block.vdf_output, + leader_proof: block.leader_proof, + transactions: block + .transactions + .iter() + .map(|tx| ui_transaction(tx, outputs)) + .collect(), + hash: block.hash, + } +} + +fn ui_transaction( + transaction: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> UiTransaction { + match transaction { + Transaction::Transfer { + inputs, + outputs, + fee, + signature, + } => UiTransaction { + kind: "transfer", + from: transaction.sender().to_string(), + to: transaction.to().map(str::to_string), + amount: transaction.amount(), + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: outputs.clone(), + change: Vec::new(), + signature: signature.clone(), + }, + Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + } => UiTransaction { + kind: "burn", + from: transaction.sender().to_string(), + to: None, + amount: *amount, + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: Vec::new(), + change: change.clone(), + signature: signature.clone(), + }, + } +} + +fn ui_inputs( + inputs: &[TxInput], + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> Vec<UiTxInput> { + inputs + .iter() + .map(|input| { + let spent_output = outputs_by_outpoint.get(&input.outpoint); + UiTxInput { + outpoint: input.outpoint.clone(), + owner: input.owner.clone(), + signature: input.signature.clone(), + amount: spent_output.map(|output| output.amount), + address: spent_output.map(|output| output.address.clone()), + } + }) + .collect() +} + +fn known_output_index( + genesis_allocations: &BTreeMap<String, Amount>, + chain: &[Block], + pending: &[Transaction], +) -> BTreeMap<OutPoint, TxOutput> { + let mut outputs = BTreeMap::new(); + for (address, amount) in genesis_allocations { + if *amount == 0 { + continue; + } + outputs.insert( + genesis_allocation_outpoint(address), + TxOutput { + address: address.clone(), + amount: *amount, + }, + ); + } + for block in chain { + for transaction in &block.transactions { + index_transaction_outputs(&mut outputs, transaction); + } + if block.reward > 0 { + outputs.insert( + reward_outpoint(&block.hash), + TxOutput { + address: block.miner.clone(), + amount: block.reward, + }, + ); + } + } + for transaction in pending { + index_transaction_outputs(&mut outputs, transaction); + } + outputs +} + +fn index_transaction_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &Transaction, +) { + let created_outputs = match transaction { + Transaction::Transfer { outputs, .. } => outputs, + Transaction::Burn { change, .. } => change, + }; + for (index, output) in created_outputs.iter().enumerate() { + outputs.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } +} + +fn genesis_allocation_outpoint(address: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("luun-genesis-allocation:{address}")), + index: 0, + } +} + +fn reward_outpoint(block_hash: &str) -> OutPoint { + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX, + } +} + async fn replace_setup_wallet_with_generated_seed( state: &HttpState, ) -> Result<WalletSetupResponse> { @@ -618,7 +882,8 @@ const INDEX_HTML: &str = r#"<!doctype html> .setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } - .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; } + .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; color: inherit; cursor: pointer; } + .wallet-balance-line:hover, .wallet-balance-line:focus-visible { border-color: #d5f55f; outline: none; } .wallet-balance-line .tx-value { font-size: 16px; font-weight: 850; } .mining-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; } .mining-form { width: 100%; display: grid; grid-template-columns: minmax(220px, .45fr) minmax(280px, 1fr); gap: 14px; align-items: end; } @@ -682,7 +947,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .mempool-item { flex: 0 0 220px; } .tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); } .tx-modal-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; } - .tx-modal-title { display: grid; gap: 6px; min-width: 0; } + .tx-modal-title { display: grid; justify-items: start; gap: 6px; min-width: 0; } .tx-modal-title h2 { margin: 0; } .tx-modal-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-bottom: 12px; } .utxo-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 12px; align-items: stretch; } @@ -690,11 +955,14 @@ const INDEX_HTML: &str = r#"<!doctype html> .utxo-column h3 { margin: 0; color: #8d989f; font-size: 11px; text-transform: uppercase; } .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-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; } .utxo-arrow { display: grid; place-items: center; color: #d5f55f; font-size: 24px; font-weight: 900; } .tx-modal-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 10px; color: #8d989f; } + .utxo-list { display: grid; gap: 8px; } + .wallet-utxo-row { display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } @media (max-width: 760px) { .utxo-flow { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid { grid-template-columns: 1fr; } } @media (max-width: 920px) { .mining-form { grid-template-columns: 1fr; } } @@ -714,10 +982,10 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/luun-ui.js?v=37"></script> + <script defer src="/assets/luun-ui.js?v=42"></script> <script defer src="/assets/alpine.min.js"></script> </head> -<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeTransactionModal()" x-cloak> +<body x-data="luunApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> <div class="app-shell"> <aside class="sidebar" aria-label="Luun navigation"> <div class="brand-mark" title="Luun">L</div> @@ -753,10 +1021,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <section x-show="tab === 'wallet'"> <div class="page-title"> - <div class="wallet-balance-line"> + <button class="wallet-balance-line" type="button" @click="openWalletUtxosModal" title="Show wallet UTXOs"> <span class="tx-label">Balance</span> <span class="tx-value money">LUUN <span x-text="status.wallet_balance ?? '-'"></span></span> - </div> + </button> </div> <div class="wallet-grid"> <div class="wallet-actions"> @@ -859,6 +1127,14 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="metric"><div class="label">Inventory Rx</div><div class="value" x-text="p2pMetrics.inventory_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Data Rx</div><div class="value" x-text="p2pMetrics.data_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Control Rx</div><div class="value" x-text="p2pMetrics.control_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Ack Sent</div><div class="value" x-text="p2pMetrics.transaction_ack_envelopes_sent ?? 0"></div></div> + <div class="metric"><div class="label">Tx Ack Rx</div><div class="value" x-text="p2pMetrics.transaction_ack_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Accepted Sent</div><div class="value" x-text="p2pMetrics.transactions_accepted_sent ?? 0"></div></div> + <div class="metric"><div class="label">Tx Accepted Rx</div><div class="value" x-text="p2pMetrics.transactions_accepted_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Rejected Sent</div><div class="value" x-text="p2pMetrics.transactions_rejected_sent ?? 0"></div></div> + <div class="metric"><div class="label">Tx Rejected Rx</div><div class="value" x-text="p2pMetrics.transactions_rejected_received ?? 0"></div></div> + <div class="metric"><div class="label">Tx Retries</div><div class="value" x-text="p2pMetrics.transaction_retries_sent ?? 0"></div></div> + <div class="metric"><div class="label">Tx Ack Pending</div><div class="value" x-text="p2pMetrics.transaction_ack_pending ?? 0"></div></div> </div> <div class="metric-context"> <div class="tx-field"><span class="tx-label">Last Failure</span><span class="tx-value text" x-text="p2pMetrics.last_session_failure || '-'"></span></div> @@ -932,7 +1208,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-list"> <h3>Transactions</h3> <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> - <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height })"> + <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Block', blockHeight: selectedBlock.height, blockMiner: selectedBlock.miner })"> <span class="pill" :class="tx.kind" x-text="tx.kind"></span> <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">LUUN <span x-text="txAmount(tx)"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">LUUN <span x-text="tx.fee ?? 0"></span></span></div> @@ -967,6 +1243,27 @@ const INDEX_HTML: &str = r#"<!doctype html> </section> </main> </div> + <div class="setup-overlay transaction-overlay" x-show="showWalletUtxos" x-transition.opacity @click.self="closeWalletUtxosModal()" role="dialog" aria-modal="true" aria-labelledby="wallet-utxos-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="wallet-utxos-title">Wallet UTXOs</h2> + <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">LUUN <span x-text="status.wallet_balance ?? '-'"></span></span></div> + </div> + <button type="button" @click="closeWalletUtxosModal">Close</button> + </div> + <div class="utxo-list"> + <template x-for="utxo in walletUtxos" :key="`${utxo.outpoint.txid}:${utxo.outpoint.index}`"> + <div class="wallet-utxo-row"> + <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">LUUN <span x-text="utxo.amount"></span></span></div> + <div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div> + <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="utxo.address"></code></div> + </div> + </template> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0">No wallet UTXOs</div> + </div> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="selectedTransaction" x-transition.opacity @click.self="closeTransactionModal()" role="dialog" aria-modal="true" aria-labelledby="tx-modal-title"> <section class="tx-modal"> <div class="tx-modal-head"> @@ -991,6 +1288,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <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-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="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> @@ -1001,11 +1299,14 @@ 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' }"> + <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }"> <div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div> <div class="utxo-node-amount">LUUN <span x-text="output.amount"></span></div> <template x-if="output.address"> - <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="output.address"></code></div> + <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="output.address"></code></div> + </template> + <template x-if="output.detail"> + <div class="tx-field"><span class="tx-label" x-text="output.detailLabel"></span><code class="tx-value hash" x-text="output.detail"></code></div> </template> </div> </template> @@ -1151,7 +1452,7 @@ mod tests { allocations.insert(alice.address().to_string(), 100); allocations.insert(bob.address().to_string(), 100); allocations.insert(carol.address().to_string(), 100); - let ledger = crate::domain::Ledger::new(allocations, 1); + let ledger = crate::domain::Ledger::new(allocations.clone(), 1); let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); @@ -1162,13 +1463,22 @@ mod tests { fake_block(32, vec![carol_burn]), ]; - let rows = wallet_transaction_rows(alice.address(), vec![pending_burn.clone()], &chain); + let outputs = + super::known_output_index(&allocations, &chain, std::slice::from_ref(&pending_burn)); + let rows = wallet_transaction_rows( + alice.address(), + vec![pending_burn.clone()], + &chain, + &outputs, + ); assert_eq!(rows.len(), 1); assert_ne!(rows[0].signature, pending_burn.signature()); assert_eq!(rows[0].signature, old_received.signature()); + assert_eq!(rows[0].inputs[0].amount, Some(100)); assert_eq!(rows[0].status, "confirmed"); assert_eq!(rows[0].block_height, Some(31)); + assert_eq!(rows[0].block_miner.as_deref(), Some("miner")); assert_eq!(rows[0].direction, "received"); } diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, io::ErrorKind, net::SocketAddr, sync::{ @@ -24,9 +24,9 @@ use tokio::{ use crate::{ app::{ BlockInventory, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, SharedNode, - SharedPeerBook, + SharedPeerBook, TransactionRejection, }, - domain::{Block, ChainSnapshot, Ledger, verify_vdf}, + domain::{Block, ChainSnapshot, Ledger, Transaction, verify_vdf}, }; const MAX_BLOCK_BATCH: usize = 128; @@ -39,6 +39,7 @@ const PEER_QUEUE_SIZE: usize = 256; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); const SESSION_SYNC_INTERVAL: Duration = Duration::from_secs(2); +const TRANSACTION_ACK_RETRY_INTERVAL: Duration = Duration::from_secs(3); const JOIN_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_JOIN_RESPONSE_ENVELOPES: usize = 16; const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1); @@ -57,10 +58,18 @@ struct GossipNetworkInner { peers: SharedPeerBook, listen_addr: SocketAddr, sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>, + tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>, metrics: P2pMetricsCounters, } #[derive(Default)] +struct PeerTransactionDelivery { + accepted: BTreeSet<String>, + rejected: BTreeSet<String>, + sent: BTreeMap<String, Instant>, +} + +#[derive(Default)] struct P2pMetricsCounters { inbound_sessions_started: AtomicU64, outbound_connect_attempts: AtomicU64, @@ -83,6 +92,13 @@ struct P2pMetricsCounters { self_peer_skips: AtomicU64, outbound_queue_full: AtomicU64, outbound_queue_closed: AtomicU64, + transaction_ack_envelopes_sent: AtomicU64, + transaction_ack_envelopes_received: AtomicU64, + transactions_accepted_sent: AtomicU64, + transactions_accepted_received: AtomicU64, + transactions_rejected_sent: AtomicU64, + transactions_rejected_received: AtomicU64, + transaction_retries_sent: AtomicU64, last_session_failure: StdMutex<Option<String>>, last_empty_frame_remote: StdMutex<Option<String>>, last_parse_error: StdMutex<Option<String>>, @@ -111,6 +127,14 @@ pub struct P2pMetrics { pub self_peer_skips: u64, pub outbound_queue_full: u64, pub outbound_queue_closed: u64, + pub transaction_ack_envelopes_sent: u64, + pub transaction_ack_envelopes_received: u64, + pub transactions_accepted_sent: u64, + pub transactions_accepted_received: u64, + pub transactions_rejected_sent: u64, + pub transactions_rejected_received: u64, + pub transaction_retries_sent: u64, + pub transaction_ack_pending: u64, pub last_session_failure: Option<String>, pub last_empty_frame_remote: Option<String>, pub last_parse_error: Option<String>, @@ -156,6 +180,22 @@ impl P2pMetricsCounters { self_peer_skips: self.self_peer_skips.load(Ordering::Relaxed), outbound_queue_full: self.outbound_queue_full.load(Ordering::Relaxed), outbound_queue_closed: self.outbound_queue_closed.load(Ordering::Relaxed), + transaction_ack_envelopes_sent: self + .transaction_ack_envelopes_sent + .load(Ordering::Relaxed), + transaction_ack_envelopes_received: self + .transaction_ack_envelopes_received + .load(Ordering::Relaxed), + transactions_accepted_sent: self.transactions_accepted_sent.load(Ordering::Relaxed), + transactions_accepted_received: self + .transactions_accepted_received + .load(Ordering::Relaxed), + transactions_rejected_sent: self.transactions_rejected_sent.load(Ordering::Relaxed), + transactions_rejected_received: self + .transactions_rejected_received + .load(Ordering::Relaxed), + transaction_retries_sent: self.transaction_retries_sent.load(Ordering::Relaxed), + transaction_ack_pending: 0, last_session_failure: self .last_session_failure .lock() @@ -186,6 +226,7 @@ impl GossipNetwork { peers, listen_addr: addr, sessions: Mutex::new(BTreeMap::new()), + tx_delivery: Mutex::new(BTreeMap::new()), metrics: P2pMetricsCounters::default(), }), }; @@ -197,7 +238,19 @@ impl GossipNetwork { } pub fn metrics(&self) -> P2pMetrics { - self.inner.metrics.snapshot() + let mut metrics = self.inner.metrics.snapshot(); + metrics.transaction_ack_pending = self + .inner + .tx_delivery + .try_lock() + .map(|delivery| { + delivery + .values() + .map(|peer_delivery| peer_delivery.sent.len() as u64) + .sum() + }) + .unwrap_or(0); + metrics } pub async fn broadcast(&self, envelopes: Vec<GossipEnvelope>) -> Result<()> { @@ -227,6 +280,85 @@ impl GossipNetwork { Ok(()) } + async fn record_sent_transactions(&self, peer: &str, envelopes: &[GossipEnvelope]) { + let now = Instant::now(); + let mut delivery = self.inner.tx_delivery.lock().await; + let peer_delivery = delivery.entry(peer.to_string()).or_default(); + for (signature, _) in transactions_in_envelopes(envelopes) { + if peer_delivery.accepted.contains(&signature) + || peer_delivery.rejected.contains(&signature) + { + continue; + } + peer_delivery.sent.insert(signature, now); + } + } + + async fn record_transaction_ack( + &self, + peer: &str, + accepted: &[String], + rejected: &[TransactionRejection], + ) { + if !accepted.is_empty() || !rejected.is_empty() { + P2pMetricsCounters::inc(&self.inner.metrics.transaction_ack_envelopes_received); + P2pMetricsCounters::add( + &self.inner.metrics.transactions_accepted_received, + accepted.len() as u64, + ); + P2pMetricsCounters::add( + &self.inner.metrics.transactions_rejected_received, + rejected.len() as u64, + ); + } + let mut delivery = self.inner.tx_delivery.lock().await; + let peer_delivery = delivery.entry(peer.to_string()).or_default(); + for signature in accepted { + peer_delivery.accepted.insert(signature.clone()); + peer_delivery.rejected.remove(signature); + peer_delivery.sent.remove(signature); + } + for rejection in rejected { + peer_delivery.rejected.insert(rejection.signature.clone()); + peer_delivery.accepted.remove(&rejection.signature); + peer_delivery.sent.remove(&rejection.signature); + } + } + + async fn pending_transactions_for_retry(&self, peer: &str) -> Vec<Transaction> { + let pending = self.inner.node.lock().await.pending_transactions(); + let pending_signatures = pending + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<BTreeSet<_>>(); + let now = Instant::now(); + let mut delivery = self.inner.tx_delivery.lock().await; + let peer_delivery = delivery.entry(peer.to_string()).or_default(); + peer_delivery + .sent + .retain(|signature, _| pending_signatures.contains(signature)); + + let retry = pending + .into_iter() + .filter(|tx| { + let signature = tx.signature(); + if peer_delivery.accepted.contains(signature) + || peer_delivery.rejected.contains(signature) + { + return false; + } + peer_delivery.sent.get(signature).is_none_or(|last_sent| { + now.duration_since(*last_sent) >= TRANSACTION_ACK_RETRY_INTERVAL + }) + }) + .collect::<Vec<_>>(); + + for tx in &retry { + peer_delivery.sent.insert(tx.signature().to_string(), now); + } + retry + } + async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> { let mut txs = Vec::new(); let mut blocks = Vec::new(); @@ -242,6 +374,7 @@ impl GossipNetwork { .map(|tx| tx.signature().to_string()) .collect::<Vec<_>>(), ); + passthrough.push(GossipEnvelope::Transactions { transactions }); } GossipEnvelope::Block(block) => blocks.push(BlockInventory { height: block.height, @@ -536,6 +669,7 @@ async fn session_loop( ).await; write_payload(&mut writer, &payload).await?; if let Some(peer) = &known_peer { + network.record_sent_transactions(peer, &payload).await; network.inner.peers.lock().await.record_sent(peer, payload.len() as u64); } } @@ -550,6 +684,15 @@ async fn session_loop( *status = updated_status; } } + if let Some(peer) = &known_peer { + let transactions = network.pending_transactions_for_retry(peer).await; + if !transactions.is_empty() { + let retry = GossipEnvelope::Transactions { transactions }; + write_envelope(&mut writer, &retry).await?; + P2pMetricsCounters::inc(&network.inner.metrics.transaction_retries_sent); + network.inner.peers.lock().await.record_sent(peer, 1); + } + } } envelope = read_session_envelope(&network, &connection_label, &mut reader) => { let Some(envelope) = envelope? else { @@ -643,6 +786,21 @@ async fn process_envelope( GossipEnvelope::PeerList { peers } => { apply_peer_list(network, remote_addr, peers).await?; } + GossipEnvelope::Transaction(tx) => { + process_transactions(network, writer, remote_addr, known_peer, vec![tx]).await?; + } + GossipEnvelope::Transactions { transactions } => { + process_transactions(network, writer, remote_addr, known_peer, transactions).await?; + } + GossipEnvelope::TransactionAck { accepted, rejected } => { + let peer = known_peer + .clone() + .unwrap_or_else(|| remote_addr.to_string()); + network + .record_transaction_ack(&peer, &accepted, &rejected) + .await; + record_inbound_result(network, known_peer, remote_addr, Ok(())).await; + } GossipEnvelope::Block(block) => { let needs_vdf = { let node = network.inner.node.lock().await; @@ -707,6 +865,62 @@ async fn process_envelope( Ok(()) } +async fn process_transactions( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + remote_addr: SocketAddr, + known_peer: &Option<String>, + transactions: Vec<Transaction>, +) -> Result<()> { + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + { + let mut node = network.inner.node.lock().await; + for tx in transactions { + let signature = tx.signature().to_string(); + match node.receive_transaction(tx) { + Ok(_) => accepted.push(signature), + Err(error) => rejected.push(TransactionRejection { + signature, + reason: format!("{error:#}"), + }), + } + } + } + + if !accepted.is_empty() || !rejected.is_empty() { + let ack = GossipEnvelope::TransactionAck { + accepted: accepted.clone(), + rejected: rejected.clone(), + }; + write_envelope(writer, &ack).await?; + P2pMetricsCounters::inc(&network.inner.metrics.transaction_ack_envelopes_sent); + P2pMetricsCounters::add( + &network.inner.metrics.transactions_accepted_sent, + accepted.len() as u64, + ); + P2pMetricsCounters::add( + &network.inner.metrics.transactions_rejected_sent, + rejected.len() as u64, + ); + } + + for rejection in rejected { + let peer = known_peer + .clone() + .unwrap_or_else(|| remote_addr.to_string()); + network + .inner + .peers + .lock() + .await + .record_inbound_error(&peer, rejection.reason); + } + network.forward_outbox().await; + record_inbound_result(network, known_peer, remote_addr, Ok(())).await; + Ok(()) +} + async fn maybe_request_catchup( network: &GossipNetwork, writer: &mut OwnedWriteHalf, @@ -802,6 +1016,25 @@ async fn write_payload(writer: &mut OwnedWriteHalf, payload: &[GossipEnvelope]) Ok(()) } +fn transactions_in_envelopes(envelopes: &[GossipEnvelope]) -> Vec<(String, Transaction)> { + let mut transactions = Vec::new(); + for envelope in envelopes { + match envelope { + GossipEnvelope::Transaction(tx) => { + transactions.push((tx.signature().to_string(), tx.clone())); + } + GossipEnvelope::Transactions { transactions: txs } => { + transactions.extend( + txs.iter() + .map(|tx| (tx.signature().to_string(), tx.clone())), + ); + } + _ => {} + } + } + transactions +} + async fn write_envelope(writer: &mut OwnedWriteHalf, envelope: &GossipEnvelope) -> Result<()> { let line = serde_json::to_string(envelope)?; if line.len() > MAX_GOSSIP_LINE_BYTES { @@ -921,6 +1154,7 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip | GossipEnvelope::BlockRangeRequest { .. } | GossipEnvelope::TransactionRequest { .. } | GossipEnvelope::BlockRequest { .. } + | GossipEnvelope::TransactionAck { .. } | GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerList { .. } => { P2pMetricsCounters::inc(&metrics.control_envelopes_received); @@ -952,6 +1186,18 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { ensure_len("transaction inventory", txs.len(), MAX_INVENTORY_ITEMS)?; ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?; } + GossipEnvelope::TransactionAck { accepted, rejected } => { + ensure_len( + "transaction ack accepted", + accepted.len(), + MAX_OBJECT_REQUESTS, + )?; + ensure_len( + "transaction ack rejected", + rejected.len(), + MAX_OBJECT_REQUESTS, + )?; + } GossipEnvelope::Transactions { transactions } => { ensure_len("transaction batch", transactions.len(), MAX_OBJECT_REQUESTS)?; } @@ -1506,6 +1752,18 @@ mod tests { } #[test] + fn oversized_transaction_ack_is_rejected_before_processing() { + let envelope = GossipEnvelope::TransactionAck { + accepted: vec!["sig".to_string(); MAX_OBJECT_REQUESTS + 1], + rejected: Vec::new(), + }; + + let error = validate_envelope_limits(&envelope).unwrap_err(); + + assert!(error.to_string().contains("transaction ack accepted")); + } + + #[test] fn parser_applies_envelope_limits() { let line = serde_json::to_string(&GossipEnvelope::BlockRequest { hashes: vec!["hash".to_string(); MAX_OBJECT_REQUESTS + 1], @@ -1547,13 +1805,20 @@ mod tests { &metrics, &GossipEnvelope::Blocks { blocks: Vec::new() }, ); + super::record_received_envelope_kind( + &metrics, + &GossipEnvelope::TransactionAck { + accepted: Vec::new(), + rejected: Vec::new(), + }, + ); super::record_received_envelope_kind(&metrics, &GossipEnvelope::ChainSnapshotRequest); let snapshot = metrics.snapshot(); assert_eq!(snapshot.peer_status_envelopes_received, 1); assert_eq!(snapshot.inventory_envelopes_received, 1); assert_eq!(snapshot.data_envelopes_received, 1); - assert_eq!(snapshot.control_envelopes_received, 1); + assert_eq!(snapshot.control_envelopes_received, 2); } #[tokio::test] @@ -1674,6 +1939,7 @@ mod tests { peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -1714,6 +1980,88 @@ mod tests { } #[tokio::test] + async fn transaction_batch_gossip_keeps_full_transactions_for_mempool_repair() { + let alice = Wallet::from_seed("mempool-repair-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: Arc::clone(&node), + peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), + listen_addr: "127.0.0.1:9544".parse().unwrap(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + metrics: super::P2pMetricsCounters::default(), + }), + }; + + let (tx, signature) = { + let mut node = node.lock().await; + let tx = node.burn(1).unwrap(); + (tx.clone(), tx.signature().to_string()) + }; + + let prepared = network + .prepare_gossip(vec![GossipEnvelope::Transactions { + transactions: vec![tx], + }]) + .await; + + assert_eq!(prepared.len(), 2); + assert!(matches!( + &prepared[0], + GossipEnvelope::Transactions { transactions } if transactions.len() == 1 + )); + match &prepared[1] { + GossipEnvelope::Inventory { txs, blocks } => { + assert_eq!(txs, &[signature]); + assert!(blocks.is_empty()); + } + other => panic!("expected inventory, got {other:?}"), + } + } + + #[tokio::test] + async fn unacked_pending_transactions_retry_until_peer_accepts() { + let alice = Wallet::from_seed("tx-ack-retry-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: Arc::clone(&node), + peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), + listen_addr: "127.0.0.1:9544".parse().unwrap(), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let peer = "127.0.0.1:9545"; + let signature = { + let mut node = node.lock().await; + node.burn(1).unwrap().signature().to_string() + }; + + let first_retry = network.pending_transactions_for_retry(peer).await; + assert_eq!(first_retry.len(), 1); + assert_eq!(first_retry[0].signature(), signature); + assert_eq!(network.metrics().transaction_ack_pending, 1); + let immediate_retry = network.pending_transactions_for_retry(peer).await; + assert!(immediate_retry.is_empty()); + + network + .record_transaction_ack(peer, std::slice::from_ref(&signature), &[]) + .await; + let after_ack_retry = network.pending_transactions_for_retry(peer).await; + + assert!(after_ack_retry.is_empty()); + let metrics = network.metrics(); + assert_eq!(metrics.transaction_ack_pending, 0); + assert_eq!(metrics.transaction_ack_envelopes_received, 1); + assert_eq!(metrics.transactions_accepted_received, 1); + } + + #[tokio::test] async fn inventory_requests_only_missing_objects() { let alice = Wallet::from_seed("missing-inv-alice"); let bob = Wallet::from_seed("missing-inv-bob"); @@ -1822,6 +2170,7 @@ mod tests { peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "127.0.0.1:9544".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -1888,6 +2237,7 @@ mod tests { peers: Arc::clone(&peers), listen_addr: "127.0.0.1:9544".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -1952,6 +2302,7 @@ mod tests { peers, listen_addr: "127.0.0.1:9544".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -1977,6 +2328,7 @@ mod tests { peers: Arc::clone(&peers), listen_addr: "127.0.0.1:9544".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -2006,6 +2358,7 @@ mod tests { peers: Arc::clone(&peers), listen_addr: "0.0.0.0:9545".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; @@ -2035,6 +2388,7 @@ mod tests { peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), listen_addr: "0.0.0.0:9545".parse().unwrap(), sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), metrics: super::P2pMetricsCounters::default(), }), }; diff --git a/src/app.rs b/src/app.rs @@ -56,6 +56,10 @@ pub enum GossipEnvelope { txs: Vec<String>, blocks: Vec<BlockInventory>, }, + TransactionAck { + accepted: Vec<String>, + rejected: Vec<TransactionRejection>, + }, Transaction(Transaction), Transactions { transactions: Vec<Transaction>, @@ -90,6 +94,12 @@ pub struct BlockInventory { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TransactionRejection { + pub signature: String, + pub reason: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeStatus { pub wallet_address: String, pub wallet_balance: Amount, @@ -221,19 +231,11 @@ impl NodeCore { } pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> { - let txs = self - .ledger - .pending() - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(); - if txs.is_empty() { + let transactions = self.ledger.pending().to_vec(); + if transactions.is_empty() { Vec::new() } else { - vec![GossipEnvelope::Inventory { - txs, - blocks: Vec::new(), - }] + vec![GossipEnvelope::Transactions { transactions }] } } @@ -435,6 +437,14 @@ impl NodeCore { Ok(tx) } + pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> { + let accepted = self.ledger.submit_transaction(tx.clone())?; + if accepted { + self.outbox.push(GossipEnvelope::Transaction(tx)); + } + Ok(accepted) + } + pub fn mine_one(&mut self) -> Result<Block> { self.mine_one_at(now_ms()) } @@ -520,8 +530,12 @@ impl NodeCore { let amount = if balance >= self.burn_per_block.saturating_add(fee) { self.burn_per_block } else { - self.burn_per_block.saturating_sub(fee) + balance.saturating_sub(fee) }; + if amount == 0 { + self.last_auto_burn_height = Some(current_height); + return Ok(None); + } let tx = self.burn_with_fee(amount, fee)?; self.last_auto_burn_height = Some(current_height); Ok(Some(tx)) @@ -553,18 +567,15 @@ impl NodeCore { | GossipEnvelope::BlockRangeRequest { .. } | GossipEnvelope::TransactionRequest { .. } | GossipEnvelope::BlockRequest { .. } + | GossipEnvelope::TransactionAck { .. } | GossipEnvelope::Inventory { .. } => Ok(()), GossipEnvelope::Transaction(tx) => { - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx)); - } + self.receive_transaction(tx)?; Ok(()) } GossipEnvelope::Transactions { transactions } => { for tx in transactions { - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx)); - } + self.receive_transaction(tx)?; } Ok(()) } diff --git a/src/domain.rs b/src/domain.rs @@ -1147,6 +1147,14 @@ impl Ledger { .sum() } + pub fn utxos_for_address(&self, address: &str) -> Vec<(OutPoint, TxOutput)> { + self.utxos + .iter() + .filter(|(_, output)| output.address == address) + .map(|(outpoint, output)| (outpoint.clone(), output.clone())) + .collect() + } + pub fn next_nonce(&self, address: &str) -> u64 { self.utxos .keys() @@ -2319,6 +2327,37 @@ mod tests { } #[test] + fn wallet_utxos_only_include_outputs_owned_by_address() { + let alice = Wallet::from_seed("wallet-utxos-alice"); + let bob = Wallet::from_seed("wallet-utxos-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3]); + ledger.utxos.insert( + OutPoint { + txid: "bob-utxo".to_string(), + index: 0, + }, + TxOutput { + address: bob.address().to_string(), + amount: 5, + }, + ); + + let alice_utxos = ledger.utxos_for_address(alice.address()); + let total = alice_utxos + .iter() + .map(|(_, output)| output.amount) + .sum::<Amount>(); + + assert_eq!(alice_utxos.len(), 2); + assert_eq!(total, ledger.balance_of(alice.address())); + assert!( + alice_utxos + .iter() + .all(|(_, output)| output.address == alice.address()) + ); + } + + #[test] fn transfer_combines_multiple_small_utxos_to_cover_amount_and_fee() { let alice = Wallet::from_seed("combine-small-utxos-alice"); let bob = Wallet::from_seed("combine-small-utxos-bob"); diff --git a/tests/luun.rs b/tests/luun.rs @@ -295,6 +295,37 @@ fn block_reward_is_fixed_at_one_hundred_luun() { } #[test] +fn burn_larger_than_block_reward_is_paid_from_existing_utxos() { + let alice = Wallet::from_seed("large-burn-existing-utxos-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD + 50); + + let mut ledger = Ledger::new(allocations, 10); + let burn_amount = BLOCK_REWARD + 25; + let burn = ledger.build_burn(&alice, burn_amount, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert_eq!(block.transactions[0].amount(), burn_amount); + assert_eq!(block.reward, BLOCK_REWARD); + + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.balance_of(alice.address()), BLOCK_REWARD + 25); +} + +#[test] +fn burn_larger_than_existing_balance_cannot_use_next_block_reward() { + let alice = Wallet::from_seed("large-burn-cannot-use-next-reward-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD); + + let ledger = Ledger::new(allocations, 10); + let error = ledger.build_burn(&alice, BLOCK_REWARD + 1, 0).unwrap_err(); + + assert!(format!("{error:#}").contains("insufficient funds")); +} + +#[test] fn transaction_fees_are_paid_to_the_block_miner() { let alice = Wallet::from_seed("fee-miner-alice"); let bob = Wallet::from_seed("fee-payer-bob"); @@ -585,6 +616,27 @@ fn automatic_mining_uses_configured_burn_fee() { } #[test] +fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { + let alice = Wallet::from_seed("auto-burn-cap-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), BLOCK_REWARD); + let mut node = NodeCore::new(NodeConfig { + wallet: alice.clone(), + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: BLOCK_REWARD + 50, + burn_fee: 1, + }); + + let outcome = node.automatic_mine_once(1); + + assert_eq!(outcome.burned.as_ref().map(|tx| tx.amount()), Some(99)); + assert_eq!(outcome.burned.as_ref().map(|tx| tx.fee()), Some(1)); + assert!(outcome.block.is_some()); + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD + 1); +} + +#[test] fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); @@ -641,6 +693,46 @@ fn automatic_mining_waits_when_wallet_is_not_selected_leader() { } #[test] +fn waiting_wallet_gossips_pending_burn_to_selected_leader() { + let alice = Wallet::from_seed("deadlock-alice"); + let bob = Wallet::from_seed("deadlock-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + let ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(bob.address(), 1)], 25) + .unwrap(); + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(bob.address()) + ); + let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); + let mut bob_node = NodeCore::from_ledger(bob.clone(), ledger, DEFAULT_BURN_PER_BLOCK); + + let alice_outcome = alice_node.automatic_mine_once(1); + assert!(alice_outcome.burned.is_some()); + assert!(alice_outcome.block.is_none()); + assert!( + alice_outcome + .skipped_reason + .unwrap() + .contains(bob.address()) + ); + assert!(bob_node.ledger().pending().is_empty()); + + for envelope in alice_node.mempool_gossip() { + bob_node.receive(envelope).unwrap(); + } + + assert_eq!(bob_node.ledger().pending().len(), 1); + assert_eq!(bob_node.ledger().pending()[0].sender(), alice.address()); + let bob_outcome = bob_node.automatic_mine_once(1); + assert!(bob_outcome.skipped_reason.is_none()); + assert!(bob_outcome.block.is_some()); +} + +#[test] fn block_with_wrong_vdf_rounds_is_rejected() { let wallet = Wallet::from_seed("alice"); let mut genesis = BTreeMap::new();