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