VYPR
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

1

Patches

2
2f5a3a237ea5

fix: complete SmartOp validate-before-mutate for ALL operation types

https://github.com/UltraDAGcom/coreJohanApr 12, 2026via nvd-ref
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,
    
45bcf7064741

fix: prevent fatal supply invariant halt from unauthorized SmartOp governance transactions

https://github.com/UltraDAGcom/coreJohanApr 12, 2026via nvd-ref
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

3

News mentions

0

No linked articles in our index yet.