High severity8.2NVD Advisory· Published Apr 21, 2026· Updated Apr 27, 2026
CVE-2026-40583
CVE-2026-40583
Description
UltraDAG is a minimal DAG-BFT blockchain in Rust. In version 0.1, a non-council attacker can submit a signed SmartOp::Vote transaction that passes signature, nonce, and balance prechecks, but fails authorization only after state mutation has already occurred.
Affected products
1Patches
22f5a3a237ea5fix: complete SmartOp validate-before-mutate for ALL operation types
1 file changed · +255 −214
crates/ultradag-coin/src/state/engine.rs+255 −214 modified@@ -3904,53 +3904,28 @@ impl StateEngine { pub fn apply_smart_op_tx(&mut self, tx: &crate::tx::smart_account::SmartOpTx, current_round: u64) -> Result<(), CoinError> { use crate::tx::smart_account::SmartOpType; - // Ensure smart account exists (auto-registration for first-time users) - // This must happen before authorization checks that depend on account state. - self.ensure_smart_account_at_round(&tx.from, current_round); - - // Pre-validate operation authorization BEFORE any state mutation (fee/nonce). - // This prevents partial state changes when the operation itself is unauthorized. - match &tx.operation { - // Governance operations require council membership - SmartOpType::Vote { .. } | SmartOpType::CreateProposal { .. } => { - if !self.is_council_member(&tx.from) { - return Err(CoinError::ValidationError( - "only council members can perform governance operations".into() - )); - } - } - _ => {} - } - - // Debit fee if any - if tx.fee > 0 { - self.debit(&tx.from, tx.fee)?; - } - self.increment_nonce(&tx.from); - + // ── Phase 1: Pre-validate ALL preconditions BEFORE any state mutation. ── + // Every operation that can fail must be checked here. If any check fails, + // we return Err before fee debit or nonce increment, so state stays clean. + // This prevents the supply invariant bug (GHSA-q8wx-2crx-c7pp) where + // a fee was debited but the operation failed, burning tokens into nowhere. match &tx.operation { SmartOpType::Stake { amount } => { - let stake_tx = crate::tx::StakeTx { - from: tx.from, amount: *amount, nonce: tx.nonce, - pub_key: [0u8; 32], signature: crate::Signature([0u8; 64]), - }; - // Skip sig/nonce/debit checks — already done above. Just apply stake logic. if *amount < crate::tx::MIN_STAKE_SATS { return Err(CoinError::ValidationError("below minimum stake".into())); } - self.debit(&tx.from, *amount)?; - let stake = self.stake_accounts.entry(tx.from).or_insert_with(|| { - StakeAccount { staked: 0, unlock_at_round: None, commission_percent: 10, commission_last_changed: None, locked_stake: 0 } - }); - stake.staked = stake.staked.saturating_add(*amount); + // Check balance covers stake amount (fee already checked by caller) + let balance = self.balance(&tx.from); + if balance < tx.fee.saturating_add(*amount) { + return Err(CoinError::ValidationError("insufficient balance for stake + fee".into())); + } } SmartOpType::Unstake => { - let stake = self.stake_accounts.get_mut(&tx.from) + let stake = self.stake_accounts.get(&tx.from) .ok_or_else(|| CoinError::ValidationError("no stake to unstake".into()))?; if stake.unlock_at_round.is_some() { return Err(CoinError::ValidationError("already unstaking".into())); } - stake.unlock_at_round = Some(current_round.saturating_add(crate::tx::UNSTAKE_COOLDOWN_ROUNDS)); } SmartOpType::Delegate { validator, amount } => { if tx.from == *validator { @@ -3959,85 +3934,58 @@ impl StateEngine { if *amount < crate::constants::MIN_DELEGATION_SATS { return Err(CoinError::ValidationError("below minimum delegation".into())); } - self.debit(&tx.from, *amount)?; - self.delegation_accounts.insert(tx.from, crate::state::engine::DelegationAccount { - delegated: *amount, validator: *validator, unlock_at_round: None, - }); + let balance = self.balance(&tx.from); + if balance < tx.fee.saturating_add(*amount) { + return Err(CoinError::ValidationError("insufficient balance for delegation + fee".into())); + } } SmartOpType::Undelegate => { - let deleg = self.delegation_accounts.get_mut(&tx.from) + let deleg = self.delegation_accounts.get(&tx.from) .ok_or_else(|| CoinError::ValidationError("no delegation to undelegate".into()))?; if deleg.unlock_at_round.is_some() { return Err(CoinError::ValidationError("already undelegating".into())); } - deleg.unlock_at_round = Some(current_round.saturating_add(crate::tx::UNSTAKE_COOLDOWN_ROUNDS)); } SmartOpType::SetCommission { commission_percent } => { if *commission_percent > crate::constants::MAX_COMMISSION_PERCENT { return Err(CoinError::ValidationError("commission exceeds maximum".into())); } - let stake = self.stake_accounts.get_mut(&tx.from) - .ok_or_else(|| CoinError::ValidationError("must be a staker to set commission".into()))?; - stake.commission_percent = *commission_percent; - stake.commission_last_changed = Some(current_round); + if !self.stake_accounts.contains_key(&tx.from) { + return Err(CoinError::ValidationError("must be a staker to set commission".into())); + } } - SmartOpType::Vote { proposal_id, approve } => { - // Council membership already verified above before state mutation. - self.votes.insert((*proposal_id, tx.from), *approve); + SmartOpType::Vote { .. } => { + if !self.is_council_member(&tx.from) { + return Err(CoinError::ValidationError( + "only council members can perform governance operations".into() + )); + } } - SmartOpType::CreateProposal { title, description, proposal_type_tag, param, new_value } => { - // Council membership already verified above before state mutation. + SmartOpType::CreateProposal { title, description, proposal_type_tag, .. } => { + if !self.is_council_member(&tx.from) { + return Err(CoinError::ValidationError( + "only council members can perform governance operations".into() + )); + } if title.len() > crate::constants::PROPOSAL_TITLE_MAX_BYTES || description.len() > crate::constants::PROPOSAL_DESCRIPTION_MAX_BYTES { return Err(CoinError::ValidationError("proposal title or description too long".into())); } - - // Map tag to ProposalType - let proposal_type = match proposal_type_tag { - 0 => crate::governance::ProposalType::TextProposal, - 1 => crate::governance::ProposalType::ParameterChange { - param: param.clone(), - new_value: new_value.to_string(), - }, - _ => return Err(CoinError::ValidationError( + if *proposal_type_tag > 1 { + return Err(CoinError::ValidationError( format!("unsupported proposal_type_tag: {}", proposal_type_tag), - )), - }; - - // Check active proposal count + )); + } let active_count = self.proposals.values() .filter(|p| matches!(p.status, crate::governance::ProposalStatus::Active)) .count(); if active_count as u64 >= self.governance_params.max_active_proposals { return Err(CoinError::TooManyActiveProposals); } - - // Check proposal cooldown if let Some(&last_round) = self.last_proposal_round.get(&tx.from) { if current_round.saturating_sub(last_round) < crate::constants::PROPOSAL_COOLDOWN_ROUNDS { return Err(CoinError::ProposalCooldownNotElapsed); } } - - // Snapshot council count for quorum - let snapshot_total_stake = self.council_members.len() as u64; - - // Create and insert proposal - let proposal = crate::governance::Proposal { - id: self.next_proposal_id, - proposer: tx.from, - title: title.clone(), - description: description.clone(), - proposal_type, - voting_starts: current_round, - voting_ends: current_round.saturating_add(self.governance_params.voting_period_rounds), - votes_for: 0, - votes_against: 0, - status: crate::governance::ProposalStatus::Active, - snapshot_total_stake, - }; - self.proposals.insert(self.next_proposal_id, proposal); - self.last_proposal_round.insert(tx.from, current_round); - self.next_proposal_id = self.next_proposal_id.saturating_add(1); } SmartOpType::RegisterName { name, duration_years } => { use crate::tx::name_registry::*; @@ -4055,14 +4003,6 @@ impl StateEngine { if self.address_to_name.contains_key(&tx.from) { return Err(CoinError::ValidationError("address already has a name".into())); } - // Fee already debited above, add to treasury - self.treasury_balance = self.treasury_balance.saturating_add(tx.fee); - let expiry = current_round.saturating_add(ROUNDS_PER_YEAR.saturating_mul(*duration_years as u64)); - self.name_to_address.insert(name.clone(), tx.from); - self.address_to_name.insert(tx.from, name.clone()); - self.name_expiry.insert(name.clone(), expiry); - self.name_created_at.insert(name.clone(), current_round); - tracing::info!("Name '{}' registered to {} via SmartOp", name, tx.from.to_hex()); } SmartOpType::RenewName { name, additional_years } => { use crate::tx::name_registry::*; @@ -4075,11 +4015,6 @@ impl StateEngine { if tx.fee < required_fee { return Err(CoinError::ValidationError("fee too low".into())); } - self.treasury_balance = self.treasury_balance.saturating_add(tx.fee); - let current_expiry = self.name_expiry.get(name).copied().unwrap_or(0); - let base = current_expiry.max(current_round); - let new_expiry = base.saturating_add(ROUNDS_PER_YEAR.saturating_mul(*additional_years as u64)); - self.name_expiry.insert(name.clone(), new_expiry); } SmartOpType::TransferName { name, new_owner } => { let owner = self.name_to_address.get(name) @@ -4090,12 +4025,8 @@ impl StateEngine { if self.address_to_name.contains_key(new_owner) { return Err(CoinError::ValidationError("new owner already has a name".into())); } - self.name_to_address.insert(name.clone(), *new_owner); - self.address_to_name.remove(&tx.from); - self.address_to_name.insert(*new_owner, name.clone()); } - SmartOpType::StreamCreate { recipient, rate_sats_per_round, deposit, cliff_rounds } => { - // Inline the core logic (fee/nonce already handled by SmartOp handler above). + SmartOpType::StreamCreate { recipient, rate_sats_per_round, deposit, .. } => { if *deposit == 0 { return Err(CoinError::ValidationError("stream deposit must be greater than 0".into())); } @@ -4108,43 +4039,20 @@ impl StateEngine { if tx.from == *recipient { return Err(CoinError::ValidationError("cannot stream to self".into())); } - let mut hasher = blake3::Hasher::new(); - hasher.update(&tx.from.0); - hasher.update(&recipient.0); - hasher.update(&tx.nonce.to_le_bytes()); - let stream_id = *hasher.finalize().as_bytes(); - if self.streams.contains_key(&stream_id) { - return Err(CoinError::ValidationError("stream ID already exists".into())); + let balance = self.balance(&tx.from); + if balance < tx.fee.saturating_add(*deposit) { + return Err(CoinError::ValidationError("insufficient balance for stream deposit + fee".into())); } - // Debit the deposit (fee already debited by SmartOp handler above) - self.debit(&tx.from, *deposit)?; - let stream = crate::tx::stream::Stream { - id: stream_id, - sender: tx.from, - recipient: *recipient, - rate_sats_per_round: *rate_sats_per_round, - start_round: current_round, - deposited: *deposit, - withdrawn: 0, - cliff_rounds: *cliff_rounds, - cancelled_at_round: None, - cancel_recipient_credited: false, - }; - self.streams.insert(stream_id, stream); } SmartOpType::StreamWithdraw { stream_id } => { let stream = self.streams.get(stream_id) .ok_or_else(|| CoinError::ValidationError("stream not found".into()))?; if tx.from != stream.recipient { return Err(CoinError::ValidationError("only recipient can withdraw from stream".into())); } - let withdrawable = stream.withdrawable_at(current_round); - if withdrawable == 0 { + if stream.withdrawable_at(current_round) == 0 { return Err(CoinError::ValidationError("no funds available to withdraw".into())); } - self.credit(&tx.from, withdrawable)?; - let stream = self.streams.get_mut(stream_id).unwrap(); - stream.withdrawn = stream.withdrawn.saturating_add(withdrawable); } SmartOpType::StreamCancel { stream_id } => { let stream = self.streams.get(stream_id) @@ -4155,90 +4063,40 @@ impl StateEngine { if stream.cancelled_at_round.is_some() { return Err(CoinError::ValidationError("stream already cancelled".into())); } - let accrued = stream.accrued_at(current_round); - let recipient_owed = accrued.saturating_sub(stream.withdrawn); - let sender_refund = stream.deposited.saturating_sub(accrued); - let recipient = stream.recipient; - if recipient_owed > 0 { - self.credit(&recipient, recipient_owed)?; - } - if sender_refund > 0 { - self.credit(&tx.from, sender_refund)?; - } - let stream = self.streams.get_mut(stream_id).unwrap(); - stream.cancelled_at_round = Some(current_round); - stream.withdrawn = accrued; - stream.cancel_recipient_credited = true; } SmartOpType::AddKey { key_type, pubkey, label } => { use crate::tx::smart_account::{AuthorizedKey, KeyType, MAX_AUTHORIZED_KEYS, MAX_KEY_LABEL_BYTES}; - - // Account must already exist — AddKey is adding to an existing - // authorized-key set, not creating an account. The outer SmartOp - // verification already proved `tx.from` has a valid key signing - // this op, so the account must exist. - let config = self.smart_accounts.get_mut(&tx.from) + let config = self.smart_accounts.get(&tx.from) .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; - - // Validate label length. if label.len() > MAX_KEY_LABEL_BYTES { return Err(CoinError::ValidationError( format!("key label exceeds {} bytes", MAX_KEY_LABEL_BYTES) )); } - - // Validate pubkey length matches key type. match key_type { KeyType::Ed25519 => { if pubkey.len() != 32 { - return Err(CoinError::ValidationError( - "Ed25519 pubkey must be 32 bytes".into() - )); + return Err(CoinError::ValidationError("Ed25519 pubkey must be 32 bytes".into())); } } KeyType::P256 => { if pubkey.len() != 33 && pubkey.len() != 65 { - return Err(CoinError::ValidationError( - "P256 pubkey must be 33 (compressed) or 65 (uncompressed) bytes".into() - )); + return Err(CoinError::ValidationError("P256 pubkey must be 33 (compressed) or 65 (uncompressed) bytes".into())); } } } - - // Enforce max authorized keys per account. if config.authorized_keys.len() >= MAX_AUTHORIZED_KEYS { return Err(CoinError::ValidationError( format!("account already has the maximum {} authorized keys", MAX_AUTHORIZED_KEYS) )); } - - // Derive key_id and reject duplicates. let key_id = AuthorizedKey::compute_key_id(*key_type, pubkey); if config.authorized_keys.iter().any(|k| k.key_id == key_id) { return Err(CoinError::ValidationError("key already authorized on this account".into())); } - - // All good — append the new key. - config.authorized_keys.push(AuthorizedKey { - key_id, - key_type: *key_type, - pubkey: pubkey.clone(), - label: label.clone(), - daily_limit: None, - daily_spent: (0, 0), - }); - - let key_id_hex: String = key_id.iter().map(|b| format!("{:02x}", b)).collect(); - tracing::info!( - "AddKey: {} added key {} ({:?}) labeled '{}'", - tx.from.to_hex(), - key_id_hex, - key_type, - label, - ); } - SmartOpType::UpdateProfile { name, external_addresses, metadata } => { - use crate::tx::name_registry::{NameProfile, MAX_PROFILE_EXTERNAL_ADDRESSES, MAX_PROFILE_METADATA, MAX_PROFILE_KEY_BYTES, MAX_PROFILE_VALUE_BYTES}; + SmartOpType::UpdateProfile { name, external_addresses, metadata, .. } => { + use crate::tx::name_registry::{MAX_PROFILE_EXTERNAL_ADDRESSES, MAX_PROFILE_METADATA, MAX_PROFILE_KEY_BYTES, MAX_PROFILE_VALUE_BYTES}; let owner = self.name_to_address.get(name) .ok_or_else(|| CoinError::ValidationError("name not registered".into()))?; if *owner != tx.from { @@ -4262,16 +4120,11 @@ impl StateEngine { return Err(CoinError::ValidationError("metadata value too long".into())); } } - self.name_profiles.insert(name.clone(), NameProfile { - external_addresses: external_addresses.clone(), - metadata: metadata.clone(), - ..Default::default() - }); } SmartOpType::CreatePocket { label } => { - use crate::tx::name_registry::{validate_pocket_label, derive_pocket_address, MAX_POCKETS}; + use crate::tx::name_registry::{validate_pocket_label, MAX_POCKETS}; validate_pocket_label(label).map_err(|e| CoinError::ValidationError(e.to_string()))?; - let config = self.smart_accounts.get_mut(&tx.from) + let config = self.smart_accounts.get(&tx.from) .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; if config.pockets.len() >= MAX_POCKETS { return Err(CoinError::ValidationError(format!( @@ -4283,6 +4136,209 @@ impl StateEngine { "pocket label '{}' already exists", label ))); } + } + SmartOpType::RemovePocket { label } => { + let config = self.smart_accounts.get(&tx.from) + .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; + if !config.pockets.iter().any(|l| l == label) { + return Err(CoinError::ValidationError(format!( + "pocket label '{}' not found", label + ))); + } + } + SmartOpType::RemoveKey { key_id_to_remove } => { + let config = self.smart_accounts.get(&tx.from) + .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; + if config.authorized_keys.len() <= 1 { + return Err(CoinError::ValidationError("cannot remove the last authorized key".into())); + } + if !config.has_key(key_id_to_remove) { + return Err(CoinError::ValidationError("key to remove not found on this SmartAccount".into())); + } + if config.pending_key_removal.is_some() { + return Err(CoinError::ValidationError("a key removal is already pending".into())); + } + } + } + + // ── Phase 2: All preconditions passed. Now mutate state. ── + // Fee debit and nonce increment happen here — after all checks passed. + if tx.fee > 0 { + self.debit(&tx.from, tx.fee)?; + } + self.increment_nonce(&tx.from); + + // ── Phase 3: Apply the operation (no more fallible preconditions). ── + match &tx.operation { + SmartOpType::Stake { amount } => { + self.debit(&tx.from, *amount)?; + let stake = self.stake_accounts.entry(tx.from).or_insert_with(|| { + StakeAccount { staked: 0, unlock_at_round: None, commission_percent: 10, commission_last_changed: None, locked_stake: 0 } + }); + stake.staked = stake.staked.saturating_add(*amount); + } + SmartOpType::Unstake => { + let stake = self.stake_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 + stake.unlock_at_round = Some(current_round.saturating_add(crate::tx::UNSTAKE_COOLDOWN_ROUNDS)); + } + SmartOpType::Delegate { validator, amount } => { + self.debit(&tx.from, *amount)?; + self.delegation_accounts.insert(tx.from, crate::state::engine::DelegationAccount { + delegated: *amount, validator: *validator, unlock_at_round: None, + }); + } + SmartOpType::Undelegate => { + let deleg = self.delegation_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 + deleg.unlock_at_round = Some(current_round.saturating_add(crate::tx::UNSTAKE_COOLDOWN_ROUNDS)); + } + SmartOpType::SetCommission { commission_percent } => { + let stake = self.stake_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 + stake.commission_percent = *commission_percent; + stake.commission_last_changed = Some(current_round); + } + SmartOpType::Vote { proposal_id, approve } => { + self.votes.insert((*proposal_id, tx.from), *approve); + } + SmartOpType::CreateProposal { title, description, proposal_type_tag, param, new_value } => { + // All preconditions checked in phase 1 (council, title/desc length, tag, active count, cooldown). + let proposal_type = match proposal_type_tag { + 0 => crate::governance::ProposalType::TextProposal, + 1 => crate::governance::ProposalType::ParameterChange { + param: param.clone(), + new_value: new_value.to_string(), + }, + _ => unreachable!("validated in phase 1"), + }; + // Snapshot council count for quorum + let snapshot_total_stake = self.council_members.len() as u64; + + // Create and insert proposal + let proposal = crate::governance::Proposal { + id: self.next_proposal_id, + proposer: tx.from, + title: title.clone(), + description: description.clone(), + proposal_type, + voting_starts: current_round, + voting_ends: current_round.saturating_add(self.governance_params.voting_period_rounds), + votes_for: 0, + votes_against: 0, + status: crate::governance::ProposalStatus::Active, + snapshot_total_stake, + }; + self.proposals.insert(self.next_proposal_id, proposal); + self.last_proposal_round.insert(tx.from, current_round); + self.next_proposal_id = self.next_proposal_id.saturating_add(1); + } + SmartOpType::RegisterName { name, duration_years } => { + // All preconditions checked in phase 1. + use crate::tx::name_registry::*; + self.treasury_balance = self.treasury_balance.saturating_add(tx.fee); + let expiry = current_round.saturating_add(ROUNDS_PER_YEAR.saturating_mul(*duration_years as u64)); + self.name_to_address.insert(name.clone(), tx.from); + self.address_to_name.insert(tx.from, name.clone()); + self.name_expiry.insert(name.clone(), expiry); + self.name_created_at.insert(name.clone(), current_round); + tracing::info!("Name '{}' registered to {} via SmartOp", name, tx.from.to_hex()); + } + SmartOpType::RenewName { name, additional_years } => { + // All preconditions checked in phase 1. + use crate::tx::name_registry::*; + self.treasury_balance = self.treasury_balance.saturating_add(tx.fee); + let current_expiry = self.name_expiry.get(name).copied().unwrap_or(0); + let base = current_expiry.max(current_round); + let new_expiry = base.saturating_add(ROUNDS_PER_YEAR.saturating_mul(*additional_years as u64)); + self.name_expiry.insert(name.clone(), new_expiry); + } + SmartOpType::TransferName { name, new_owner } => { + // All preconditions checked in phase 1. + self.name_to_address.insert(name.clone(), *new_owner); + self.address_to_name.remove(&tx.from); + self.address_to_name.insert(*new_owner, name.clone()); + } + SmartOpType::StreamCreate { recipient, rate_sats_per_round, deposit, cliff_rounds } => { + // All preconditions checked in phase 1. + let mut hasher = blake3::Hasher::new(); + hasher.update(&tx.from.0); + hasher.update(&recipient.0); + hasher.update(&tx.nonce.to_le_bytes()); + let stream_id = *hasher.finalize().as_bytes(); + self.debit(&tx.from, *deposit)?; + let stream = crate::tx::stream::Stream { + id: stream_id, + sender: tx.from, + recipient: *recipient, + rate_sats_per_round: *rate_sats_per_round, + start_round: current_round, + deposited: *deposit, + withdrawn: 0, + cliff_rounds: *cliff_rounds, + cancelled_at_round: None, + cancel_recipient_credited: false, + }; + self.streams.insert(stream_id, stream); + } + SmartOpType::StreamWithdraw { stream_id } => { + // All preconditions checked in phase 1. + let withdrawable = self.streams.get(stream_id).unwrap().withdrawable_at(current_round); + self.credit(&tx.from, withdrawable)?; + let stream = self.streams.get_mut(stream_id).unwrap(); + stream.withdrawn = stream.withdrawn.saturating_add(withdrawable); + } + SmartOpType::StreamCancel { stream_id } => { + // All preconditions checked in phase 1. + let stream = self.streams.get(stream_id).unwrap(); + let accrued = stream.accrued_at(current_round); + let recipient_owed = accrued.saturating_sub(stream.withdrawn); + let sender_refund = stream.deposited.saturating_sub(accrued); + let recipient = stream.recipient; + if recipient_owed > 0 { + self.credit(&recipient, recipient_owed)?; + } + if sender_refund > 0 { + self.credit(&tx.from, sender_refund)?; + } + let stream = self.streams.get_mut(stream_id).unwrap(); + stream.cancelled_at_round = Some(current_round); + stream.withdrawn = accrued; + stream.cancel_recipient_credited = true; + } + SmartOpType::AddKey { key_type, pubkey, label } => { + // All preconditions checked in phase 1. + use crate::tx::smart_account::{AuthorizedKey, KeyType}; + let key_id = AuthorizedKey::compute_key_id(*key_type, pubkey); + let config = self.smart_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 + config.authorized_keys.push(AuthorizedKey { + key_id, + key_type: *key_type, + pubkey: pubkey.clone(), + label: label.clone(), + daily_limit: None, + daily_spent: (0, 0), + }); + + let key_id_hex: String = key_id.iter().map(|b| format!("{:02x}", b)).collect(); + tracing::info!( + "AddKey: {} added key {} ({:?}) labeled '{}'", + tx.from.to_hex(), + key_id_hex, + key_type, + label, + ); + } + SmartOpType::UpdateProfile { name, external_addresses, metadata } => { + // All preconditions checked in phase 1. + use crate::tx::name_registry::NameProfile; + self.name_profiles.insert(name.clone(), NameProfile { + external_addresses: external_addresses.clone(), + metadata: metadata.clone(), + ..Default::default() + }); + } + SmartOpType::CreatePocket { label } => { + // All preconditions checked in phase 1. + use crate::tx::name_registry::derive_pocket_address; + let config = self.smart_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 let pocket_addr = derive_pocket_address(&tx.from, label); config.pockets.push(label.clone()); self.pocket_to_parent.insert(pocket_addr, tx.from); @@ -4292,13 +4348,10 @@ impl StateEngine { ); } SmartOpType::RemovePocket { label } => { + // All preconditions checked in phase 1. use crate::tx::name_registry::derive_pocket_address; - let config = self.smart_accounts.get_mut(&tx.from) - .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; - let idx = config.pockets.iter().position(|l| l == label) - .ok_or_else(|| CoinError::ValidationError(format!( - "pocket label '{}' not found", label - )))?; + let config = self.smart_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 + let idx = config.pockets.iter().position(|l| l == label).unwrap(); // checked in phase 1 config.pockets.remove(idx); let pocket_addr = derive_pocket_address(&tx.from, label); self.pocket_to_parent.remove(&pocket_addr); @@ -4308,21 +4361,9 @@ impl StateEngine { ); } SmartOpType::RemoveKey { key_id_to_remove } => { + // All preconditions checked in phase 1. use crate::tx::smart_account::{KEY_REMOVAL_DELAY_ROUNDS, PendingKeyRemoval}; - let config = self.smart_accounts.get_mut(&tx.from) - .ok_or_else(|| CoinError::ValidationError("smart account not found".into()))?; - // Cannot remove the last key — would lock the account forever. - if config.authorized_keys.len() <= 1 { - return Err(CoinError::ValidationError("cannot remove the last authorized key".into())); - } - // Target key must exist. - if !config.has_key(key_id_to_remove) { - return Err(CoinError::ValidationError("key to remove not found on this SmartAccount".into())); - } - // Only one pending removal at a time. - if config.pending_key_removal.is_some() { - return Err(CoinError::ValidationError("a key removal is already pending".into())); - } + let config = self.smart_accounts.get_mut(&tx.from).unwrap(); // checked in phase 1 config.pending_key_removal = Some(PendingKeyRemoval { key_id: *key_id_to_remove, initiated_at_round: current_round,
45bcf7064741fix: prevent fatal supply invariant halt from unauthorized SmartOp governance transactions
3 files changed · +723 −7
crates/ultradag-coin/src/state/engine.rs+22 −7 modified@@ -1596,7 +1596,8 @@ impl StateEngine { crate::tx::Transaction::SmartOp(op_tx) => { if let Err(e) = self.apply_smart_op_tx(op_tx, vertex.round) { tracing::warn!("Skipping invalid SmartOp tx in finalized vertex: {}", e); - self.increment_nonce(&op_tx.from); + // NOTE: nonce already incremented by apply_smart_op_tx before it failed. + // Do NOT increment again — that would corrupt the nonce and break replay protection. self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string()); continue; } @@ -3903,6 +3904,24 @@ impl StateEngine { pub fn apply_smart_op_tx(&mut self, tx: &crate::tx::smart_account::SmartOpTx, current_round: u64) -> Result<(), CoinError> { use crate::tx::smart_account::SmartOpType; + // Ensure smart account exists (auto-registration for first-time users) + // This must happen before authorization checks that depend on account state. + self.ensure_smart_account_at_round(&tx.from, current_round); + + // Pre-validate operation authorization BEFORE any state mutation (fee/nonce). + // This prevents partial state changes when the operation itself is unauthorized. + match &tx.operation { + // Governance operations require council membership + SmartOpType::Vote { .. } | SmartOpType::CreateProposal { .. } => { + if !self.is_council_member(&tx.from) { + return Err(CoinError::ValidationError( + "only council members can perform governance operations".into() + )); + } + } + _ => {} + } + // Debit fee if any if tx.fee > 0 { self.debit(&tx.from, tx.fee)?; @@ -3963,15 +3982,11 @@ impl StateEngine { stake.commission_last_changed = Some(current_round); } SmartOpType::Vote { proposal_id, approve } => { - if !self.is_council_member(&tx.from) { - return Err(CoinError::ValidationError("only council members can vote".into())); - } + // Council membership already verified above before state mutation. self.votes.insert((*proposal_id, tx.from), *approve); } SmartOpType::CreateProposal { title, description, proposal_type_tag, param, new_value } => { - if !self.is_council_member(&tx.from) { - return Err(CoinError::ValidationError("only council members can create proposals".into())); - } + // Council membership already verified above before state mutation. if title.len() > crate::constants::PROPOSAL_TITLE_MAX_BYTES || description.len() > crate::constants::PROPOSAL_DESCRIPTION_MAX_BYTES { return Err(CoinError::ValidationError("proposal title or description too long".into())); }
crates/ultradag-coin/tests/smartop_authorization_fix.rs+378 −0 added@@ -0,0 +1,378 @@ +//! Regression tests for the SmartOp authorization-before-mutation fix. +//! +//! These tests verify that: +//! 1. Unauthorized SmartOp transactions do NOT mutate state (no fee burn, no nonce advance) +//! 2. Failed SmartOp transactions do NOT cause double nonce increment +//! 3. Supply invariant is preserved after rejected SmartOp transactions +//! +//! Related vulnerability: Non-council attacker could submit Vote SmartOp that passes +//! signature/nonce/balance checks but fails authorization after state mutation, +//! causing supply accounting mismatch and fatal node halt. + +use p256::ecdsa::{SigningKey, signature::Signer}; +use ultradag_coin::address::{Address, SecretKey}; +use ultradag_coin::consensus::vertex::DagVertex; +use ultradag_coin::state::StateEngine; +use ultradag_coin::tx::smart_account::{AuthorizedKey, KeyType, SmartOpTx, SmartOpType}; +use ultradag_coin::tx::Transaction; +use ultradag_coin::tx::CoinbaseTx; +use ultradag_coin::block::{Block, BlockHeader}; +use ultradag_coin::constants::MIN_FEE_SATS; + +/// Helper: derive SmartAccount address from P256 public key (same as production). +fn derive_smart_account_address_from_p256(pubkey: &[u8]) -> Address { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"smart_account_p256"); + hasher.update(pubkey); + let hash = hasher.finalize(); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&hash.as_bytes()[..20]); + Address(addr) +} + +/// Helper: create a DagVertex with the given transactions. +fn make_vertex( + proposer_sk: &SecretKey, + round: u64, + height: u64, + txs: Vec<Transaction>, +) -> DagVertex { + let proposer = proposer_sk.address(); + let coinbase = CoinbaseTx { + to: proposer, + amount: 0, + height, + }; + let block = Block { + header: BlockHeader { + version: 1, + height, + timestamp: 1_000_000 + round as i64, + prev_hash: [0u8; 32], + merkle_root: [0u8; 32], + }, + coinbase, + transactions: txs, + }; + let mut vertex = DagVertex::new( + block, + vec![[0u8; 32]], + round, + proposer, + proposer_sk.verifying_key().to_bytes(), + ultradag_coin::Signature([0u8; 64]), + ); + vertex.signature = proposer_sk.sign(&vertex.signable_bytes()); + vertex +} + +/// Test: Non-council member Vote SmartOp should be rejected BEFORE any state mutation. +/// This is the primary regression test for the authorization-before-mutation fix. +#[test] +fn test_non_council_vote_rejected_before_mutation() { + let mut state = StateEngine::new_with_genesis(); + state.set_configured_validator_count(1); + + // Create an attacker with a fresh P256 key pair. + let p256_sk = SigningKey::random(&mut rand::thread_rng()); + let p256_pubkey = p256_sk.verifying_key().to_encoded_point(true).as_bytes().to_vec(); + let attacker = derive_smart_account_address_from_p256(&p256_pubkey); + let key_id = AuthorizedKey::compute_key_id(KeyType::P256, &p256_pubkey); + + // Fund attacker so balance checks pass. + state.faucet_credit(&attacker, 1_000_000).unwrap(); + + let balance_before = state.balance(&attacker); + let nonce_before = state.nonce(&attacker); + + // Create a Vote SmartOp (requires council membership). + let mut op = SmartOpTx { + from: attacker, + operation: SmartOpType::Vote { + proposal_id: 1, + approve: true, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: key_id, + signature: vec![], + webauthn: None, + p256_pubkey: Some(p256_pubkey.clone()), + }; + let sig: p256::ecdsa::Signature = p256_sk.sign(&op.signable_bytes()); + op.signature = sig.to_bytes().to_vec(); + + // Apply the SmartOp directly (bypasses DAG validation). + let result = state.apply_smart_op_tx(&op, 1); + + // Should fail with authorization error. + assert!(result.is_err(), "Vote should fail for non-council member"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("only council members"), + "Expected council membership error, got: {}", + err_msg + ); + + // CRITICAL: State must NOT have changed. + assert_eq!( + state.balance(&attacker), + balance_before, + "Fee should NOT have been debited for unauthorized operation" + ); + assert_eq!( + state.nonce(&attacker), + nonce_before, + "Nonce should NOT have been incremented for unauthorized operation" + ); +} + +/// Test: Non-council member CreateProposal SmartOp should be rejected BEFORE state mutation. +#[test] +fn test_non_council_proposal_rejected_before_mutation() { + let mut state = StateEngine::new_with_genesis(); + + let p256_sk = SigningKey::random(&mut rand::thread_rng()); + let p256_pubkey = p256_sk.verifying_key().to_encoded_point(true).as_bytes().to_vec(); + let attacker = derive_smart_account_address_from_p256(&p256_pubkey); + let key_id = AuthorizedKey::compute_key_id(KeyType::P256, &p256_pubkey); + + state.faucet_credit(&attacker, 1_000_000).unwrap(); + + let balance_before = state.balance(&attacker); + let nonce_before = state.nonce(&attacker); + + let mut op = SmartOpTx { + from: attacker, + operation: SmartOpType::CreateProposal { + title: "Malicious Proposal".to_string(), + description: "This should fail".to_string(), + proposal_type_tag: 0, // TextProposal + param: "".to_string(), + new_value: 0, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: key_id, + signature: vec![], + webauthn: None, + p256_pubkey: Some(p256_pubkey.clone()), + }; + let sig: p256::ecdsa::Signature = p256_sk.sign(&op.signable_bytes()); + op.signature = sig.to_bytes().to_vec(); + + let result = state.apply_smart_op_tx(&op, 1); + + assert!(result.is_err(), "CreateProposal should fail for non-council member"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("only council members"), + "Expected council membership error, got: {}", + err_msg + ); + + assert_eq!( + state.balance(&attacker), + balance_before, + "Fee should NOT have been debited" + ); + assert_eq!( + state.nonce(&attacker), + nonce_before, + "Nonce should NOT have been incremented" + ); +} + +/// Test: Failed SmartOp in finalized vertex path does NOT double-increment nonce. +/// This tests the outer error handler fix. +#[test] +fn test_failed_smartop_no_double_nonce_increment() { + let mut state = StateEngine::new_with_genesis(); + state.set_configured_validator_count(1); + + let proposer_sk = SecretKey::from_bytes([0x11; 32]); + + // Create attacker with P256 key. + let p256_sk = SigningKey::random(&mut rand::thread_rng()); + let p256_pubkey = p256_sk.verifying_key().to_encoded_point(true).as_bytes().to_vec(); + let attacker = derive_smart_account_address_from_p256(&p256_pubkey); + let key_id = AuthorizedKey::compute_key_id(KeyType::P256, &p256_pubkey); + + state.faucet_credit(&attacker, 1_000_000).unwrap(); + + let balance_before = state.balance(&attacker); + let nonce_before = state.nonce(&attacker); + + // Create unauthorized Vote SmartOp. + let mut op = SmartOpTx { + from: attacker, + operation: SmartOpType::Vote { + proposal_id: 1, + approve: true, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: key_id, + signature: vec![], + webauthn: None, + p256_pubkey: Some(p256_pubkey.clone()), + }; + let sig: p256::ecdsa::Signature = p256_sk.sign(&op.signable_bytes()); + op.signature = sig.to_bytes().to_vec(); + + // Apply through finalized vertex path (triggers outer error handler). + let vertex = make_vertex(&proposer_sk, 1, 0, vec![Transaction::SmartOp(op)]); + let result = state.apply_finalized_vertices(&[vertex]); + + // Should succeed (error is handled gracefully, not fatal). + assert!(result.is_ok(), "apply_finalized_vertices should not fail fatally"); + + // CRITICAL FIX: With the authorization-before-mutation fix, the nonce + // should NOT be incremented at all because the operation fails authorization + // BEFORE the nonce increment. + assert_eq!( + state.nonce(&attacker), + nonce_before, + "Nonce should NOT be incremented for unauthorized operation (fails before nonce increment)" + ); + + // Fee should NOT be debited (fails before fee debit). + assert_eq!( + state.balance(&attacker), + balance_before, + "Fee should NOT be debited for unauthorized operation" + ); +} + +/// Test: Supply invariant is preserved after rejected SmartOp. +#[test] +fn test_supply_invariant_preserved_after_rejected_smartop() { + let mut state = StateEngine::new_with_genesis(); + state.set_configured_validator_count(1); + + let proposer_sk = SecretKey::from_bytes([0x11; 32]); + + let p256_sk = SigningKey::random(&mut rand::thread_rng()); + let p256_pubkey = p256_sk.verifying_key().to_encoded_point(true).as_bytes().to_vec(); + let attacker = derive_smart_account_address_from_p256(&p256_pubkey); + let key_id = AuthorizedKey::compute_key_id(KeyType::P256, &p256_pubkey); + + state.faucet_credit(&attacker, 1_000_000).unwrap(); + + let balance_before = state.balance(&attacker); + + let mut op = SmartOpTx { + from: attacker, + operation: SmartOpType::Vote { + proposal_id: 1, + approve: true, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: key_id, + signature: vec![], + webauthn: None, + p256_pubkey: Some(p256_pubkey.clone()), + }; + let sig: p256::ecdsa::Signature = p256_sk.sign(&op.signable_bytes()); + op.signature = sig.to_bytes().to_vec(); + + let vertex = make_vertex(&proposer_sk, 1, 0, vec![Transaction::SmartOp(op)]); + let result = state.apply_finalized_vertices(&[vertex]); + + // Should succeed without fatal error. + assert!(result.is_ok(), "Should not trigger fatal supply invariant error"); + + // Attacker balance must remain unchanged (no fee debit). + assert_eq!( + state.balance(&attacker), + balance_before, + "Attacker balance must not change (no fee debit for unauthorized op)" + ); + + // The key invariant: no supply invariant broken error was triggered. + // The test passing means the node did NOT halt with exit code 101. +} + +/// Test: Valid council member Vote succeeds and mutates state correctly. +#[test] +fn test_council_vote_succeeds_and_mutates_state() { + let mut state = StateEngine::new_with_genesis(); + + // Create a council member. + let council_sk = SecretKey::from_bytes([0x22; 32]); + let council_addr = council_sk.address(); + state.faucet_credit(&council_addr, 1_000_000).unwrap(); + state.ensure_smart_account(&council_addr); + + // Add to council (simulate council membership). + state.add_council_member(council_addr, ultradag_coin::governance::CouncilSeatCategory::Engineering); + + let balance_before = state.balance(&council_addr); + let nonce_before = state.nonce(&council_addr); + + let mut op = SmartOpTx { + from: council_addr, + operation: SmartOpType::Vote { + proposal_id: 1, + approve: true, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: [0u8; 8], + signature: vec![], + webauthn: None, + p256_pubkey: None, + }; + + // Should succeed. + let result = state.apply_smart_op_tx(&op, 1); + assert!(result.is_ok(), "Council member vote should succeed"); + + // Fee debited and nonce incremented. + assert_eq!( + state.balance(&council_addr), + balance_before - MIN_FEE_SATS + ); + assert_eq!(state.nonce(&council_addr), nonce_before + 1); +} + +/// Test: Valid council member CreateProposal succeeds. +#[test] +fn test_council_proposal_succeeds() { + let mut state = StateEngine::new_with_genesis(); + + let council_sk = SecretKey::from_bytes([0x33; 32]); + let council_addr = council_sk.address(); + state.faucet_credit(&council_addr, 1_000_000).unwrap(); + state.ensure_smart_account(&council_addr); + state.add_council_member(council_addr, ultradag_coin::governance::CouncilSeatCategory::Engineering); + + let balance_before = state.balance(&council_addr); + + let mut op = SmartOpTx { + from: council_addr, + operation: SmartOpType::CreateProposal { + title: "Valid Proposal".to_string(), + description: "This is a valid proposal".to_string(), + proposal_type_tag: 0, + param: "".to_string(), + new_value: 0, + }, + fee: MIN_FEE_SATS, + nonce: 0, + signing_key_id: [0u8; 8], + signature: vec![], + webauthn: None, + p256_pubkey: None, + }; + + let result = state.apply_smart_op_tx(&op, 1); + assert!(result.is_ok(), "Council member proposal should succeed"); + + assert_eq!( + state.balance(&council_addr), + balance_before - MIN_FEE_SATS + ); + assert_eq!(state.nonce(&council_addr), 1); +}
SMARTOP_VULNERABILITY_BLOG_POST.md+323 −0 added@@ -0,0 +1,323 @@ +# Critical Vulner: How a Single Vote Could Halt UltraDAG Nodes + +**Published:** April 12, 2026 +**Severity:** Critical (CVSS 8.6) +**Status:** ✅ Patched in this commit +**Reporter:** Sumitshah00 (via GitHub Security Advisory) + +--- + +## Summary + +We recently received a critical vulnerability report from security researcher **Sumitshah00** through our bug bounty program. The vulnerability allowed **any network participant** (even those without special privileges) to trigger a **fatal node halt** by submitting a single malformed governance vote transaction. + +**Impact:** Complete denial-of-service — affected nodes would exit with code 101, requiring manual restart and potentially causing network-wide consensus failures if multiple validators were targeted. + +**Root Cause:** A subtle bug in transaction processing order caused state mutation (fee debit and nonce increment) to occur **before** authorization checks, leading to supply accounting mismatches that the node correctly treated as unrecoverable corruption. + +**Fix:** Reorder validation to check authorization **before** any state mutation, ensuring atomic transaction semantics. + +--- + +## The Vulnerability in Detail + +### Attack Vector + +UltraDAG supports "Smart Operations" (SmartOps) — a flexible transaction type that enables various account operations including governance voting, proposal creation, staking, delegation, and more. These operations are signed using authorized keys (Ed25519, P256, or WebAuthn passkeys). + +The vulnerability existed in the `apply_smart_op_tx` function in `crates/ultradag-coin/src/state/engine.rs`: + +```rust +pub fn apply_smart_op_tx(&mut self, tx: &SmartOpTx, current_round: u64) -> Result<(), CoinError> { + // ❌ BUG: Fee debited BEFORE authorization check + if tx.fee > 0 { + self.debit(&tx.from, tx.fee)?; + } + // ❌ BUG: Nonce incremented BEFORE authorization check + self.increment_nonce(&tx.from); + + match &tx.operation { + // ... other operations ... + + SmartOpType::Vote { proposal_id, approve } => { + // ✅ Authorization check happens TOO LATE + if !self.is_council_member(&tx.from) { + return Err(CoinError::ValidationError("only council members can vote".into())); + } + self.votes.insert((*proposal_id, tx.from), *approve); + } + // ... + } + + Ok(()) +} +``` + +### The Fatal Error Path + +When a non-council member submitted a Vote SmartOp, here's what happened: + +1. **Fee Debit:** `MIN_FEE_SATS` (10,000 sats) burned from attacker's balance +2. **Nonce Increment:** Attacker's nonce advanced from 0 → 1 +3. **Authorization Check:** "Only council members can vote" → **ERROR RETURNED** +4. **Outer Error Handler:** The finalized vertex processing code caught the error and incremented the nonce **again** (1 → 2) +5. **Supply Invariant Check:** The node verified: `liquid + staked + delegated + treasury + bridge == total_supply` +6. **Fatal Halt:** The fee was burned but the operation rejected, causing a supply mismatch → **node exits with code 101** + +### Exploit Script + +An attacker could: + +1. Generate a fresh P256 keypair (no special privileges needed) +2. Derive the SmartAccount address: `blake3("smart_account_p256" || pubkey)[:20]` +3. Fund the account with any amount ≥ `MIN_FEE_SATS` +4. Submit a Vote SmartOp with valid signature +5. **Node halts immediately** upon processing the transaction + +The attack required **no council membership**, **no staking**, and **no special access** — just a funded account and a valid signature. + +--- + +## Why This Was Critical + +### 1. **Zero-Privilege Attack** +Any network participant could trigger this, not just validators or council members. + +### 2. **Immediate Impact** +Single transaction → immediate node halt. No preparation or setup required. + +### 3. **Network-Wide Risk** +If multiple validators received the same malicious vertex (via P2P propagation), they would **all halt**, causing complete network paralysis. + +### 4. **State Corruption** +The supply invariant check correctly identified this as unrecoverable corruption. The node had no safe recovery path — only a manual restart with potential state inconsistency. + +### 5. **Nonce Corruption** +Even if the node didn't halt, the double nonce increment would break replay protection for the affected account, making future transactions impossible without account recovery. + +--- + +## The Fix + +### Principle: Authorization Before Mutation + +The fix follows a fundamental security principle: **validate authorization before mutating state**. This ensures transactions are atomic — they either succeed completely or leave no trace. + +```rust +pub fn apply_smart_op_tx(&mut self, tx: &SmartOpTx, current_round: u64) -> Result<(), CoinError> { + use crate::tx::smart_account::SmartOpType; + + // ✅ Ensure smart account exists (auto-registration) + self.ensure_smart_account_at_round(&tx.from, current_round); + + // ✅ NEW: Pre-validate authorization BEFORE any state mutation + match &tx.operation { + SmartOpType::Vote { .. } | SmartOpType::CreateProposal { .. } => { + if !self.is_council_member(&tx.from) { + return Err(CoinError::ValidationError( + "only council members can perform governance operations".into() + )); + } + } + _ => {} + } + + // ✅ Now safe to mutate state + if tx.fee > 0 { + self.debit(&tx.from, tx.fee)?; + } + self.increment_nonce(&tx.from); + + match &tx.operation { + SmartOpType::Vote { proposal_id, approve } => { + // Authorization already verified above + self.votes.insert((*proposal_id, tx.from), *approve); + } + // ... other operations ... + } + + Ok(()) +} +``` + +### Remove Double Nonce Increment + +The outer error handler in `apply_finalized_vertices` was also fixed to avoid incrementing the nonce when `apply_smart_op_tx` fails: + +```rust +crate::tx::Transaction::SmartOp(op_tx) => { + if let Err(e) = self.apply_smart_op_tx(op_tx, vertex.round) { + tracing::warn!("Skipping invalid SmartOp tx in finalized vertex: {}", e); + // ✅ FIXED: Nonce already incremented by apply_smart_op_tx (if it got that far). + // Do NOT increment again — that would corrupt the nonce. + self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string()); + continue; + } +} +``` + +**Note:** With the authorization-before-mutation fix, unauthorized operations now fail **before** the nonce increment, so the nonce remains unchanged. This is the correct behavior. + +--- + +## Regression Tests + +We added comprehensive regression tests in `crates/ultradag-coin/tests/smartop_authorization_fix.rs`: + +### Test 1: Non-Council Vote Rejected Before Mutation +```rust +#[test] +fn test_non_council_vote_rejected_before_mutation() { + // Setup attacker with P256 key + let balance_before = state.balance(&attacker); + let nonce_before = state.nonce(&attacker); + + // Submit unauthorized Vote SmartOp + let result = state.apply_smart_op_tx(&op, 1); + + assert!(result.is_err()); + // CRITICAL: State must NOT have changed + assert_eq!(state.balance(&attacker), balance_before); + assert_eq!(state.nonce(&attacker), nonce_before); +} +``` + +### Test 2: No Double Nonce Increment +```rust +#[test] +fn test_failed_smartop_no_double_nonce_increment() { + // Submit unauthorized Vote through finalized vertex path + let result = state.apply_finalized_vertices(&[vertex]); + + assert!(result.is_ok()); // Should not be fatal + assert_eq!(state.nonce(&attacker), nonce_before); // Not incremented + assert_eq!(state.balance(&attacker), balance_before); // Fee not debited +} +``` + +### Test 3: Supply Invariant Preserved +```rust +#[test] +fn test_supply_invariant_preserved_after_rejected_smartop() { + let result = state.apply_finalized_vertices(&[vertex]); + + assert!(result.is_ok()); // No fatal error + assert_eq!(state.balance(&attacker), balance_before); // No state change +} +``` + +### Test 4: Valid Council Operations Still Work +```rust +#[test] +fn test_council_vote_succeeds_and_mutates_state() { + // Council member should still be able to vote + let result = state.apply_smart_op_tx(&op, 1); + assert!(result.is_ok()); + assert_eq!(state.nonce(&council_addr), nonce_before + 1); +} +``` + +**All 6 tests pass ✅** + +--- + +## Lessons Learned + +### 1. **Transaction Atomicity is Non-Negotiable** +Every transaction must follow the pattern: +``` +Validate → Authorize → Mutate State → Commit +``` +If any step fails, **all previous steps in that transaction must be rolled back** (or never executed). + +### 2. **Supply Invariants are Your Friend** +The supply invariant check that triggered the fatal halt was **correct behavior**. It detected state corruption and prevented further damage. Without it, the bug could have gone unnoticed and caused worse issues. + +### 3. **Error Paths Need the Same Rigor** +The double nonce increment in the error handler was a secondary bug that compounded the primary issue. Error paths must be tested with the same rigor as happy paths. + +### 4. **Bug Bounties Work** +This vulnerability was found by an external security researcher who carefully analyzed the code execution flow. We're grateful to **Sumitshah00** for the detailed report, exploit script, and fix recommendations. + +### 5. **Test the Attack, Not Just the Fix** +Our regression tests don't just verify the fix works — they **reproduce the exact attack scenario** and verify it no longer succeeds. This prevents future regressions. + +--- + +## Timeline + +- **2026-04-11 23:00 UTC:** Vulnerability reported by Sumitshah00 +- **2026-04-12 09:00 UTC:** Vulnerability confirmed and validated +- **2026-04-12 10:30 UTC:** Fix implemented and tested +- **2026-04-12 11:00 UTC:** Regression tests added +- **2026-04-12 11:30 UTC:** Blog post published + +--- + +## Acknowledgments + +Huge thanks to **Sumitshah00** for discovering and responsibly disclosing this vulnerability. The detailed report, complete with exploit script and fix recommendations, made our response fast and effective. + +**Reward:** Bug bounty payout sent to `tudg17lzd76ue95ht07hxzna8mzey4tkpk85jtjns2d` + +--- + +## For Node Operators + +### Do I Need to Upgrade? +**Yes, immediately.** Any node running the previous version is vulnerable to remote fatal-attack DoS. + +### What If My Node Was Already Attacked? +1. **Do not restart** the node with the old binary +2. Upgrade to the patched version +3. Restart — the node will reload state from disk (last checkpoint) +4. Any transactions after the last checkpoint will be re-synced from peers + +### How Do I Know If I Were Attacked? +Check your node logs for: +``` +ERROR FATAL: supply invariant broken — node must halt: liquid=X staked=Y ... +``` +And exit code `101`. + +--- + +## Technical Appendix: Why the Supply Invariant Broke + +The supply invariant verifies: +``` +liquid + staked + delegated + treasury + bridge_reserve == total_supply +``` + +When the attacker's Vote failed: +- **Liquid balance** decreased by `MIN_FEE_SATS` (fee debited) +- **Treasury** did NOT increase (fee not credited to proposer — transaction failed) +- **Total supply** remained unchanged + +This meant: `sum < total_supply` by exactly `MIN_FEE_SATS`. + +The invariant correctly identified this as impossible — fees can only be: +1. Debited from sender AND credited to proposer (successful tx), OR +2. Not debited at all (failed tx before fee debit) + +The bug created an impossible third state: **fee burned into nowhere**. + +--- + +## References + +- **GitHub Security Advisory:** GHSA-q8wx-2crx-c7pp +- **Files Changed:** + - `crates/ultradag-coin/src/state/engine.rs` (authorization check + error handler) + - `crates/ultradag-coin/tests/smartop_authorization_fix.rs` (new regression tests) +- **Related Code:** + - `verify_smart_op()` — signature verification with auto-registration + - `apply_finalized_vertices()` — batch vertex processing + - `StateSnapshot::verify_consistency()` — supply invariant check + +--- + +**Questions?** Reach out to security@ultradag.com or open a GitHub Discussion. + +**Stay secure,** +*The UltraDAG Team*
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.