iuna

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

commit ccc94c25c33fa2ec182edaa8156d2e7331884700
parent 6741b33a57f92886160f7adf083ab1921bbbf73a
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 29 Jul 2026 09:49:02 +0200

Harden mempool sync and auth

Diffstat:
Msrc/adapters/http.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/p2p.rs | 19++++++++++++++++---
Msrc/app.rs | 7++++---
Msrc/domain.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/iuna.rs | 5++++-
5 files changed, 185 insertions(+), 9 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -40,6 +40,8 @@ const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; const AUTH_COOKIE_NAME: &str = "iuna_session"; const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000; +const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5; +const AUTH_LOCKOUT_MS: u64 = 60 * 1_000; const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; const PASSWORD_KDF_ITERATIONS: u32 = 120_000; const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; @@ -54,6 +56,7 @@ struct HttpState { wallet_path: PathBuf, stratum: StratumStatus, auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, + auth_backoff: Arc<Mutex<AuthBackoff>>, } #[derive(Clone)] @@ -62,6 +65,12 @@ struct AuthSession { wallet_password: String, } +#[derive(Clone, Debug, Default)] +struct AuthBackoff { + failed_attempts: u32, + locked_until_ms: Option<u64>, +} + pub struct ServeOptions { pub config_path: PathBuf, pub wallet_path: PathBuf, @@ -256,6 +265,7 @@ pub async fn serve( wallet_path: options.wallet_path, stratum: options.stratum, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), + auth_backoff: Arc::new(Mutex::new(AuthBackoff::default())), }; let app = Router::new() .route("/", get(index)) @@ -1441,6 +1451,7 @@ async fn setup_auth_password(state: &HttpState, password: &str) -> Result<String } async fn login_auth_password(state: &HttpState, password: &str) -> Result<String> { + check_auth_backoff(state).await?; let hash = state .ui_config .lock() @@ -1449,14 +1460,46 @@ async fn login_auth_password(state: &HttpState, password: &str) -> Result<String .clone() .context("authentication setup is required")?; if !verify_password(password, &hash)? { + record_auth_failure(state).await; bail!("invalid password"); } wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; state.node.lock().await.replace_wallet(wallet); + clear_auth_backoff(state).await; create_session_cookie(state, password).await } +async fn check_auth_backoff(state: &HttpState) -> Result<()> { + let now = now_ms(); + let mut backoff = state.auth_backoff.lock().await; + if backoff + .locked_until_ms + .is_some_and(|locked_until| locked_until > now) + { + bail!("too many failed login attempts; try again later"); + } + if backoff.locked_until_ms.is_some() { + backoff.locked_until_ms = None; + backoff.failed_attempts = 0; + } + Ok(()) +} + +async fn record_auth_failure(state: &HttpState) { + let mut backoff = state.auth_backoff.lock().await; + backoff.failed_attempts = backoff.failed_attempts.saturating_add(1); + if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS { + backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS)); + } +} + +async fn clear_auth_backoff(state: &HttpState) { + let mut backoff = state.auth_backoff.lock().await; + backoff.failed_attempts = 0; + backoff.locked_until_ms = None; +} + fn validate_password(password: &str) -> Result<()> { if password.len() < 12 { bail!("password must be at least 12 characters"); @@ -2698,6 +2741,38 @@ mod tests { } #[tokio::test] + async fn login_authentication_locks_out_after_repeated_failures() { + let dir = tempfile::tempdir().unwrap(); + let password = "correct horse battery staple"; + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: Some(hash_password(password).unwrap()), + ..UiConfig::default() + }, + ) + .await; + + for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { + let error = super::login_auth_password(&state, "wrong horse battery staple") + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("invalid password")); + } + + let locked = super::login_auth_password(&state, password) + .await + .unwrap_err(); + assert!(format!("{locked:#}").contains("too many failed login attempts")); + + state.auth_backoff.lock().await.locked_until_ms = + Some(crate::app::now_ms().saturating_sub(1)); + let cookie = super::login_auth_password(&state, password).await.unwrap(); + assert!(cookie.starts_with(AUTH_COOKIE_NAME)); + assert_eq!(state.auth_backoff.lock().await.failed_attempts, 0); + } + + #[tokio::test] async fn password_setup_creates_session_for_protected_endpoints() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); @@ -3065,6 +3140,7 @@ mod tests { listen_addr: None, }, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), + auth_backoff: Arc::new(Mutex::new(super::AuthBackoff::default())), } } diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1590,7 +1590,7 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { ensure_len("peer list", peers.len(), MAX_PEER_LIST)?; } GossipEnvelope::PeerStatus { mempool_txs, .. } => { - ensure_len("mempool status", mempool_txs.len(), MAX_INVENTORY_ITEMS)?; + ensure_len("mempool status", mempool_txs.len(), MEMPOOL_STATUS_LIMIT)?; } GossipEnvelope::Hello(_) | GossipEnvelope::ChainSnapshotRequest @@ -2352,9 +2352,9 @@ mod tests { let envelope = GossipEnvelope::PeerStatus { height: 7, tip_hash: "tip".to_string(), - mempool_count: MAX_INVENTORY_ITEMS + 1, + mempool_count: super::MEMPOOL_STATUS_LIMIT + 1, mempool_root: "root".to_string(), - mempool_txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1], + mempool_txs: vec!["sig".to_string(); super::MEMPOOL_STATUS_LIMIT + 1], }; let error = validate_envelope_limits(&envelope).unwrap_err(); @@ -2363,6 +2363,19 @@ mod tests { } #[test] + fn mempool_status_can_advertise_full_pending_pool_beyond_inventory_batch_size() { + let envelope = GossipEnvelope::PeerStatus { + height: 7, + tip_hash: "tip".to_string(), + mempool_count: MAX_INVENTORY_ITEMS + 1, + mempool_root: "root".to_string(), + mempool_txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1], + }; + + validate_envelope_limits(&envelope).unwrap(); + } + + #[test] fn received_envelope_metrics_are_categorized() { let metrics = super::P2pMetricsCounters::default(); diff --git a/src/app.rs b/src/app.rs @@ -11,8 +11,9 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, - Ledger, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, - Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, + Ledger, MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, + StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, + VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -23,7 +24,7 @@ pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; pub const PROTOCOL_VERSION: u32 = 1; pub const NETWORK_ID: &str = "iuna-devnet-v2"; pub const BLOCK_REQUEST_LIMIT: usize = 128; -pub const MEMPOOL_STATUS_LIMIT: usize = 512; +pub const MEMPOOL_STATUS_LIMIT: usize = MAX_PENDING_TRANSACTIONS; const IMPORT_REBROADCAST_LIMIT: usize = 128; pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; diff --git a/src/domain.rs b/src/domain.rs @@ -22,7 +22,8 @@ const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; const MINE_MIN_DIFFICULTY_BITS: u32 = 1; const MINE_MAX_DIFFICULTY_BITS: u32 = 32; const MINE_MAX_ANCHOR_AGE_BLOCKS: u64 = MINE_RETARGET_WINDOW_BLOCKS; -const MAX_PENDING_TRANSACTIONS: usize = 10_000; +pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; +const MAX_ORPHAN_TRANSACTIONS: usize = 1_024; const MAX_BLOCK_TRANSACTIONS: usize = 1_000; const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3; @@ -1165,6 +1166,7 @@ pub struct Ledger { utxos: BTreeMap<OutPoint, TxOutput>, tickets: Vec<BurnTicket>, pending: Vec<Transaction>, + orphans: Vec<Transaction>, mine_reward: Amount, initial_vdf_rounds: u32, vdf_rounds: u32, @@ -1211,6 +1213,7 @@ impl Ledger { utxos, tickets, pending: Vec::new(), + orphans: Vec::new(), mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, @@ -1250,6 +1253,7 @@ impl Ledger { utxos, tickets: Vec::new(), pending: Vec::new(), + orphans: Vec::new(), mine_reward: MINE_REWARD, initial_vdf_rounds: vdf_rounds, vdf_rounds, @@ -1420,6 +1424,7 @@ impl Ledger { fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) { let mut carry_forward = self.pending.clone(); + carry_forward.extend(self.orphans.clone()); for block in self .chain .iter() @@ -1523,9 +1528,14 @@ impl Ledger { &self.pending } + pub fn orphan_transactions(&self) -> &[Transaction] { + &self.orphans + } + pub fn transaction_by_signature(&self, signature: &str) -> Option<Transaction> { self.pending .iter() + .chain(self.orphans.iter()) .chain( self.chain .iter() @@ -1856,6 +1866,9 @@ impl Ledger { if transaction_inputs_spent_by(&transaction, &self.pending) { return Ok(TransactionSubmitOutcome::ConflictsWithPending); } + if transaction_inputs_spent_by(&transaction, &self.orphans) { + return Ok(TransactionSubmitOutcome::ConflictsWithPending); + } if self.pending.len() >= MAX_PENDING_TRANSACTIONS { bail!("mempool is full"); @@ -1863,11 +1876,15 @@ impl Ledger { let mut utxos = self.utxos_after_valid_pending()?; if transaction_has_missing_inputs(&transaction, &utxos) { - self.pending.push(transaction); + if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS { + bail!("orphan transaction pool is full"); + } + self.orphans.push(transaction); return Ok(TransactionSubmitOutcome::Added); } apply_transaction(&transaction, &mut utxos)?; self.pending.push(transaction); + self.promote_orphan_transactions()?; Ok(TransactionSubmitOutcome::Added) } @@ -1969,6 +1986,15 @@ impl Ledger { && self.validate_transaction_terms(tx).is_ok() }) .collect(); + let orphans = std::mem::take(&mut self.orphans); + self.orphans = orphans + .into_iter() + .filter(|tx| { + !mined_signatures.contains(tx.signature()) + && self.validate_transaction_terms(tx).is_ok() + }) + .collect(); + self.promote_orphan_transactions()?; self.vdf_rounds = self.next_vdf_rounds_after_tip(); Ok(()) } @@ -2204,6 +2230,35 @@ impl Ledger { apply_transaction(transaction, &mut utxos) } + fn promote_orphan_transactions(&mut self) -> Result<()> { + loop { + if self.pending.len() >= MAX_PENDING_TRANSACTIONS { + return Ok(()); + } + let mut promoted_index = None; + let mut utxos = self.utxos_after_valid_pending()?; + for (index, transaction) in self.orphans.iter().enumerate() { + if transaction_inputs_spent_by(transaction, &self.pending) { + continue; + } + if transaction_has_missing_inputs(transaction, &utxos) { + continue; + } + if self.validate_transaction_terms(transaction).is_ok() + && apply_transaction(transaction, &mut utxos).is_ok() + { + promoted_index = Some(index); + break; + } + } + + let Some(index) = promoted_index else { + return Ok(()); + }; + self.pending.push(self.orphans.remove(index)); + } + } + fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> { match transaction { Transaction::Transfer { @@ -3657,6 +3712,34 @@ mod tests { } #[test] + fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() { + let alice = Wallet::from_seed("missing-input-orphan-alice"); + let bob = Wallet::from_seed("missing-input-orphan-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); + let transaction = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: hex_hash("missing-input-orphan"), + index: 0, + }, + owner: alice.address().to_string(), + }], + outputs: vec![TxOutput { + address: bob.address().to_string(), + amount: 1, + }], + fee: 0, + } + .sign(&alice); + + let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap(); + + assert_eq!(outcome, TransactionSubmitOutcome::Added); + assert!(ledger.pending().is_empty()); + assert_eq!(ledger.orphan_transactions().len(), 1); + } + + #[test] fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { let alice = Wallet::from_seed("oversized-select-alice"); let bob = Wallet::from_seed("oversized-select-bob"); diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1631,7 +1631,8 @@ fn mempool_gossip_repairs_future_nonce_gap_without_networking() { bob_node .receive(iuna::app::GossipEnvelope::Transaction(second.clone())) .unwrap(); - assert_eq!(bob_node.ledger().pending().len(), 1); + assert_eq!(bob_node.ledger().pending().len(), 0); + assert_eq!(bob_node.ledger().orphan_transactions().len(), 1); let mut requests = Vec::new(); for envelope in alice_node.mempool_gossip() { @@ -1654,6 +1655,8 @@ fn mempool_gossip_repairs_future_nonce_gap_without_networking() { other => bob_node.receive(other).unwrap(), } } + assert_eq!(bob_node.ledger().pending().len(), 2); + assert!(bob_node.ledger().orphan_transactions().is_empty()); let block = alice_node.mine_one_at(1).unwrap(); let signatures = block .transactions