iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 29a53c783f91aac03dcde38cf34ec8f9ae2687e4
parent 71e5830ed770e98af007789fd7d9e0729e6271a2
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 30 Aug 2026 08:08:54 +0200

Fix mine transaction replay inflation

Diffstat:
MROADMAP.md | 2++
Mdocs/protocol.md | 18++++++++++++++++--
Mdocs/security-review.md | 15++++++++++++---
Msrc/domain.rs | 3++-
Msrc/domain/ledger_apply.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain/ledger_chain.rs | 12++++++++++++
Msrc/domain/ledger_queries.rs | 7++++++-
Msrc/domain/ledger_state.rs | 3++-
Msrc/domain/protocol.rs | 2++
9 files changed, 191 insertions(+), 11 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -41,6 +41,8 @@ These items are not protocol rules. They are the attack and reliability checks t - [x] Burn bundle relay cannot import embedded burns before bundle metadata, membership, signature, fee ordering, and size are prechecked. - [x] Block validation with burn attestations remains independent of local mempool contents, including empty and conflicting mempools. - [x] Post-genesis transactions cannot spend with `genesis` input signatures. +- [x] From height 1000, previously mined transaction IDs cannot be replayed; in + particular, spending a mine reward cannot make its inputless proof mint again. - [x] P2P envelope item limits reject batches only above their configured boundaries. - [x] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps. - [x] Fork and snapshot adversarial tests cover legacy finality, post-height-1000 objective checkpoints, deterministic conflicting-certificate recovery, same-height leader-quality choice, invalid late snapshot blocks, and pending transaction carry-forward after reorg. diff --git a/docs/protocol.md b/docs/protocol.md @@ -47,11 +47,12 @@ The current mainnet-candidate parameter set is intentionally close to Bitcoin wh - burn committee lineage maturity: `20` blocks; - fallback ticket invalidation activation height: `300`; - grinding-resistance activation height: `1000`; -- transaction signing format v1 activation height: `1000`. +- transaction signing format v1 activation height: `1000`; +- transaction replay-protection activation height: `1000`. Changing any value in this section requires a conscious mainnet-candidate reset or later hard-fork process. -Transaction signing format v1 and objective finality activate automatically at height `1000`. Existing chain state and history remain valid; operators only need to upgrade every consensus node before activation. A chain-ID or genesis change remains a separate consensus reset. +Transaction signing format v1, chain-wide transaction replay protection, and objective finality activate automatically at height `1000`. Existing chain state and history remain valid; operators only need to upgrade every consensus node before activation. A chain-ID or genesis change remains a separate consensus reset. The consensus block-size limit is the exact number of bytes produced by the compact snapshot v6 block-body encoder when the block is appended to its parent chain. The encoder's reference tables are seeded by genesis allocations and extended in chain order, so all nodes calculate the same context-dependent size. The snapshot header, launch profile, block-count field, SQLite row metadata, and SQLite page overhead are not charged to an individual block. @@ -110,6 +111,15 @@ At height `1000`, every transfer, burn, and mine action becomes cryptographicall Transfers and burns use Ed25519 over this binary preimage. Native and Stratum mine proofs commit the same domain and logical mine fields before proof-specific hashing. Validators reconstruct the domain from their local launch profile and genesis block, so a transaction valid on candidate, mainnet, testnet, or another genesis fails signature/proof validation everywhere else. Blocks below height `1000` retain the legacy text signatures and proof preimages permanently so existing history and snapshots replay unchanged. Blocks at height `1000` and later accept only format v1; there is no post-activation legacy fallback. +Height `1000` also activates chain-wide transaction-ID uniqueness. A block at or +above the activation height is invalid if any transaction ID already occurred in +an earlier block on that chain. This is especially important for inputless mine +actions: without the historical check, a previously included proof could be +replayed after its reward output was spent, recreating the same outpoint and +inflating supply. Pre-activation blocks retain their original validation rules, +while the first activated block rejects replays of both legacy history and newer +transactions. + Synthetic genesis-allocation outpoints retain their original address-based derivation for the lifetime of the chain. Changing them at activation would rewrite the existing UTXO set, so chain isolation is introduced only in new signatures and proofs. ## Burns Become Tickets @@ -211,6 +221,10 @@ This keeps issuance separate from finalization. PoW miners compete to create min A block may contain at most `2` mine actions for the same anchor. This leaves room for the difficulty retarget to move upward when PoW regularly fills both slots, while still bounding issuance from any single anchor. +Each mine proof may be included only once in the chain from height `1000`, under +the transaction-ID uniqueness rule above. Spending a mine reward never makes +its proof eligible for inclusion again. + ## Fair Burn Inclusion The central censorship risk is simple: what if a finalizer only includes its own burns and ignores everyone else's burns? diff --git a/docs/security-review.md b/docs/security-review.md @@ -66,6 +66,14 @@ and boundary/replay tests cover pre-activation compatibility, candidate/mainnet/ testnet IDs, distinct genesis hashes, native and Stratum proofs, and hexadecimal casing malleability under format v1. +Chain-wide transaction-ID replay protection activates at the same height. +Validators maintain an index reconstructed from genesis/snapshots and reject an +activated block when any transaction ID already exists in its history. This +closes an issuance bug where an inputless mine proof could be included again +after its original reward outpoint had been spent, recreating the output. Tests +cover the activation boundary, the full block-validation path, and index rebuild +from a persisted snapshot. + Evidence already in the tree: - focused adversarial tests for zero-fee burns, bundle import ordering, @@ -259,9 +267,10 @@ see which revision was tested. advisory); iuna does not call that iterator API directly. Reassess this transitive stack on every Tauri upgrade and no later than 2026-11-30. - Height `1000` activation: the release activates both grinding resistance and - transaction signing format v1 automatically. All candidate nodes must upgrade - before activation; the height activation itself requires no chain-state reset - or operator migration command, but mixed versions will split at height `1000`. + transaction signing format v1 plus chain-wide transaction-ID replay protection + automatically. All candidate nodes must upgrade before activation; the height + activation itself requires no chain-state reset or operator migration command, + but mixed versions will split at height `1000`. ## Sign-Off Table diff --git a/src/domain.rs b/src/domain.rs @@ -62,7 +62,8 @@ pub use protocol::{ MAX_BLOCK_BYTES, MAX_BURN_BUNDLE_BYTES, MAX_PENDING_TRANSACTIONS, MAX_VDF_ROUNDS, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, RECOVERY_BLOCK_DELAY_MS, - TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, + TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, }; use protocol::{ BLOCK_MEDIAN_TIME_PAST_WINDOW, DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -14,9 +14,9 @@ use super::ticket::{ use super::transaction::transaction_inputs_available; use super::{ Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, BurnBundleSection, FinalityCheckpoint, - FinalizerMode, Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, Transaction, - insert_output_with_lineage, output_lineage_root_for_transaction, spend_inputs_with_lineage, - unix_now_ms, verify_vdf, + FinalizerMode, Ledger, MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, + TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, Transaction, insert_output_with_lineage, + output_lineage_root_for_transaction, spend_inputs_with_lineage, unix_now_ms, verify_vdf, }; impl Ledger { @@ -117,6 +117,8 @@ impl Ledger { self.lineage_values = lineage_values; self.lineage_owners = lineage_owners; self.tickets = tickets; + self.mined_transaction_ids + .extend(mined_signatures.iter().cloned()); self.chain.push(block); if let Some(checkpoint) = certified_parent { self.objective_finality_checkpoint = Some(checkpoint); @@ -148,6 +150,20 @@ impl Ledger { Ok(()) } + fn ensure_block_transactions_are_not_replays(&self, block: &Block) -> Result<()> { + if block.height < TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT { + return Ok(()); + } + if block + .transactions + .iter() + .any(|transaction| self.mined_transaction_ids.contains(transaction.signature())) + { + bail!("block replays a previously mined transaction"); + } + Ok(()) + } + fn precheck_block_without_vdf_at(&self, block: &Block, now_ms: u64) -> Result<bool> { if block.height <= self.tip().height { let existing = self @@ -176,6 +192,7 @@ impl Ledger { if block.compute_hash() != block.hash { bail!("block hash is invalid"); } + self.ensure_block_transactions_are_not_replays(block)?; if block.reward != self.expected_reward_for_block(block)? { bail!("block reward is invalid"); } @@ -343,6 +360,123 @@ mod tests { use std::collections::BTreeMap; use super::*; + use crate::domain::{BurnTicket, GenesisBurn, MICRO_IUNA, Wallet, run_vdf}; + + fn mine_transaction(signature: &str) -> Transaction { + Transaction::Mine { + recipient: "1".repeat(64), + anchor: "2".repeat(64), + salt: 1, + nonce: 1, + difficulty_bits: 10, + proof_header: None, + signature: signature.to_string(), + } + } + + #[test] + fn historical_mine_replay_is_rejected_from_height_1000() { + let signature = "3".repeat(64); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + ledger.mined_transaction_ids.insert(signature.clone()); + let mut block = ledger.tip().clone(); + block.transactions = vec![mine_transaction(&signature)]; + + block.height = TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT - 1; + ledger + .ensure_block_transactions_are_not_replays(&block) + .unwrap(); + + block.height = TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT; + assert!( + ledger + .ensure_block_transactions_are_not_replays(&block) + .unwrap_err() + .to_string() + .contains("replays a previously mined transaction") + ); + } + + #[test] + fn activated_block_validation_rejects_replayed_mine_proof() { + let wallet = Wallet::from_seed("activated-mine-replay-wallet"); + let mut ledger = Ledger::new_with_genesis_burns( + BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]), + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + ledger.chain.last_mut().unwrap().height = + TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT - 1; + ledger.tickets = vec![BurnTicket { + id: "5".repeat(64), + owner: wallet.address().to_string(), + amount: MICRO_IUNA, + eligible_from_height: TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, + eligible_until_height: TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, + }]; + + let burn = ledger.build_burn(&wallet, 1, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mine = ledger.build_mine(wallet.address()).unwrap(); + let replayed_id = mine.signature().to_string(); + ledger.submit_transaction(mine).unwrap(); + let prepared = ledger.prepare_next_block(wallet.address(), 1).unwrap(); + let block = prepared.finish(&wallet, "test-vdf-output".to_string()); + assert_eq!( + block.height, + TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT + ); + + ledger.mined_transaction_ids.insert(replayed_id); + let error = ledger + .apply_preverified_block_at(block, 1) + .unwrap_err() + .to_string(); + + assert!(error.contains("replays a previously mined transaction")); + } + + #[test] + fn applied_blocks_and_snapshot_restore_index_mined_transaction_ids() { + let wallet = Wallet::from_seed("replay-index-genesis-wallet"); + let mut ledger = Ledger::new_with_genesis_burns( + BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]), + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let genesis_transaction_id = ledger.chain[0].transactions[0].signature().to_string(); + let burn = ledger.build_burn(&wallet, 1, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mine = ledger.build_mine(wallet.address()).unwrap(); + let mine_transaction_id = mine.signature().to_string(); + ledger.submit_transaction(mine).unwrap(); + let prepared = ledger.prepare_next_block(wallet.address(), 1).unwrap(); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(&wallet, vdf_output); + ledger.apply_preverified_block_at(block, 1).unwrap(); + + assert!( + ledger + .mined_transaction_ids + .contains(&genesis_transaction_id) + ); + assert!(ledger.mined_transaction_ids.contains(&mine_transaction_id)); + let restored = Ledger::from_persisted_snapshot(ledger.snapshot()).unwrap(); + assert!( + restored + .mined_transaction_ids + .contains(&genesis_transaction_id) + ); + assert!( + restored + .mined_transaction_ids + .contains(&mine_transaction_id) + ); + assert!(restored.has_transaction(&genesis_transaction_id)); + assert!(restored.has_transaction(&mine_transaction_id)); + } #[test] fn median_time_past_uses_the_median_of_the_latest_eleven_blocks() { diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -76,6 +76,11 @@ impl Ledger { } else { CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))? }; + let mined_transaction_ids = genesis + .transactions + .iter() + .map(|transaction| transaction.signature().to_string()) + .collect(); Ok(Self { chain: vec![genesis], genesis_allocations: genesis_allocations.clone(), @@ -84,6 +89,7 @@ impl Ledger { lineage_values: BTreeMap::new(), lineage_owners: BTreeMap::new(), tickets, + mined_transaction_ids, pending: Vec::new(), orphans: Vec::new(), pending_bytes: 0, @@ -140,6 +146,11 @@ impl Ledger { let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; let compact_block_context = CompactBlockContext::for_chain(&genesis_allocations, std::slice::from_ref(&genesis))?; + let mined_transaction_ids = genesis + .transactions + .iter() + .map(|transaction| transaction.signature().to_string()) + .collect(); let mut ledger = Self { chain: vec![genesis], @@ -149,6 +160,7 @@ impl Ledger { lineage_values: BTreeMap::new(), lineage_owners: BTreeMap::new(), tickets: Vec::new(), + mined_transaction_ids, pending: Vec::new(), orphans: Vec::new(), pending_bytes: 0, diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -549,7 +549,12 @@ impl Ledger { } pub fn has_transaction(&self, signature: &str) -> bool { - self.transaction_by_signature(signature).is_some() + self.mined_transaction_ids.contains(signature) + || self + .pending + .iter() + .chain(self.orphans.iter()) + .any(|transaction| transaction.signature() == signature) } pub fn pending_mine_count_for_anchor(&self, anchor: &str) -> usize { diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, time::{SystemTime, UNIX_EPOCH}, }; @@ -18,6 +18,7 @@ pub struct Ledger { pub(super) lineage_values: BTreeMap<UtxoLineageRoot, Amount>, pub(super) lineage_owners: LineageOwnerValues, pub(super) tickets: Vec<BurnTicket>, + pub(super) mined_transaction_ids: BTreeSet<String>, pub(super) pending: Vec<Transaction>, pub(super) orphans: Vec<Transaction>, pub(super) pending_bytes: usize, diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -18,6 +18,7 @@ pub const MAX_BURN_BUNDLE_BYTES: usize = 10_000; pub const BURN_LINEAGE_MATURITY_HEIGHTS: u64 = 20; pub const GRINDING_RESISTANCE_ACTIVATION_HEIGHT: u64 = 1_000; pub const TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT: u64 = 1_000; +pub const TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT: u64 = 1_000; pub const OBJECTIVE_FINALITY_ACTIVATION_HEIGHT: u64 = 1_000; pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; @@ -66,6 +67,7 @@ mod tests { assert_eq!(BURN_LINEAGE_MATURITY_HEIGHTS, 20); assert_eq!(GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1_000); assert_eq!(TRANSACTION_SIGNING_V1_ACTIVATION_HEIGHT, 1_000); + assert_eq!(TRANSACTION_REPLAY_PROTECTION_ACTIVATION_HEIGHT, 1_000); assert_eq!(OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, 1_000); assert_eq!(MAX_PENDING_TRANSACTIONS, 10_000); assert_eq!(MAX_PENDING_POOL_BYTES, 8 * 1024 * 1024);