iuna

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

commit f790fad1f12fc1a9f51f72d4ab3055ca4e9de573
parent 3f509d0eb6c363cc6c441d887b37ec5d9474c3eb
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 28 Jul 2026 12:01:29 +0200

Reject conflicting mempool gossip acknowledgements

Diffstat:
Msrc/adapters/p2p.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/app.rs | 12++++++------
Msrc/domain.rs | 28++++++++++++++++++++++++----
Mtests/iuna.rs | 7++++++-
4 files changed, 103 insertions(+), 28 deletions(-)

diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -23,10 +23,10 @@ use tokio::{ use crate::{ app::{ - BlockInventory, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, ProtocolHello, SharedNode, - SharedPeerBook, TransactionRejection, + BlockInventory, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, ProtocolHello, + SharedNode, SharedPeerBook, TransactionRejection, }, - domain::{Block, ChainSnapshot, Ledger, Transaction, verify_vdf}, + domain::{Block, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, verify_vdf}, }; const MAX_BLOCK_BATCH: usize = 128; @@ -988,21 +988,10 @@ async fn process_transactions( known_peer: &Option<String>, transactions: Vec<Transaction>, ) -> Result<()> { - let mut accepted = Vec::new(); - let mut rejected = Vec::new(); - { + let (accepted, rejected) = { 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:#}"), - }), - } - } - } + receive_transactions_for_ack(&mut node, transactions) + }; if !accepted.is_empty() || !rejected.is_empty() { let ack = GossipEnvelope::TransactionAck { @@ -1043,6 +1032,33 @@ async fn process_transactions( Ok(()) } +fn receive_transactions_for_ack( + node: &mut NodeCore, + transactions: Vec<Transaction>, +) -> (Vec<String>, Vec<TransactionRejection>) { + let mut accepted = Vec::new(); + let mut rejected = Vec::new(); + for tx in transactions { + let signature = tx.signature().to_string(); + match node.receive_transaction(tx) { + Ok(TransactionSubmitOutcome::Added | TransactionSubmitOutcome::AlreadyKnown) => { + accepted.push(signature); + } + Ok(TransactionSubmitOutcome::ConflictsWithPending) => { + rejected.push(TransactionRejection { + signature, + reason: "transaction conflicts with pending mempool inputs".to_string(), + }); + } + Err(error) => rejected.push(TransactionRejection { + signature, + reason: format!("{error:#}"), + }), + } + } + (accepted, rejected) +} + async fn maybe_request_catchup( network: &GossipNetwork, writer: &mut OwnedWriteHalf, @@ -1180,6 +1196,7 @@ fn transaction_rejection_is_state_dependent(reason: &str) -> bool { "mempool is full", "anchor is not on this chain", "anchor is too old", + "conflict", "missing output", "not spendable", "insufficient funds", @@ -2352,6 +2369,38 @@ mod tests { } #[test] + fn conflicting_input_transaction_is_rejected_not_acked_as_accepted() { + let alice = Wallet::from_seed("tx-ack-conflict-alice"); + let bob = Wallet::from_seed("tx-ack-conflict-bob"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let ledger = Ledger::new(allocations, 25); + let first = ledger + .build_transfer(&alice, bob.address(), 100, 0) + .unwrap(); + let conflicting = ledger.build_burn(&alice, 100, 0).unwrap(); + let mut receiver = NodeCore::from_ledger(bob, ledger, 0); + + let (accepted, rejected) = super::receive_transactions_for_ack( + &mut receiver, + vec![first.clone(), conflicting.clone()], + ); + + assert_eq!(accepted, vec![first.signature().to_string()]); + assert_eq!(rejected.len(), 1); + assert_eq!(rejected[0].signature, conflicting.signature()); + assert!( + rejected[0].reason.contains("conflicts with pending"), + "{}", + rejected[0].reason + ); + assert_eq!(receiver.ledger().pending().len(), 1); + assert_eq!( + receiver.ledger().pending()[0].signature(), + first.signature() + ); + } + + #[test] fn transaction_rejection_classifier_only_scores_structural_invalidity() { for reason in [ "transaction signature is invalid", @@ -2375,6 +2424,7 @@ mod tests { "mempool is full", "mine transaction anchor is not on this chain", "mine transaction anchor is too old", + "transaction conflicts with pending mempool inputs", "transaction spends missing output abc:0", "selected UTXOs do not cover transfer amount plus fee", "insufficient funds for address", diff --git a/src/app.rs b/src/app.rs @@ -10,8 +10,8 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, - Ledger, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TxOutput, - VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Ledger, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, + TransactionSubmitOutcome, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -687,12 +687,12 @@ impl NodeCore { Ok(tx) } - pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> { - let accepted = self.ledger.submit_transaction(tx.clone())?; - if accepted { + pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { + let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; + if outcome.added() { self.outbox.push(GossipEnvelope::Transaction(tx)); } - Ok(accepted) + Ok(outcome) } fn build_burn_with_fee_rate( diff --git a/src/domain.rs b/src/domain.rs @@ -1124,6 +1124,19 @@ enum TransactionKind { Burn, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransactionSubmitOutcome { + Added, + AlreadyKnown, + ConflictsWithPending, +} + +impl TransactionSubmitOutcome { + pub fn added(self) -> bool { + matches!(self, Self::Added) + } +} + #[derive(Clone, Debug)] pub struct Ledger { chain: Vec<Block>, @@ -1744,15 +1757,22 @@ impl Ledger { } pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> { + Ok(self.submit_transaction_with_outcome(transaction)?.added()) + } + + pub fn submit_transaction_with_outcome( + &mut self, + transaction: Transaction, + ) -> Result<TransactionSubmitOutcome> { if self.has_transaction(transaction.signature()) { - return Ok(false); + return Ok(TransactionSubmitOutcome::AlreadyKnown); } transaction.verify_signature()?; self.validate_transaction_terms(&transaction)?; if transaction_inputs_spent_by(&transaction, &self.pending) { - return Ok(false); + return Ok(TransactionSubmitOutcome::ConflictsWithPending); } if self.pending.len() >= MAX_PENDING_TRANSACTIONS { @@ -1762,11 +1782,11 @@ impl Ledger { let mut utxos = self.utxos_after_valid_pending()?; if transaction_has_missing_inputs(&transaction, &utxos) { self.pending.push(transaction); - return Ok(true); + return Ok(TransactionSubmitOutcome::Added); } apply_transaction(&transaction, &mut utxos)?; self.pending.push(transaction); - Ok(true) + Ok(TransactionSubmitOutcome::Added) } pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<Block> { diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -5,7 +5,8 @@ use iuna::{ app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, domain::{ Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, - MICRO_IUNA, MINE_REWARD, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + MICRO_IUNA, MINE_REWARD, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + verify_vdf, }, }; use tempfile::tempdir; @@ -1113,6 +1114,10 @@ fn conflicting_utxo_spends_are_not_accepted_together() { let first = burn_tx(&ledger, &wallet, 3); let conflicting = burn_tx(&ledger, &wallet, 4); ledger.submit_transaction(first.clone()).unwrap(); + let outcome = ledger + .submit_transaction_with_outcome(conflicting.clone()) + .unwrap(); + assert_eq!(outcome, TransactionSubmitOutcome::ConflictsWithPending); assert!(!ledger.submit_transaction(conflicting).unwrap()); assert_eq!(ledger.pending().len(), 1);