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:
| M | assets/luun-ui.js | | | 40 | ++++++++++++++++++++++++++++++++++++++-- |
| M | src/adapters/http.rs | | | 354 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| M | src/adapters/p2p.rs | | | 364 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/app.rs | | | 47 | +++++++++++++++++++++++++++++------------------ |
| M | src/domain.rs | | | 39 | +++++++++++++++++++++++++++++++++++++++ |
| M | tests/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();