Root cause
Injection & unsafe execution
Add SmartAccount system, Name Registry, and passkey-first onboarding
AI-assisted change: Claude
How AI contributed: Flawed AI-written codePublished May 8, 2026 in
UltraDAGcom/core
SmartAccount spending-policy bypass: check_spending_policy and per-key daily-limit checks keyed on tx.from without resolving pocket_to_parent first; pockets have no SmartAccountConfig so get_mut returns None -> Ok(None) 'no policy', bypassing 24h vault delay / daily limits / whitelist. verify_smart_transfer (signature auth) DID resolve pocket->parent while policy enforcement did not -> asymmetric delegation allows draining every pocket of an account with a strict policy. Fix resolves policy_owner = pocket_to_parent.get(from).unwrap_or(from) in both check_spending_policy and apply_smart_transfer_tx, threads from through PendingVaultTransfer for cancel-refund to origin surface.
Root cause
Add SmartAccount system, Name Registry, and passkey-first onboarding
AI-assisted change: Claude
Fix
fix(smart-account): enforce parent policy on pocket transfers (GHSA-9chc-gjfr-6hrq)
Case facts
@@ -0,0 +1,1387 @@+use serde::{Deserialize, Serialize};++use crate::address::{Address, Signature};++// ────────────────────────────────────────────────────────────+// Sponsored transactions (fee-payer)+// ────────────────────────────────────────────────────────────++/// A third-party fee payer for sponsored transactions.+/// When present on a transaction, the fee is debited from the fee_payer's address+/// instead of the sender's address. This enables:+/// - Relay-sponsored account creation (new user has no UDAG)+/// - Relay-sponsored name registration+/// - Any third party paying fees on behalf of a user+///+/// The fee_payer must sign the SAME signable_bytes as the primary signer,+/// proving they authorized the fee payment for this specific transaction.+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]+pub struct FeePayer {+ /// Address of the fee payer.+ pub address: Address,+ /// Fee payer's Ed25519 public key (must hash to address).+ pub pub_key: [u8; 32],+ /// Fee payer's signature over the transaction's signable_bytes.+ pub signature: Signature,+ /// Fee payer's nonce (incremented on their account).+ pub nonce: u64,+}++impl FeePayer {+ /// Verify the fee payer's Ed25519 signature over the given message.+ pub fn verify(&self, message: &[u8]) -> bool {+ if Address::from_pubkey(&self.pub_key) != self.address {+ return false;+ }+ let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&self.pub_key) else {+ return false;+ };+ let sig = ed25519_dalek::Signature::from_bytes(&self.signature.0);+ vk.verify_strict(message, &sig).is_ok()+ }+}++/// Maximum number of authorized keys per SmartAccount.+pub const MAX_AUTHORIZED_KEYS: usize = 10;++/// Maximum label length in bytes.+pub const MAX_KEY_LABEL_BYTES: usize = 32;++/// Time-lock delay for key removal (same as unstake cooldown: ~2.8 hours at 5s rounds).+pub const KEY_REMOVAL_DELAY_ROUNDS: u64 = 2_016;++// ────────────────────────────────────────────────────────────+// Signature verification+// ────────────────────────────────────────────────────────────++/// Verify a P256 (secp256r1) signature over a message.+/// Accepts both DER-encoded and raw (r || s, 64 bytes) signature formats.+/// Public key can be compressed (33 bytes) or uncompressed (65 bytes) SEC1.+pub fn verify_p256(pubkey: &[u8], sig_bytes: &[u8], message: &[u8]) -> bool {+ use p256::ecdsa::{signature::Verifier, Signature as P256Sig, VerifyingKey};++ let vk = match VerifyingKey::from_sec1_bytes(pubkey) {+ Ok(k) => k,+ Err(_) => return false,+ };++ // Try DER format first (variable length, typically 70-72 bytes)+ if let Ok(sig) = P256Sig::from_der(sig_bytes) {+ return vk.verify(message, &sig).is_ok();+ }++ // Try raw r||s format (exactly 64 bytes)+ if let Ok(sig) = P256Sig::try_from(sig_bytes) {+ return vk.verify(message, &sig).is_ok();+ }++ false+}++/// Verify an Ed25519 signature over a message.+/// Used by SmartAccount verification dispatch (same as existing verify_strict).+pub fn verify_ed25519(pubkey: &[u8], sig_bytes: &[u8], message: &[u8]) -> bool {+ if pubkey.len() != 32 || sig_bytes.len() != 64 {+ return false;+ }+ let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey.try_into().unwrap()) else {+ return false;+ };@@ -280,6 +280,21 @@ pub struct StateEngine { /// Prevents double-slashing the same equivocation event if evidence is re-encountered /// after the applied_validators_per_round tracker is pruned. slashed_events: std::collections::HashSet<(Address, u64)>,+ /// SmartAccount configurations: address → config (authorized keys, pending operations).+ /// Created automatically on first outgoing transaction via ensure_smart_account().+ /// Persisted in SMART_ACCOUNTS redb table.+ smart_accounts: HashMap<Address, crate::tx::smart_account::SmartAccountConfig>,+ // ── Name Registry ───────────────────────────────────────+ /// Name → Address forward mapping.+ name_to_address: HashMap<String, Address>,+ /// Address → Name reverse lookup (one name per address).+ address_to_name: HashMap<Address, String>,+ /// Name → expiry round.+ name_expiry: HashMap<String, u64>,+ /// Name → creation round (for anti-gaming).+ name_created_at: HashMap<String, u64>,+ /// Name → optional cross-chain profile data.+ name_profiles: HashMap<String, crate::tx::name_registry::NameProfile>, } impl StateEngine {@@ -317,6 +332,12 @@ impl StateEngine { bridge_release_first_vote_round: HashMap::new(), bridge_release_disagree_count: HashMap::new(), slashed_events: std::collections::HashSet::new(),+ smart_accounts: HashMap::new(),+ name_to_address: HashMap::new(),+ address_to_name: HashMap::new(),+ name_expiry: HashMap::new(),+ name_created_at: HashMap::new(),+ name_profiles: HashMap::new(), } } @@ -1005,8 +1026,13 @@ impl StateEngine { // will fail nonce validation. We must skip these gracefully — a finalized // vertex cannot be un-finalized, so aborting would permanently halt finality. for tx in &vertex.block.transactions {- // Verify signature- if !tx.verify_signature() {+ // Verify signature — SmartTransfer uses state-based key lookup instead of+ // the stateless blake3(pub_key)==from check used by legacy transaction types.+ let sig_valid = match tx {+ crate::tx::Transaction::SmartTransfer(st) => self.verify_smart_transfer(st),+ _ => tx.verify_signature(),+ };+ if !sig_valid { tracing::warn!("Skipping tx with invalid signature in finalized vertex"); self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, "invalid signature"); continue;@@ -1151,6 +1177,110 @@ impl StateEngine { continue; } }+ crate::tx::Transaction::AddKey(add_key_tx) => {+ if let Err(e) = self.apply_add_key_tx(add_key_tx) {+ tracing::warn!("Skipping invalid AddKey tx in finalized vertex: {}", e);+ self.increment_nonce(&add_key_tx.from);+ self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string());+ continue;+ }+ }+ crate::tx::Transaction::RemoveKey(remove_key_tx) => {+ if let Err(e) = self.apply_remove_key_tx(remove_key_tx, vertex.round) {+ tracing::warn!("Skipping invalid RemoveKey tx in finalized vertex: {}", e);+ self.increment_nonce(&remove_key_tx.from);+ self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string());+ continue;+ }+ }+ crate::tx::Transaction::SmartTransfer(smart_tx) => {+ if let Err(e) = self.apply_smart_transfer_tx(smart_tx) {+ tracing::warn!("Skipping invalid SmartTransfer tx in finalized vertex: {}", e);+ self.increment_nonce(&smart_tx.from);+ self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string());+ continue;+ }+ }+ crate::tx::Transaction::SetRecovery(recovery_tx) => {+ if let Err(e) = self.apply_set_recovery_tx(recovery_tx) {+ tracing::warn!("Skipping invalid SetRecovery tx in finalized vertex: {}", e);+ self.increment_nonce(&recovery_tx.from);+ self.record_receipt(tx.hash(), vertex.round, vertex_hash, false, &e.to_string());+ continue;+ }+ }+ crate::tx::Transaction::RecoverAccount(recover_tx) => {+ if let Err(e) = self.apply_recover_account_tx(recover_tx) {+ tracing::warn!("Skipping invalid RecoverAccount tx in finalized vertex: {}", e);@@ -3475,16 +3475,20 @@ impl StateEngine { self.increment_nonce(&tx.from); - // Refund the amount (was debited when vault was created)- self.credit(&tx.from, vault.amount).map_err(|e| {+ // Refund to the origin surface — pocket-originated vaults must return+ // to the pocket, not the parent, or a cancel silently rehomes the+ // balance across security surfaces. Zero-address (legacy records+ // without the `from` field) falls back to the parent for safety.+ let refund_to = if vault.from == Address::default() { tx.from } else { vault.from };+ self.credit(&refund_to, vault.amount).map_err(|e| { CoinError::ValidationError(format!("Failed to refund vault transfer: {}", e)) })?; let config = self.smart_accounts.get_mut(&tx.from).unwrap(); config.pending_vault_transfers.remove(vault_idx); - tracing::info!("SmartAccount {}: vault transfer cancelled, {} sats refunded",- tx.from.to_hex(), vault.amount);+ tracing::info!("SmartAccount {}: vault transfer cancelled, {} sats refunded to {}",+ tx.from.to_hex(), vault.amount, refund_to.to_hex()); Ok(()) }@@ -3497,7 +3501,11 @@ impl StateEngine { ) -> Result<Option<crate::tx::smart_account::PendingVaultTransfer>, CoinError> { use crate::tx::smart_account::*; - let config = match self.smart_accounts.get_mut(from) {+ // Pockets inherit the parent's spending policy. Resolving here keeps+ // policy enforcement symmetric with signature authorization.+ let policy_owner = self.pocket_to_parent.get(from).copied().unwrap_or(*from);++ let config = match self.smart_accounts.get_mut(&policy_owner) { Some(c) => c, None => return Ok(None), // No SmartAccount = no policy };@@ -3547,6 +3555,7 @@ impl StateEngine { created_at_round: current_round, executes_at_round: current_round.saturating_add(policy.vault_delay_rounds), transfer_id,+ from: *from, }; return Ok(Some(vault)); }@@ -4520,8 +4529,13 @@ impl StateEngine { let current_round = self.last_finalized_round.unwrap_or(0); + // Pockets are policy-transparent: per-key limits, vault thresholds, and+ // daily budgets all live on the parent config. Authorization already+ // resolves to the parent in verify_smart_transfer — enforcement must too.+ let policy_owner = self.pocket_to_parent.get(&tx.from).copied().unwrap_or(tx.from);+ // Check per-key daily spending limit- if let Some(config) = self.smart_accounts.get_mut(&tx.from) {+ if let Some(config) = self.smart_accounts.get_mut(&policy_owner) { if let Some(key) = config.authorized_keys.iter_mut().find(|k| k.key_id == tx.signing_key_id) { if let Some(key_limit) = key.daily_limit { use crate::tx::smart_account::ROUNDS_PER_DAY;@@ -4543,16 +4557,19 @@ impl StateEngine { } } - // Check account-level spending policy (daily limits, vault threshold)+ // Check account-level spending policy (daily limits, vault threshold).+ // check_spending_policy itself resolves pocket→parent, but we pass the+ // raw origin so the vault transfer remembers it for cancel-refund. let vault_result = self.check_spending_policy(&tx.from, &tx.to, tx.amount, tx.nonce, current_round)?; - // Debit sender (amount + fee)+ // Debit sender (amount + fee) from the origin surface. self.debit(&tx.from, tx.total_cost())?; self.increment_nonce(&tx.from); if let Some(vault_transfer) = vault_result {- // Transfer exceeds vault threshold — hold in pending vault (amount already debited)- let config = self.smart_accounts.get_mut(&tx.from).unwrap();+ // Transfer exceeds vault threshold — hold in pending vault on the+ // parent config (only the parent can execute/cancel vault ops).+ let config = self.smart_accounts.get_mut(&policy_owner).unwrap(); config.pending_vault_transfers.push(vault_transfer); tracing::info!("SmartAccount {}: transfer of {} sats to {} held in vault (executes after delay)", tx.from.to_hex(), tx.amount, tx.to.to_hex());@@ -0,0 +1,57 @@+# GHSA-9chc-gjfr-6hrq — Spending-policy bypass via pockets++**Severity:** Critical+**Component:** `ultradag-coin` — `StateEngine` SmartAccount policy enforcement+**Reporter:** Sumitshah00+**Status:** Fixed++## Summary++Pockets (derived sub-addresses under a SmartAccount) inherit the parent's+authorization keys but did not inherit the parent's spending policy. A+transfer originating from a pocket was authorized against the parent's keys+but policy-checked against the pocket address — which has no+`SmartAccountConfig` of its own, so `check_spending_policy` fell through to+`Ok(None)` and bypassed every constraint on the account.++## Impact++Any holder of an authorized key — including a low-security, daily-limited key+whose entire purpose is to cap blast radius — could drain every pocket on the+account by routing the transfer through the pocket surface. The following+were all bypassed for pocket-held funds:++- Account-level daily spending limit+- Vault threshold + time-locked withdrawal delay+- Whitelist-only restrictions+- Per-authorized-key daily limit++The parent-account surface was unaffected; only transfers whose `tx.from` was+a pocket escaped enforcement.++## Root cause++Authorization in `verify_smart_transfer` already resolved+`pocket_to_parent` → parent, but enforcement in `check_spending_policy` and in+`apply_smart_transfer_tx` keyed directly on `tx.from`. With no config at the+pocket address, enforcement silently no-op'd.++## Fix++- `check_spending_policy` now resolves `pocket_to_parent` → parent before+ loading the policy config.+- `apply_smart_transfer_tx` threads the resolved `policy_owner` through the+ per-key daily-limit check and pushes any pending vault transfer onto the+ parent's config.+- `PendingVaultTransfer` gained a `from` field so `apply_cancel_vault_tx`+ refunds to the origin surface. Cancelling a pocket-originated vault+ otherwise silently rehomed the balance onto the parent account.++Five regression tests in `crates/ultradag-coin/tests/smart_account_policy_pocket.rs`+cover the daily-limit, vault-routing, cancel-refund destination, per-key+limit, and whitelist-inheritance paths.++## Credits++Reported by Sumitshah00 via GitHub Security Advisory with a standalone PoC+reproducing the bypass against a 1 UDAG daily-limit policy.@@ -0,0 +1,265 @@+//! Regression tests for GHSA-9chc-gjfr-6hrq — pockets must inherit their+//! parent's spending policy. A transfer originating from a pocket is+//! authorized by the parent's keys; enforcement has to resolve to the parent+//! config too, or every limit (daily, vault, per-key) is bypassed for the+//! pocket balance.++use ultradag_coin::address::{Address, SecretKey, Signature};+use ultradag_coin::state::StateEngine;+use ultradag_coin::tx::name_registry::derive_pocket_address;+use ultradag_coin::tx::smart_account::*;++fn setup(sk: &SecretKey, balance: u64) -> StateEngine {+ let mut engine = StateEngine::new_with_genesis();+ engine.faucet_credit(&sk.address(), balance).unwrap();+ engine.ensure_smart_account(&sk.address());+ engine+}++fn make_set_policy_tx(+ sk: &SecretKey, instant_limit: u64, vault_threshold: u64,+ vault_delay: u64, whitelist: Vec<Address>, daily_limit: Option<u64>, nonce: u64,+) -> SetPolicyTx {+ let mut tx = SetPolicyTx {+ from: sk.address(),+ instant_limit,+ vault_threshold,+ vault_delay_rounds: vault_delay,+ whitelisted_recipients: whitelist,+ daily_limit,+ fee: 10_000,+ nonce,+ pub_key: sk.verifying_key().to_bytes(),+ signature: Signature([0u8; 64]),+ };+ tx.signature = sk.sign(&tx.signable_bytes());+ tx+}++fn create_pocket_op(from: Address, label: &str, nonce: u64) -> SmartOpTx {+ SmartOpTx {+ from,+ operation: SmartOpType::CreatePocket { label: label.to_string() },+ fee: 0, nonce,+ signing_key_id: [0u8; 8],+ signature: vec![],+ webauthn: None,+ p256_pubkey: None,+ }+}++fn make_smart_transfer(+ signer: &SecretKey, from: Address, to: Address, amount: u64, fee: u64, nonce: u64,+) -> SmartTransferTx {+ let pubkey = signer.verifying_key().to_bytes();+ let key_id = AuthorizedKey::compute_key_id(KeyType::Ed25519, &pubkey);+ let mut tx = SmartTransferTx {+ from, to, amount, fee, nonce,+ signing_key_id: key_id,+ signature: vec![],+ memo: None,+ webauthn: None,+ };+ tx.signature = signer.sign(&tx.signable_bytes()).0.to_vec();+ tx+}++fn make_cancel_vault_tx(sk: &SecretKey, transfer_id: [u8; 8], nonce: u64) -> CancelVaultTx {+ let mut tx = CancelVaultTx {+ from: sk.address(),+ transfer_id,+ nonce,+ pub_key: sk.verifying_key().to_bytes(),+ signature: Signature([0u8; 64]),+ };+ tx.signature = sk.sign(&tx.signable_bytes());+ tx+}++/// Register the parent's Ed25519 key on its SmartAccount so SmartTransfer+/// signatures verify against it. Auto-registration normally handles this on+/// the first outgoing tx, but we want the policy in place before the first+/// pocket transfer — so we seed it manually.+fn seed_parent_key(engine: &mut StateEngine, parent: &SecretKey) {+ let pubkey = parent.verifying_key().to_bytes();+ let key_id = AuthorizedKey::compute_key_id(KeyType::Ed25519, &pubkey);+ let config = engine.smart_account_mut_for_test(&parent.address()).unwrap();+ if !config.authorized_keys.iter().any(|k| k.key_id == key_id) {+ config.authorized_keys.push(AuthorizedKey {+ key_id,AI-assisted change f6949c3c218309b247fa326c7b458b99fb2d7cb8436eedc662aff44365aa004b · Fix ed275854c3ee0f5f79a34444b30be2bcd315b7ebf71f747f12f52b2a07755905