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:
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);