diff --git a/dsm_client/deterministic_state_machine/dsm/src/commitments/parameter_comparison.rs b/dsm_client/deterministic_state_machine/dsm/src/commitments/parameter_comparison.rs index 14ca6e41b..ea51ff608 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/commitments/parameter_comparison.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/commitments/parameter_comparison.rs @@ -179,8 +179,6 @@ pub fn extract_operation_parameters( amount, token_id, policy_commit, - authorized_by, - proof_of_authorization, message, } => { let mut params = HashMap::new(); @@ -188,11 +186,6 @@ pub fn extract_operation_parameters( params.insert("amount".to_string(), balance_to_bytes(amount)); params.insert("token_id".to_string(), token_id.clone()); params.insert("policy_commit".to_string(), policy_commit.to_vec()); - params.insert("authorized_by".to_string(), authorized_by.clone()); - params.insert( - "proof_of_authorization".to_string(), - proof_of_authorization.clone(), - ); params.insert("message".to_string(), message.as_bytes().to_vec()); Ok(params) } diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/transition.rs b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/transition.rs index 15ddae690..0d3d182e4 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/transition.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/state_machine/transition.rs @@ -420,15 +420,12 @@ pub fn enforce_operation_authorization(operation: &Operation) -> Result<(), DsmE )); } } - Operation::Mint { - proof_of_authorization, - .. - } => { - if proof_of_authorization.is_empty() { - return Err(DsmError::invalid_operation( - "Mint missing proof_of_authorization", - )); - } + Operation::Mint { .. } => { + // Mint carries NO authorization bytes. Authorization of unit + // creation is the 0x0029 issuance evidence resolved during + // economic admission — there is nothing inside the operation for + // this legacy check to demand, and demanding anything here would + // recreate the second authorization channel that was deleted. } Operation::Burn { proof_of_ownership, .. @@ -1234,22 +1231,17 @@ fn apply_token_balance_delta( } } Operation::Mint { - token_id, - amount, - authorized_by, - proof_of_authorization, - .. + token_id, amount, .. } => { let token_id_str = canonical_token_id_str(token_id) .ok_or_else(|| DsmError::invalid_operation("Mint has malformed or empty token_id"))? .to_string(); - verify_mint_authorization_for_transition( - current_state, - &token_id_str, - amount.value(), - authorized_by, - proof_of_authorization, - )?; + // No embedded-proof verification: Mint authorization is the + // 0x0029 issuance evidence, proven by the economic verifier during + // admission. This legacy path performs only the balance + // arithmetic; on the canonical device-head path the accepting + // layer refuses any positive mint without an attached admission. + let _ = amount; let policy_commit = crate::core::token::resolve_policy_commit(&token_id_str)?; let owner_key = crate::core::token::derive_canonical_balance_key( &policy_commit, @@ -1359,34 +1351,6 @@ fn parse_embedded_proof(proof: &[u8], label: &str) -> Result<(Vec, Vec), Ok((pk, sig)) } -fn verify_mint_authorization_for_transition( - current_state: &State, - token_id: &str, - amount: u64, - authorized_by: &[u8], - proof: &[u8], -) -> Result<(), DsmError> { - let (pk, sig) = parse_embedded_proof(proof, "mint_proof")?; - let policy_commit = crate::core::token::resolve_policy_commit(token_id)?; - - let mut msg = b"mint|v2|".to_vec(); - msg.extend_from_slice(authorized_by); - msg.extend_from_slice(token_id.as_bytes()); - msg.extend_from_slice(&amount.to_le_bytes()); - msg.extend_from_slice(¤t_state.hash); - - let msg_hash = crate::crypto::blake3::token_domain_hash(&policy_commit, "mint", &msg); - let verified = crate::crypto::sphincs::sphincs_verify(&pk, msg_hash.as_bytes(), &sig)?; - if verified { - Ok(()) - } else { - Err(DsmError::unauthorized( - "Invalid mint authorization proof", - None::, - )) - } -} - fn verify_burn_authorization_for_transition( current_state: &State, token_id: &str, @@ -1573,8 +1537,10 @@ mod tests { signed_transfer_op_amount(sk, state_hash, nonce, message, "ERA", 10) } - fn signed_mint_op_amount(sk: &[u8], token_id: &str, amount: u64) -> Operation { - let mut op = Operation::Mint { + // Mint carries no authorization bytes — authority lives in the 0x0029 + // admission evidence, so a fixture mint is just the economic intent. + fn mint_op_amount(token_id: &str, amount: u64) -> Operation { + Operation::Mint { amount: { let mut balance = Balance::zero(); balance.update_add(amount); @@ -1582,26 +1548,12 @@ mod tests { }, token_id: token_id.as_bytes().to_vec(), policy_commit: [0u8; 32], - authorized_by: b"authority".to_vec(), - proof_of_authorization: vec![], message: "test mint".to_string(), - }; - - let bytes = op.to_bytes(); - let sig = sphincs_sign(sk, &bytes).unwrap_or_else(|e| panic!("sign mint failed: {e}")); - if let Operation::Mint { - proof_of_authorization, - .. - } = &mut op - { - *proof_of_authorization = sig; } - - op } - fn signed_mint_op(sk: &[u8]) -> Operation { - signed_mint_op_amount(sk, "token2", 100) + fn signed_mint_op(_sk: &[u8]) -> Operation { + mint_op_amount("token2", 100) } fn signed_burn_op_amount(sk: &[u8], token_id: &str, amount: u64) -> Operation { @@ -1899,8 +1851,7 @@ mod tests { balance.update_add(150); balance }); - let (_state, _pk, sk) = create_test_state_with_keypair(0); - let mint_op = signed_mint_op_amount(&sk, "token1", 50); + let mint_op = mint_op_amount("token1", 50); let result = verify_token_balance_consistency(&prev_state, ¤t_state, &mint_op); assert!(result.is_ok()); @@ -2209,8 +2160,7 @@ mod tests { let current_state = create_test_state(2); // Mint operation but token not added to current state - let (_state, _pk, sk) = create_test_state_with_keypair(0); - let mint_op = signed_mint_op_amount(&sk, "new_token", 100); + let mint_op = mint_op_amount("new_token", 100); let result = verify_token_balance_consistency(&prev_state, ¤t_state, &mint_op); assert!(result.is_ok()); @@ -2236,8 +2186,7 @@ mod tests { balance }); - let (_state, _pk, sk) = create_test_state_with_keypair(0); - let mint_op = signed_mint_op_amount(&sk, "token1", 100); + let mint_op = mint_op_amount("token1", 100); let result = verify_token_balance_consistency(&prev_state, ¤t_state, &mint_op); assert!(result.is_ok()); diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs index d8a850030..5e84b6f80 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/policy/policy_enforcement.rs @@ -366,12 +366,16 @@ impl PolicyEnforcer { } PolicyCondition::TokenAuthority { signers, threshold } => { - // Only gates value issuance/destruction; other operations are - // governed by their own conditions. - if !matches!( - ctx.operation_type.as_str(), - "mint" | "burn" | "create_token" - ) { + // Gates burn and create_token, which still authorize through + // the embedded `token_authorization_preimage` witness. MINT IS + // DELIBERATELY EXCLUDED: since the 0x0029 producer cut, mint + // authorization is the policy-signed issuance evidence bundle + // verified during economic admission — the operation carries + // no witness for this condition to check, and gating it here + // would resurrect the second authorization channel that was + // deleted. Other operations are governed by their own + // conditions. + if !matches!(ctx.operation_type.as_str(), "burn" | "create_token") { return Ok(EnforcementResult::allowed( "TokenAuthority does not gate this operation", tick, diff --git a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs index 6d5a918ae..431492459 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/core/token/token_state_manager.rs @@ -335,27 +335,13 @@ impl TokenStateManager { } Operation::Mint { - amount, - token_id, - authorized_by, - proof_of_authorization, - .. + amount, token_id, .. } => { let token_id_str = canonical_token_id_str(token_id).ok_or_else(|| { DsmError::invalid_operation("Mint has malformed or empty token_id") })?; - if !self.verify_mint_authorization( - token_id_str, - authorized_by, - amount.value(), - current_state.hash, - proof_of_authorization, - )? { - return Err(DsmError::unauthorized( - "Invalid mint authorization", - None::, - )); - } + // No embedded-proof verification: Mint authorization is the + // 0x0029 issuance evidence, proven during economic admission. let owner_pk = ¤t_state.device_info.public_key; let owner_key = self.make_balance_key(owner_pk, token_id_str)?; @@ -483,59 +469,6 @@ impl TokenStateManager { )) } - fn verify_mint_authorization( - &self, - token_id: &str, - authorized_by: &[u8], - amount: u64, - state_hash: [u8; 32], - proof: &[u8], - ) -> Result { - if proof.is_empty() { - return Ok(false); - } - - // proof := u16 pk_len | pk_bytes | u16 sig_len | sig_bytes - if proof.len() < 4 { - return Ok(false); - } - - let mut idx: usize = 0; - let read_u16 = |buf: &[u8], i: &mut usize| -> Result { - if *i + 2 > buf.len() { - return Err(DsmError::invalid_parameter( - "mint_proof: truncated length field", - )); - } - let v = u16::from_le_bytes([buf[*i], buf[*i + 1]]); - *i += 2; - Ok(v) - }; - let read_bytes = |buf: &[u8], i: &mut usize, n: usize| -> Result, DsmError> { - if *i + n > buf.len() { - return Err(DsmError::invalid_parameter("mint_proof: truncated field")); - } - let out = buf[*i..*i + n].to_vec(); - *i += n; - Ok(out) - }; - - let pk_len = read_u16(proof, &mut idx)? as usize; - let pk = read_bytes(proof, &mut idx, pk_len)?; - let sig_len = read_u16(proof, &mut idx)? as usize; - let sig = read_bytes(proof, &mut idx, sig_len)?; - - let policy_commit = self.resolve_policy_commit(token_id)?; - let mut msg = b"mint|v2|".to_vec(); - msg.extend_from_slice(authorized_by); - msg.extend_from_slice(token_id.as_bytes()); - msg.extend_from_slice(&amount.to_le_bytes()); - msg.extend_from_slice(&state_hash); - let msg_hash = crate::crypto::blake3::token_domain_hash(&policy_commit, "mint", &msg); - - sphincs::sphincs_verify(&pk, msg_hash.as_bytes(), &sig) - } - fn verify_token_ownership( &self, token_id: &str, @@ -628,11 +561,10 @@ impl TokenStateManager { ); context.insert("recipient".to_string(), recipient.clone()); } - Operation::Mint { - amount, - authorized_by, - .. - } => { + Operation::Mint { amount, .. } => { + // Amount facts stay (supply semantics may read them); the + // legacy authorized_by witness is gone — Mint authority is the + // 0x0029 evidence, not a caller-chosen byte string. context.insert( "amount_u64".to_string(), amount.value().to_le_bytes().to_vec(), @@ -641,7 +573,6 @@ impl TokenStateManager { "amount".to_string(), amount.value().to_string().into_bytes(), ); - context.insert("authorized_by".to_string(), authorized_by.clone()); } Operation::Burn { amount, .. } => { context.insert( diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/credit.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/credit.rs index b338993be..b4b0c86d8 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/credit.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/credit.rs @@ -45,10 +45,11 @@ use crate::ccb::{class, push_digest32, push_envelope, push_u32, push_u64, CcbErr /// `0x0023` schema 1 — funded by an authorized issuance transition. /// -/// The authorization itself is addressed rather than inline: its class -/// (`0x0029`) is still **reserved**, because a field table for it would encode -/// an issuance predicate this protocol does not yet define. Referencing it by -/// address costs nothing today and commits nothing prematurely. +/// The authorization itself is addressed rather than inline: class `0x0029` +/// (`IssuanceAuthorizationBody`) defines the issuance predicate, and the +/// descriptor names the evidence bundle carrying it by INNER content identity. +/// Inlining the bundle here would put one fact in two encodings; the arm +/// fetches and re-verifies the addressed bytes instead. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreditSourceAuthorizedIssuance { pub credit_mutation_index: u32, diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs index e0190493e..4ae6cab9c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs @@ -57,9 +57,10 @@ use crate::crypto::blake3::dsm_domain_hasher; /// /// Keeping them outside makes the ordering acyclic and obvious: the operation /// is frozen first, its digest derived, and the authorities then sign a body -/// that commits that digest. `Mint.proof_of_authorization` is canonically -/// EMPTY under this schema — it must not become a second authorization -/// channel — and the field itself is deleted in the producer cut. +/// that commits that digest. Since the producer cut this is STRUCTURAL: +/// `Operation::Mint` carries no authorization fields at all, so a second +/// channel inside the operation is unrepresentable rather than merely +/// forbidden. /// /// ## Non-reuse /// @@ -126,12 +127,10 @@ impl IssuanceAuthorizationBody { /// Why a policy is not admissible for V1 issuance. #[derive(Debug, Clone, PartialEq, Eq)] pub enum IssuancePolicyRefusal { - /// No `TokenAuthority` condition. V1 requires one: without it, "the - /// generic evaluator found nothing to deny" would become authenticated /// A `SupplyCap` with a finite ceiling. Its `circulating` input is derived /// from the ISSUER'S OWN chain history and is not global, so N authorized - /// devices would each mint to the cap. Burned for canonical issuance until - /// a globally non-duplicable cap mechanism exists. + /// devices would each mint to the cap. Refused for canonical issuance + /// until a globally non-duplicable cap mechanism exists. FiniteSupplyCap, /// A condition whose inputs are not foreign-verifiable, or whose issuance /// meaning is not defined by this schema. @@ -158,20 +157,6 @@ impl core::fmt::Display for IssuancePolicyRefusal { impl std::error::Error for IssuancePolicyRefusal {} -/// The conditions V1 enforces, extracted from an admissible policy. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AdmissibleIssuancePolicy<'a> { - /// The `k` of k-of-N. - pub threshold: u32, - /// The `N` — raw SPHINCS+ public keys the POLICY names. The verifier draws - /// keys from here and never from the presented proof. - pub signers: &'a [Vec], - /// Operations the policy permits, when it restricts them at all. - pub allowed_operations: Option<&'a [String]>, - /// A per-operation amount ceiling, when the policy sets one. - pub amount_limit: Option, -} - /// The issuance-relevant facts of a committed token policy, parsed in CORE. /// /// The blob parser lives here rather than in the SDK because the VERIFIER is @@ -262,23 +247,50 @@ pub fn parse_issuance_policy(policy_proto: &[u8]) -> Result 0, + // exactly count x 32-byte DevIDs follow + // + // No dual-format acceptance. Every divergence — nonzero count under NONE, + // zero count under INLINE, a flag disagreeing with the payload, truncated + // entries, trailing bytes, an unknown kind — fails closed, mirroring the + // SDK reader exactly so two verifiers can never disagree about one blob. need(i, 1, len)?; let allowlist_kind = b[i]; i += 1; + need(i, 2, len)?; + let allowlist_count = u16::from_be_bytes([b[i], b[i + 1]]) as usize; + i += 2; let mut allowlist_device_ids = Vec::new(); - if allowlist_kind == 1 { - need(i, 2, len)?; - let count = u16::from_be_bytes([b[i], b[i + 1]]) as usize; - i += 2; - for _ in 0..count { - need(i, 32, len)?; - let mut d = [0u8; 32]; - d.copy_from_slice(&b[i..i + 32]); - allowlist_device_ids.push(d); - i += 32; + match allowlist_kind { + 0 => { + if allowlist_count != 0 { + return Err("policy blob allowlist kind NONE carries a nonzero count".into()); + } } - } else if allowlist_kind != 0 { - return Err("policy blob has an unknown allowlist kind".into()); + 1 => { + if allowlist_count == 0 { + return Err("policy blob allowlist kind INLINE carries a zero count".into()); + } + for _ in 0..allowlist_count { + need(i, 32, len)?; + let mut d = [0u8; 32]; + d.copy_from_slice(&b[i..i + 32]); + allowlist_device_ids.push(d); + i += 32; + } + } + _ => return Err("policy blob has an unknown allowlist kind".into()), + } + let flag_claims_allowlist = flags & 0x04 != 0; + if flag_claims_allowlist != !allowlist_device_ids.is_empty() { + return Err("policy blob allowlist flag disagrees with its payload".into()); } // The blob must be consumed EXACTLY. A padded policy would let two byte diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs index 305950231..bc76a0454 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs @@ -42,18 +42,14 @@ //! verifier that turns a mutation list into a post-root, and the exhaustive //! classifier that says which operations owe a write set at all. //! -//! It does **not** yet provide credit provenance. That gap is the whole -//! remaining security question and it should be read as one: a closed write -//! set proves *what changed*, never *why a credit may appear*. A verifier that -//! checked only the mutations here would accept a trader crediting itself from -//! nothing, because every mutation in that write set is individually -//! well-formed. Provenance — the six-arm `CreditSource` algebra, classes -//! `0x0023`–`0x0028` — is a separate and **conjunctive** obligation. -//! -//! Class `0x001D` (`EconomicTransitionWitness`) and the provenance classes -//! `0x0023`–`0x0029` are allocated in [`crate::ccb::reserved`] and have no -//! encoder; see [`witness`] for why, and `reserved_classes_have_no_encoder` -//! for the test that keeps allocation from drifting into serialization. +//! Credit provenance is a separate and **conjunctive** obligation: a closed +//! write set proves *what changed*, never *why a credit may appear*, and a +//! verifier that checked only the mutations would accept a trader crediting +//! itself from nothing. The `CreditSource` algebra (classes +//! `0x0023`–`0x0028`, plus `0x0030`) lives in [`provenance`], and class +//! `0x0029` (`IssuanceAuthorizationBody`, [`issuance`]) is the policy-signed +//! predicate the `0x0023` arm resolves — the producer is `token.mint`'s +//! economic admission. pub mod admission; pub mod authority_evidence; diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs index e736ac605..3ca96b8bb 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs @@ -654,32 +654,16 @@ pub fn verify_credit_source( // 1. The exact accepted operation's own issuance coordinates. Read // from the AUTHENTICATED successor, never from the descriptor. let (op_policy_commit, op_amount, op_kind) = match op { + // THE AUTHORIZATION NEVER RIDES INSIDE THE OPERATION IT + // AUTHORIZES — and since the producer cut this is structural, + // not a check: Mint has no authorization fields at all. The + // `0x0029` signatures cover this operation's digest and live + // only in the evidence bundle the descriptor addresses. crate::types::operations::Operation::Mint { policy_commit, amount, - proof_of_authorization, .. - } => { - // THE AUTHORIZATION NEVER RIDES INSIDE THE OPERATION IT - // AUTHORIZES. The `0x0029` signatures cover this - // operation's digest, so carrying them here would make the - // digest depend on the signatures over it — a preimage - // that cannot be constructed honestly, and an invitation - // to a second, unverified place for the same fact to live. - // Authorization is carried by the evidence bundle the - // descriptor addresses, and only there. Refusing a - // non-empty field keeps that the ONLY channel rather than - // leaving a silently-ignored one beside it. - if !proof_of_authorization.is_empty() { - return Err(invalid( - "a Mint funded by an issuance authorization must carry an empty \ - proof_of_authorization: the 0x0029 signatures cover this \ - operation's digest and live only in the evidence bundle" - .into(), - )); - } - (*policy_commit, amount.value(), "mint") - } + } => (*policy_commit, amount.value(), "mint"), // Reachable only if supply-at-creation is later enabled; today // the route, the write-set table and the accepting layer all // refuse it. Handled here so the arm covers the operations the @@ -707,7 +691,14 @@ pub fn verify_credit_source( &d.issuance_authorization_addr, ) .map_err(ProvenanceError::OwnerLineage)?; - if crate::storage_object::immutable_addr( + // The descriptor's address is the INNER content identity — + // `H_dom(namespace, payload)` — the same form every other object + // in the evidence DAG (witness, manifest, authority, successor + // evidence) is addressed by, and the form the resolver's fetch + // path derives its store key from. The outer storage-object addr + // exists too, but an arm that committed to it would name an + // address no resolver can dereference. + if crate::storage_object::immutable_inner( crate::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, &bundle_bytes, ) != d.issuance_authorization_addr diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs index 2cb77c021..56f0314e0 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs @@ -1352,6 +1352,34 @@ pub fn verify_operation_write_set( } Ok(()) } + ( + FactsKind::AuthorizedIssuance, + CreditSource::AuthorizedIssuance(d), + Operation::Mint { .. } | Operation::CreateToken { .. }, + ) => { + // THE SHAPE HALF of the issuance rule. Exactly one balance + // credit and nothing else: non-reuse is the signed body's + // position + operation-digest binding, proven by the + // 0x0023 provenance arm — never a consumed-source leaf. + // Everything semantic (the policy bytes, the k-of-N + // signatures, amount, position, digest) is that arm's job; + // this layer pins that the witness claims exactly the + // effect the operation derives and that the descriptor + // funds exactly the one credit. + if !consumed.is_empty() || witness.mutations.len() != 1 { + return Err(WriteSetError::WrongWriteSet { + detail: "an authorized issuance is exactly one balance credit — \ + its non-reuse is the authorization's position+digest \ + binding, not a consumed-source leaf", + }); + } + if d.credit_mutation_index != b.mutation_index { + return Err(WriteSetError::WrongWriteSet { + detail: "issuance source does not fund the balance credit", + }); + } + Ok(()) + } (FactsKind::PeerDebit, CreditSource::ValidatedPeerDebit(d), _) => { if consumed.len() != 1 || witness.mutations.len() != 2 { return Err(WriteSetError::WrongWriteSet { diff --git a/dsm_client/deterministic_state_machine/dsm/src/recovery/pdsmt_posting.rs b/dsm_client/deterministic_state_machine/dsm/src/recovery/pdsmt_posting.rs index caffde2bc..c725a3c14 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/recovery/pdsmt_posting.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/recovery/pdsmt_posting.rs @@ -798,9 +798,8 @@ mod tests { .with_balance_for_testing([0xF1; 32], 1_000); // A value relationship (Burn → Yes). A burn is value-bearing without - // being issuance, which the accepting layer refuses until class 0x0029 - // exists; what this test needs is a relationship FLAGGED value-bearing, - // and either direction does that. + // needing an issuance admission attached; what this test needs is a + // relationship FLAGGED value-bearing, and either direction does that. let c_yes = [0xC1; 32]; let rk_yes = compute_smt_key(&owner, &c_yes); let dev = dev diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs index 6bef709d5..98c53cf2b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/device_state.rs @@ -1540,13 +1540,13 @@ impl DeviceState { // future route, or any direct `advance` caller, would silently reopen the // hole. This is the chokepoint every mint must cross. // - // Fail-closed with no exemption: `EXCEPT through an explicit issuance - // predicate whose evidence THIS verifier validates` is the intended - // shape, and no such predicate exists yet — so there is no admissible - // builtin issuance today rather than a placeholder one. A `SupplyCap` - // condition would NOT be that predicate: it reads `circulating_le` from - // caller-supplied enforcement context, and no canonical producer - // authenticates that number. + // Fail-closed with no exemption for BUILTINS: class 0x0029 exists and + // authorizes user-token issuance, but a builtin's issuance is not + // self-authorizable under any policy signature — ERA enters through + // the faucet's bootstrap tickets, dBTC through the Bitcoin tap. A + // `SupplyCap` condition would NOT be an issuance predicate either: it + // reads `circulating_le` from caller-supplied enforcement context, and + // no canonical producer authenticates that number. // Keyed on `policy_commit`, which is the identity that actually moves // value: `validate_conservation` binds the credit delta to it, `balances` // is keyed by it, and the compat projection resolves a ticker FROM it. @@ -1625,29 +1625,19 @@ impl DeviceState { } } - // ISSUANCE REFUSAL — every asset, not just the builtins. + // THE MINT GATE. A positive mint CREATES units — the one operation + // whose whole effect is a credit with no prior holder — so it may + // enter canonical device state only through the economic-admission + // fence, exactly like a faucet claim or an online credit-direction + // transfer. This layer does NOT parse the 0x0029 evidence; its job is + // narrower and load-bearing: no raw local positive credit without an + // attached admission. The economic verifier proves the admission's + // 0x0023 AuthorizedIssuance source during validation. // - // A positive mint CREATES units, so it is the one operation whose whole - // effect is a credit with no prior holder. `R_econ` funds a credit only - // through a `CreditSource`, and the arm that would carry issuance - // (`0x0023 AuthorizedIssuance`) fails closed because its evidence class - // `0x0029` is not written yet: there is no foreign-verifiable proof - // that a token policy authorized this exact issuance. - // - // Until that predicate exists, the refusal belongs HERE, at the - // accepting layer, before any device-state balance changes. A route - // guard alone leaves every other caller — and every future one — free - // to credit units nothing can ever admit, and the damage is not - // confined to the minter: `dlv.create` funds vaults from the device - // head, so unadmittable units become vault reserves and a whole market - // that no verifier can accept. Worse, holding them is a ONE-WAY DOOR: - // `activate` refuses a device with a non-empty balance map, so the - // identity can never enter the economic model at all. - // - // The two reasons are genuinely different, so they are reported - // differently: a builtin's issuance is not self-authorizable at all, - // while a user asset's issuance is authorizable in principle and simply - // has no predicate yet. + // The builtin arm stays UNCONDITIONAL and is keyed on the COMMIT, not + // the ticker: builtin issuance is not self-authorizable under any + // admission — ERA enters through the faucet's bootstrap tickets, and + // dBTC arrives with the Bitcoin tap integration. if let Operation::Mint { policy_commit, amount, @@ -1661,16 +1651,12 @@ impl DeviceState { { return Err(DsmError::invalid_operation(format!( "advance: refusing to mint the builtin token {name} — builtin issuance is not \ - self-authorizable, and no authenticated issuance predicate is defined for it" + self-authorizable; ERA is distributed by the faucet's bootstrap tickets and \ + dBTC issuance arrives with the Bitcoin tap integration" ))); } if amount.value() > 0 { - return Err(DsmError::invalid_operation( - "advance: refusing to mint units with no authenticated issuance predicate — \ - class 0x0029 (issuance authorization) is not defined, so no verifier could \ - ever fund this credit, and the units would be unspendable in every validated \ - lineage while permanently blocking this device's economic activation", - )); + self.require_attached_dsm_admission(&operation, "an authorized issuance mint")?; } } @@ -1691,10 +1677,10 @@ impl DeviceState { if let Operation::CreateToken { initial_supply, .. } = &operation { if initial_supply.value() > 0 { return Err(DsmError::invalid_operation( - "advance: refusing to create a token with initial supply — issuance needs an \ - authenticated predicate (class 0x0029) that is not defined yet, so the \ - supply could never be funded in any validated lineage. Create the token \ - with zero supply; issuance becomes available when the predicate does", + "advance: refusing to create a token with initial supply — supply at \ + creation has no issuance source. Create the token with zero supply and \ + issue through token.mint, whose credit is funded by a 0x0029 issuance \ + authorization the verifier reruns", )); } } @@ -3415,20 +3401,26 @@ mod tests { /// `policy_commit` credited that asset, on the reasoning that refusing it /// would reject honest issuance. That reasoning assumed honest issuance was /// expressible. It is not: `R_econ` funds a credit only through a - /// `CreditSource`, the issuance arm (`0x0023`) fails closed while its - /// evidence class `0x0029` is unwritten, and there is no other arm that can - /// originate units. So the units were not honest issuance — they were + /// `CreditSource`, and AT THE TIME the issuance arm (`0x0023`) failed + /// closed with class `0x0029` unwritten (it exists now; the builtin + /// refusal here is unconditional regardless). So the units were not + /// honest issuance — they were /// unadmittable, they became DLV vault reserves through the head-gated /// funding path, and holding them permanently blocked `activate`. /// /// The gate is still keyed on the ASSET rather than the ticker: both assets /// refuse, but for different reasons, and this pins that they do not - /// collapse into one blanket refusal. When `0x0029` lands, the non-builtin - /// arm is what changes. + /// collapse into one blanket refusal. Since the 0x0029 producer cut, the + /// non-builtin reason is the ADMISSION FENCE: a positive mint may enter + /// only with an attached DsmBacked admission whose digest names exactly + /// this operation — a raw local credit is refused before any balance + /// changes, and the economic verifier proves the admission's issuance + /// source separately. /// - /// THE MUTATION CONTROL for the issuance refusal: delete the - /// `amount.value() > 0` block in `advance` and this test goes red by - /// actually crediting 1_000 units of a non-builtin asset into the head. + /// THE MUTATION CONTROL for the issuance gate: replace the + /// `require_attached_dsm_admission` call in the Mint arm of `advance` with + /// `Ok(())` and this test goes red by actually crediting 1_000 units of a + /// non-builtin asset into the head. #[test] fn no_asset_mints_from_air_and_the_two_refusals_stay_distinct() { let pc = [0x5Au8; 32]; @@ -3462,11 +3454,11 @@ mod tests { None, None, ) - .expect_err("a non-builtin asset has no issuance predicate either") + .expect_err("an unadmitted mint must be refused at the accepting layer") ); assert!( - err.contains("0x0029"), - "the non-builtin refusal names the missing issuance predicate, got: {err}" + err.contains("no pending economic admission"), + "the non-builtin refusal is the ADMISSION FENCE, got: {err}" ); assert!( !err.contains("builtin issuance is not self-authorizable"), @@ -4807,8 +4799,6 @@ mod tests { amount: bal(amount), token_id: b"ERA".to_vec(), policy_commit, - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), } } @@ -4828,11 +4818,11 @@ mod tests { /// Value op matching a delta's direction, amount AND asset — the guard now /// binds all three, so a fixture must name the asset its delta moves. /// - /// The credit arm is a credit-direction `Transfer`, not a mint: issuance is - /// refused at the accepting layer until class 0x0029 exists, and a transfer - /// is the credit shape production actually admits. Callers driving the - /// credit direction through `advance` must attach the matching Prepared - /// admission — see `prepared_for`. + /// The credit arm is a credit-direction `Transfer`, not a mint: a mint + /// requires an attached admission carrying `0x0029` issuance evidence, + /// and this fixture's subject is delta/asset binding, not issuance. + /// Callers driving the credit direction through `advance` must attach the + /// matching Prepared admission — see `prepared_for`. fn value_op(dir: BalanceDirection, amount: u64, policy_commit: [u8; 32]) -> Operation { match dir { BalanceDirection::Credit => credit_transfer_op(amount, policy_commit), diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs index 9caca0df2..2a53d19c2 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs @@ -318,7 +318,16 @@ pub enum Operation { /// Which ticket. Must be `< ERA_FAUCET_TICKET_COUNT`. ticket_index: u64, }, - /// Mint new tokens into existence (requires authorization proof). + /// Mint new tokens into existence. + /// + /// NOTHING IN THIS OPERATION ASSERTS ISSUANCE AUTHORITY. The legacy + /// `authorized_by` / `proof_of_authorization` channel is deleted: those + /// bytes participated in the operation digest, and the `0x0029` + /// authorization signs a body that COMMITS that digest — so authorization + /// material inside the operation has no fixed point. Authority comes only + /// from the attached economic admission: this operation's exact digest is + /// named by a policy-signed `IssuanceAuthorizationBody`, resolved by the + /// `0x0023 AuthorizedIssuance` arm during validation. Mint { /// Quantity of tokens to mint (must be > 0). amount: Balance, @@ -332,10 +341,6 @@ pub enum Operation { /// delta's count/direction/amount, and a mint for token X could credit /// ERA. Mirrors `Transfer.policy_commit`. policy_commit: [u8; 32], - /// Binary identifier of the authority that authorized this minting. - authorized_by: Vec, - /// Cryptographic proof from the minting authority. - proof_of_authorization: Vec, /// Human-readable description of the minting event. message: String, }, @@ -1222,8 +1227,6 @@ impl Operation { amount, token_id, policy_commit, - authorized_by, - proof_of_authorization, message, } => { put_u8(&mut out, 4); @@ -1232,8 +1235,6 @@ impl Operation { put_bytes(&mut out, token_id); // CPTA policy commitment — same length-prefixed convention as Transfer. put_bytes(&mut out, policy_commit); - put_bytes(&mut out, authorized_by); - put_bytes(&mut out, proof_of_authorization); put_str(&mut out, message); } Burn { @@ -1979,15 +1980,11 @@ impl Operation { get_bytes(&mut input)?.as_slice().try_into().map_err(|_| { DsmError::invalid_operation("mint policy_commit must be 32 bytes") })?; - let authorized_by = get_bytes(&mut input)?; - let proof_of_authorization = get_bytes(&mut input)?; let message = get_str(&mut input)?; Mint { amount, token_id, policy_commit, - authorized_by, - proof_of_authorization, message, } } @@ -2543,10 +2540,9 @@ impl Operation { /// Get proof of authorization if available pub fn get_proof_of_authorization(&self) -> Option> { match self { - Operation::Mint { - proof_of_authorization, - .. - } => Some(proof_of_authorization.clone()), + // Mint carries NO authorization bytes: its authority is the 0x0029 + // evidence bundle resolved during economic admission, never a + // field inside the operation whose digest that evidence signs. // For Transfer, the signature IS the proof of authorization Operation::Transfer { signature, .. } if !signature.is_empty() => { Some(signature.clone()) @@ -3096,8 +3092,6 @@ mod tests { amount: test_balance(1), token_id: vec![1], policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), } .is_value_egress()); @@ -3121,8 +3115,6 @@ mod tests { amount: test_balance(1), token_id: vec![1], policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), }; assert!(!mint.is_value_egress() && mint.is_value_bearing()); @@ -3210,8 +3202,6 @@ mod tests { amount: test_balance(1), token_id: b"ERA".to_vec(), policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), }; for op in [ @@ -3352,8 +3342,6 @@ mod tests { amount: test_balance(10_000), token_id: b"ERA".to_vec(), policy_commit: [0u8; 32], - authorized_by: vec![0xAA; 32], - proof_of_authorization: vec![0xBB; 64], message: "mint tokens".into(), }); } @@ -3882,8 +3870,6 @@ mod tests { amount: test_balance(1), token_id: vec![], policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), }; assert_eq!(mint.get_operation_type(), "mint"); @@ -4285,8 +4271,6 @@ mod tests { amount: test_balance(50), token_id: b"ERA".to_vec(), policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), }; assert!(TokenOps::is_valid(&op)); @@ -4400,8 +4384,6 @@ mod tests { amount: bal.clone(), token_id: b"T".to_vec(), policy_commit: [0u8; 32], - authorized_by: vec![], - proof_of_authorization: vec![], message: String::new(), }; let decoded = roundtrip(&op); diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs b/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs index 9e5f1de58..6ee1e8500 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/policy_types.rs @@ -194,9 +194,11 @@ pub enum PolicyCondition { /// The `signers` list is the "N" in k-of-N — it did not exist anywhere in /// the data model before, which is why the threshold the wizard collected /// could never be enforced against anything. Verification takes the public - /// key from HERE; taking it from the caller's own proof (as the dead - /// `verify_mint_authorization_for_transition` did) authorises anyone able - /// to sign with a key they generated themselves. + /// key from HERE; taking it from the caller's own proof (as the deleted + /// legacy mint verifier did) authorises anyone able to sign with a key + /// they generated themselves. Mint itself no longer uses this condition — + /// its authority is the 0x0029 issuance evidence — so this gates burn and + /// create_token. TokenAuthority { /// Raw SPHINCS+ public keys permitted to mint/burn. signers: Vec>, diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs index 2e8cc3f7f..1bb079eb0 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_admission_lifecycle.rs @@ -472,8 +472,6 @@ fn issuance_requires_its_predicate_to_be_satisfied_not_merely_defined() { amount: Balance::from_state(100, [0u8; 32]), token_id: b"ERA".to_vec(), policy_commit: ERA, - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }; let accepted = AcceptedSubstrate::from_verified_dsm_successor( @@ -495,14 +493,26 @@ fn issuance_requires_its_predicate_to_be_satisfied_not_merely_defined() { .expect("valid witness"); let manifest = manifest_for(&witness); let registered = registered_for(&manifest, 1, post_root); + // LAYERING SHIFT (producer cut): the witness DOES carry a 0x0023 + // descriptor, so the write-set shape check now passes — the missing + // verifier arm that used to refuse this as a kind mismatch was one of the + // defects the producer cut fixed. The refusal therefore moved DOWN to the + // layer that actually resolves the predicate: provenance must fetch the + // authorization evidence, and in this fixture no evidence store exists — + // the predicate is defined but unsatisfiable, and validation fails closed + // exactly there. match run(&fx, ®istered, &manifest, &witness, &accepted) { - Err(EconomicValidationError::WriteSet( - dsm::economic::write_set::WriteSetError::WrongWriteSet { detail }, - )) => assert!( - detail.contains("credit source kind does not match"), - "the refusal must be the missing issuance source, got: {detail}" + Err(EconomicValidationError::Provenance(e)) => { + let msg = e.to_string(); + assert!( + msg.contains("no evidence store in this fixture"), + "the refusal must be the unresolvable issuance evidence, got: {msg}" + ); + } + other => panic!( + "a mint whose issuance predicate cannot be satisfied must be refused at the \ + provenance layer, got {other:?}" ), - other => panic!("a mint with no issuance source must be refused, got {other:?}"), } } diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_authorized_issuance.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_authorized_issuance.rs index 848f9a835..847e0eeb0 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_authorized_issuance.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_authorized_issuance.rs @@ -83,7 +83,11 @@ fn policy_bytes( b.extend_from_slice(&0u16.to_be_bytes()); // description b.extend_from_slice(&0u16.to_be_bytes()); // icon_url if allowlist.is_empty() { + // Kind NONE still carries its u16 count, committed as zero — the + // canonical SDK packer always writes it, and this helper's whole + // reason to exist is producing the bytes the packer actually commits. b.push(0); + b.extend_from_slice(&0u16.to_be_bytes()); } else { b.push(1); b.extend_from_slice(&(allowlist.len() as u16).to_be_bytes()); @@ -174,8 +178,6 @@ fn fixture( amount: Balance::from_state(amount, [0u8; 32]), token_id: b"NEW".to_vec(), policy_commit, - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }; let op_digest = dsm::economic::faucet::dsm_operation_digest(&op.to_bytes()); @@ -204,7 +206,7 @@ fn fixture( signatures, } .encode_to_vec(); - let evidence_addr = dsm::storage_object::immutable_addr( + let evidence_addr = dsm::storage_object::immutable_inner( dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, &evidence_bytes, ); @@ -256,8 +258,6 @@ fn fixture_with_stranger(signer_count: usize, amount: u64) -> Fixture { amount: Balance::from_state(amount, [0u8; 32]), token_id: b"NEW".to_vec(), policy_commit, - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }; let op_digest = dsm::economic::faucet::dsm_operation_digest(&op.to_bytes()); @@ -289,7 +289,7 @@ fn fixture_with_stranger(signer_count: usize, amount: u64) -> Fixture { signatures, } .encode_to_vec(); - let evidence_addr = dsm::storage_object::immutable_addr( + let evidence_addr = dsm::storage_object::immutable_inner( dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, &evidence_bytes, ); @@ -415,8 +415,6 @@ fn an_amount_the_authorization_does_not_name_is_refused() { amount: Balance::from_state(1_001, [0u8; 32]), token_id: token_id.clone(), policy_commit: *policy_commit, - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }, _ => unreachable!(), @@ -435,8 +433,6 @@ fn a_policy_commit_the_authorization_does_not_name_is_refused() { amount: amount.clone(), token_id: token_id.clone(), policy_commit: [0xEE; 32], - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }, _ => unreachable!(), @@ -498,28 +494,10 @@ fn an_authority_its_own_signer_set_cannot_satisfy_refuses_the_issuance() { .expect("a satisfiable authority admits the issuance"); } -/// THE AUTHORIZATION MAY NOT RIDE INSIDE THE OPERATION IT AUTHORIZES. -/// -/// The `0x0029` signatures cover this Mint's own digest, so a bundle carried -/// in `proof_of_authorization` would have to be signed before it existed. -/// The field is refused when non-empty so the evidence bundle stays the only -/// channel — an ignored second channel is how one fact acquires two homes. -#[test] -fn a_mint_carrying_its_own_authorization_is_refused() { - let fx = fixture(3, 2, 1_000, true, true, &[], true); - let mut op = fx.op.clone(); - if let Operation::Mint { - proof_of_authorization, - .. - } = &mut op - { - *proof_of_authorization = vec![0xAB; 8]; - } else { - panic!("the fixture operation is a Mint"); - } - refusal(&fx, &resolver(&fx), &op, "empty proof_of_authorization"); -} - +// `a_mint_carrying_its_own_authorization_is_refused` was DELETED with the +// channel it guarded: `Operation::Mint` no longer has authorization fields, +// so an authorization riding inside the operation is unrepresentable — the +// type system now enforces what that test asserted at runtime. /// Tampered evidence bytes do not hash to the descriptor's address. #[test] fn tampered_evidence_is_refused_by_address() { diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs index 8d8cecf5f..c437b550f 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs @@ -394,11 +394,9 @@ fn a_mint_builds_a_credit_but_demands_its_issuance_facts() { amount: Balance::from_state(100, [0u8; 32]), token_id: b"NEW".to_vec(), policy_commit: pc, - authorized_by: Vec::new(), // The 0x0029 signatures live in the evidence bundle, never here: a // signature inside the operation would change the operation digest the // signed body commits to, and the scheme would have no fixed point. - proof_of_authorization: Vec::new(), message: String::new(), }; assert_eq!( @@ -438,8 +436,6 @@ fn a_mint_builds_a_credit_but_demands_its_issuance_facts() { amount: Balance::from_state(0, [0u8; 32]), token_id: b"NEW".to_vec(), policy_commit: pc, - authorized_by: Vec::new(), - proof_of_authorization: Vec::new(), message: String::new(), }; assert_eq!( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs index 46fa573d1..bd9327bd6 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/dlv_routes.rs @@ -4686,9 +4686,9 @@ mod funded_creation_tests { }; // Fund the foreign trader with the input asset so its settle can pay it. - // Installed directly rather than minted: issuance is refused at the - // accepting layer until class 0x0029 exists, and this fixture's subject - // is the RECONCILE's duplicate-generation refusal, not where the + // Installed directly rather than minted: an authorized mint would drag + // a policy, an admission and a fleet into a fixture whose subject is + // the RECONCILE's duplicate-generation refusal, not where the // trader's units came from. let head = DeviceState::new(dev, dev, kp.public_key.clone(), 64) .with_balance_for_testing(*pc_in, 10_000); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs index 267306e89..d5a7cfa98 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/sender_admission_tests.rs @@ -9,7 +9,7 @@ use serial_test::serial; use crate::bridge::AppRouter; use crate::handlers::faucet_flow_tests_support::{setup_funded, NETWORK}; -use crate::sdk::economic_admission_flow::admitted_self_loop_operation; +use crate::sdk::economic_admission_flow::{admitted_self_loop_operation, resume_pending_admission}; use crate::storage::client_db; fn era() -> [u8; 32] { @@ -42,10 +42,20 @@ async fn an_admitted_burn_advances_the_lineage_and_is_foreign_walkable() { // Then the decisive check: a FOREIGN walk of positions 1..2, crossing a // faucet credit AND a pure debit in one lineage. let (core, _fleet) = setup_funded(0xC1).await; - let (outcome, admitted) = - admitted_self_loop_operation(&core, burn_op(40), burn_delta(40), None) - .await - .expect("admitted burn"); + let (outcome, admitted) = admitted_self_loop_operation( + &core, + burn_op(40), + burn_delta(40), + |_| { + Ok(( + dsm::economic::write_set::CreditSourceFacts::None, + Vec::new(), + )) + }, + None, + ) + .await + .expect("admitted burn"); assert_eq!(admitted.economic_position, 2); assert_eq!(outcome.new_device_state.balance(&era()), 60); let head = core.device_head().expect("head"); @@ -170,13 +180,35 @@ async fn sequential_admissions_stay_monotonic_across_operation_kinds() { // Faucet claim, burn, burn — three admissions, three kinds of witness // content, one strictly monotonic lineage. let (core, _fleet) = setup_funded(0xC4).await; - let (_o, a2) = admitted_self_loop_operation(&core, burn_op(10), burn_delta(10), None) - .await - .expect("burn 1"); + let (_o, a2) = admitted_self_loop_operation( + &core, + burn_op(10), + burn_delta(10), + |_| { + Ok(( + dsm::economic::write_set::CreditSourceFacts::None, + Vec::new(), + )) + }, + None, + ) + .await + .expect("burn 1"); assert_eq!(a2.economic_position, 2); - let (_o, a3) = admitted_self_loop_operation(&core, burn_op(20), burn_delta(20), None) - .await - .expect("burn 2"); + let (_o, a3) = admitted_self_loop_operation( + &core, + burn_op(20), + burn_delta(20), + |_| { + Ok(( + dsm::economic::write_set::CreditSourceFacts::None, + Vec::new(), + )) + }, + None, + ) + .await + .expect("burn 2"); assert_eq!(a3.economic_position, 3); assert_eq!(core.device_head().unwrap().balance(&era()), 70); } @@ -517,3 +549,499 @@ async fn a_failed_finish_holds_the_outbox_and_resume_completes_the_same_admissio assert!(applied.success, "{:?}", applied.errors); assert_eq!(p.b.era_balance(), 10, "B received the held transfer once"); } + +/// THE HONEST AUTHORIZED MINT, END TO END, THEN FOREIGN-WALKED. +/// +/// This is the statement the producer cut exists to make true: +/// +/// ```text +/// canonical policy -> exact Mint frozen -> transition-bound 1-of-1 0x0029 +/// -> 0x0023 AuthorizedIssuance -> economic admission -> positive R_econ +/// ``` +/// +/// Faucet funds position 1, the fee-bearing create admits position 2 and +/// anchors the policy, the mint admits position 3 — and then a FOREIGN +/// verifier with no local shortcuts walks the lineage and validates the mint +/// through the full 0x0023 arm, fetching the 0x0029 bundle by content +/// address from the fleet. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn token_routes_admit_an_authorized_mint_that_is_foreign_walkable() { + use prost::Message; + let p = crate::test_support::two_device::Pair::boot(100, 0).await; + p.a.enter(); + let router = p.a.router(); + let pack = |body: Vec| { + crate::generated::ArgPack { + schema_hash: Some(crate::generated::Hash32 { v: vec![0u8; 32] }), + codec: crate::generated::Codec::Proto as i32, + body, + } + .encode_to_vec() + }; + let created = router + .invoke(crate::bridge::AppInvoke { + method: "token.create".into(), + args: pack( + crate::generated::TokenCreateRequest { + ticker: "MNTA".into(), + alias: "Mintable Token".into(), + decimals: 2, + max_supply_u128: 0u128.to_be_bytes().to_vec(), + initial_alloc_u128: 0u128.to_be_bytes().to_vec(), + mint_burn_enabled: true, + transferable: true, + unlimited_supply: true, + mint_burn_threshold: 1, + description: String::new(), + icon_url: String::new(), + allowlist_device_ids: Vec::new(), + } + .encode_to_vec(), + ), + }) + .await; + assert!(created.success, "{:?}", created.error_message); + assert_eq!( + client_db::economic_lineage::get_admitted() + .unwrap() + .unwrap() + .0, + 2, + "the creation fee admitted position 2" + ); + + let minted = router + .invoke(crate::bridge::AppInvoke { + method: "token.mint".into(), + args: pack( + crate::generated::TokenMintRequest { + token_id: "MNTA".into(), + amount: 500, + message: "first authorized issuance".into(), + } + .encode_to_vec(), + ), + }) + .await; + assert!(minted.success, "{:?}", minted.error_message); + let resp = match crate::generated::Envelope::decode(&minted.data[1..]) + .expect("envelope") + .payload + { + Some(crate::generated::envelope::Payload::TokenMintResponse(t)) => t, + other => panic!("expected TokenMintResponse, got {other:?}"), + }; + assert_eq!(resp.new_balance, 500, "the credit landed"); + let (position, admitted_root) = client_db::economic_lineage::get_admitted() + .unwrap() + .expect("admitted"); + assert_eq!(position, 3, "the mint admitted position 3"); + let head = p.a.router().core_sdk.device_head().expect("head"); + assert!( + head.pending_economic_admission().is_none(), + "no fence remains after ECON_ADMITTED" + ); + + // FOREIGN VERIFICATION: no cached shortcuts, live quorum, the real + // resolver — position 3 must validate as an AuthorizedIssuance-funded + // Mint from public material alone. + let (genesis, devid) = (head.genesis_digest(), head.devid()); + client_db::economic_lineage::clear_peer_lineage(&genesis, &devid).unwrap(); + let handle = tokio::runtime::Handle::current(); + let peer = tokio::task::spawn_blocking(move || { + use dsm::economic::provenance::ProvenanceResolver; + let profile = + dsm::economic::register::resolve_root_register_profile(NETWORK).expect("profile"); + let set = crate::sdk::storage_set::StorageSetCatalog::from_env_config() + .expect("catalog") + .resolve(&profile.storage_set_id) + .cloned() + .expect("canonical set"); + let resolver = crate::sdk::economic_registers::LiveRegisterResolver { + set: &set, + runtime: handle, + expected_network_id: NETWORK.to_vec(), + }; + resolver.validated_peer_transition(&genesis, &devid, 3) + }) + .await + .expect("join") + .expect("the minted position MUST be foreign-walkable through the 0x0023 arm"); + assert_eq!(peer.validated_root.economic_position(), 3); + assert_eq!(peer.validated_root.economic_root(), admitted_root); + assert!( + matches!( + peer.verified_operation, + dsm::types::operations::Operation::Mint { .. } + ), + "the walked operation is the Mint itself" + ); +} + +/// EVERY UNSUPPORTED POLICY SHAPE REFUSES AT THE PRODUCER, BEFORE ANY +/// MUTATION — the policy's own reason, not a generic failure. One boot, three +/// shapes: mint/burn disabled, an allowlist excluding this device, and the +/// allowlist POSITIVE control proving the refusal is the allowlist rule. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn mint_preflight_refuses_each_unsupported_policy_shape_by_name() { + use prost::Message; + let p = crate::test_support::two_device::Pair::boot(100, 0).await; + p.a.enter(); + let router = p.a.router(); + let own_devid = router.core_sdk.device_head().expect("head").devid(); + let pack = |body: Vec| { + crate::generated::ArgPack { + schema_hash: Some(crate::generated::Hash32 { v: vec![0u8; 32] }), + codec: crate::generated::Codec::Proto as i32, + body, + } + .encode_to_vec() + }; + let create = |ticker: &str, mint_burn: bool, allow: Vec>| { + crate::generated::TokenCreateRequest { + ticker: ticker.into(), + alias: format!("{ticker} Token"), + decimals: 0, + max_supply_u128: 0u128.to_be_bytes().to_vec(), + initial_alloc_u128: 0u128.to_be_bytes().to_vec(), + mint_burn_enabled: mint_burn, + transferable: true, + unlimited_supply: true, + mint_burn_threshold: 1, + description: String::new(), + icon_url: String::new(), + allowlist_device_ids: allow, + } + .encode_to_vec() + }; + let mint = |token: &str| { + crate::generated::TokenMintRequest { + token_id: token.into(), + amount: 10, + message: String::new(), + } + .encode_to_vec() + }; + + let admitted_before_mints = { + // three creates, three fee admissions + for (ticker, mb, allow) in [ + ("NOMB", false, Vec::new()), + ("ALLW", true, vec![vec![0x77u8; 32]]), + ("ALLK", true, vec![own_devid.to_vec()]), + ] { + let r = router + .invoke(crate::bridge::AppInvoke { + method: "token.create".into(), + args: pack(create(ticker, mb, allow)), + }) + .await; + assert!(r.success, "create {ticker}: {:?}", r.error_message); + } + client_db::economic_lineage::get_admitted() + .unwrap() + .unwrap() + .0 + }; + + let refused = router + .invoke(crate::bridge::AppInvoke { + method: "token.mint".into(), + args: pack(mint("NOMB")), + }) + .await; + assert!(!refused.success); + let msg = refused.error_message.unwrap_or_default(); + assert!( + msg.contains("disables mint/burn"), + "the committed policy's own reason, got: {msg}" + ); + + let refused = router + .invoke(crate::bridge::AppInvoke { + method: "token.mint".into(), + args: pack(mint("ALLW")), + }) + .await; + assert!(!refused.success); + let msg = refused.error_message.unwrap_or_default(); + assert!( + msg.contains("allowlist"), + "the receiving device is outside the committed allowlist, got: {msg}" + ); + + // POSITIVE CONTROL: the same shape NAMING this device mints — so the two + // refusals above are the policy rules, not a broken producer. + let ok = router + .invoke(crate::bridge::AppInvoke { + method: "token.mint".into(), + args: pack(mint("ALLK")), + }) + .await; + assert!(ok.success, "{:?}", ok.error_message); + assert_eq!( + client_db::economic_lineage::get_admitted() + .unwrap() + .unwrap() + .0, + admitted_before_mints + 1, + "exactly the allowlisted mint admitted; the refusals moved nothing" + ); +} + +/// ATOMICITY: a failure while building the issuance facts leaves NOTHING — +/// no advance, no fence, no admitted movement, no frozen evidence. The facts +/// closure runs before anything durable, so its error must be a clean no-op. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_failed_issuance_evidence_build_leaves_no_trace() { + let (core, _fleet) = setup_funded(0xD4).await; + let head_root_before = core.device_head().expect("head").root(); + let admitted_before = client_db::economic_lineage::get_admitted().unwrap(); + + let mint = dsm::types::operations::Operation::Mint { + amount: dsm::types::token_types::Balance::from_state(25, [0u8; 32]), + token_id: b"GHST".to_vec(), + policy_commit: [0x5Cu8; 32], + message: String::new(), + }; + let delta = dsm::types::device_state::BalanceDelta { + policy_commit: [0x5Cu8; 32], + direction: dsm::types::device_state::BalanceDirection::Credit, + amount: 25, + }; + let refused = admitted_self_loop_operation( + &core, + mint, + delta, + |_| { + Err(dsm::types::error::DsmError::invalid_operation( + "TEST: evidence construction failed", + )) + }, + None, + ) + .await; + assert!(refused.is_err(), "the seam must surface the build failure"); + + let head = core.device_head().expect("head"); + assert_eq!(head.root(), head_root_before, "no advance survived"); + assert!( + head.pending_economic_admission().is_none(), + "no fence survived" + ); + assert_eq!( + client_db::economic_lineage::get_admitted().unwrap(), + admitted_before, + "no admitted movement" + ); + assert!( + client_db::frozen_publication_artifact::find_current_payload_with_prefix_and_purpose( + "immutable::DSM/issuance-authorization-evidence/v1::", + "issuance-authorization-evidence", + ) + .unwrap() + .is_none(), + "no evidence artifact was frozen" + ); +} + +/// A FAILED FINISH HOLDS THE MINT, AND RESUME COMPLETES THE SAME ADMISSION — +/// with the SAME 0x0029 evidence bytes, re-signed by nobody. Quorum dies +/// after the staged commit; the crash invariant is that the mint, its pending +/// admission and its exact evidence all exist durably, and resume finishes +/// from frozen bytes alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_failed_finish_holds_the_mint_and_resume_completes_the_same_admission() { + let (core, _fleet) = setup_funded(0xD5).await; + + // A real 1-of-1 policy naming THIS wallet's signing key, packed by the + // sole production packer — the seam-level twin of what token.create + // anchors. The registry is deliberately not involved: the seam consumes + // policy BYTES, and only the route resolves tickers. + let signer_pk = crate::sdk::signing_authority::current_public_key().expect("pk"); + let policy_proto = { + let packed = crate::handlers::token_routes::build_policy_v3_bytes( + &crate::handlers::token_routes::ParsedTokenPolicy { + ticker: "HELD".into(), + alias: "Held Token".into(), + decimals: 0, + max_supply: 0, + initial_alloc: 0, + description: Option::None, + icon_url: Option::None, + mint_burn_enabled: true, + transferable: true, + unlimited_supply: true, + mint_burn_threshold: 1, + signers: vec![signer_pk.clone()], + allowlist_device_ids: Vec::new(), + }, + ) + .expect("pack"); + use prost::Message; + crate::generated::TokenPolicyV3 { + policy_bytes: packed, + } + .encode_to_vec() + }; + let policy_commit = dsm::crypto::blake3::domain_hash_bytes( + dsm::common::domain_tags::TAG_DSM_POLICY, + &policy_proto, + ); + let head = core.device_head().expect("head"); + let (genesis, devid) = (head.genesis_digest(), head.devid()); + // The policy engine on the advance path rehydrates by TOKEN ID from the + // durable registry, and DEFAULT-DENIES a token it cannot rehydrate — so + // the fixture anchors both halves exactly as token.create would: the + // registry row and the verified policy bytes under their own commit. + client_db::token_registry::upsert_policy(&policy_commit, &policy_proto) + .expect("anchor policy bytes"); + client_db::token_registry::insert_token(&client_db::token_registry::TokenRegistryRow { + token_id: "HELD".into(), + policy_commit, + ticker: "HELD".into(), + alias: "Held Token".into(), + decimals: 0, + max_supply: 0, + owner_device_id: devid, + }) + .expect("registry row"); + // A bare CoreSDK has no policy resolver (the router installs it); wire + // the SAME resolution the production installer uses, so the advance-path + // policy engine can rehydrate this token instead of default-denying it. + core.set_policy_resolver(std::sync::Arc::new(|identifier: &str| { + let row = client_db::token_registry::get_token(identifier) + .ok() + .flatten() + .or_else(|| { + client_db::token_registry::get_token_by_ticker(identifier) + .ok() + .flatten() + })?; + let raw = client_db::token_registry::load_policy_verified(&row.policy_commit) + .ok() + .flatten()?; + let parsed = crate::handlers::token_routes::parse_token_policy(&raw)?; + Some(( + crate::handlers::token_routes::derive_policy_file(&row.ticker, &parsed), + dsm::types::policy_types::PolicyAnchor::from_bytes(row.policy_commit), + )) + })); + let mint = dsm::types::operations::Operation::Mint { + amount: dsm::types::token_types::Balance::from_state(25, genesis), + token_id: b"HELD".to_vec(), + policy_commit, + message: String::new(), + }; + let op_digest = dsm::economic::faucet::dsm_operation_digest(&mint.to_bytes()); + let delta = dsm::types::device_state::BalanceDelta { + policy_commit, + direction: dsm::types::device_state::BalanceDirection::Credit, + amount: 25, + }; + let facts = move |target_position: u64| { + use prost::Message; + let body = dsm::economic::issuance::IssuanceAuthorizationBody { + policy_commit, + issuer_genesis: genesis, + issuer_devid: devid, + issuer_economic_position: target_position, + recipient_operation_digest: op_digest, + amount: 25, + }; + let body_ccb = body.encode().expect("ccb"); + let digest = body.signing_digest().expect("digest"); + let sk = crate::sdk::signing_authority::current_secret_key()?; + let sig = dsm::crypto::sphincs::sphincs_sign(&sk, &digest).expect("sign"); + let evidence_bytes = crate::generated::IssuanceAuthorizationEvidenceV1 { + canonical_policy_bytes: policy_proto.clone(), + authorization_body_ccb: body_ccb, + signatures: vec![crate::generated::PolicySignerSignatureV1 { + signer_public_key: signer_pk.clone(), + signature: sig, + }], + } + .encode_to_vec(); + let addr = dsm::storage_object::immutable_inner( + dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, + &evidence_bytes, + ); + let key = crate::sdk::economic_registers::immutable_object_key( + dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, + &evidence_bytes, + ); + Ok(( + dsm::economic::write_set::CreditSourceFacts::AuthorizedIssuance { + issuance_authorization_addr: addr, + }, + vec![(key, evidence_bytes, "issuance-authorization-evidence")], + )) + }; + + // Quorum is 2-of-3: two dead members make evidence publication + // impossible, so finish dies AFTER the staged commit. + crate::sdk::storage_io::fake_fleet::fail_member("dsm-node-1"); + crate::sdk::storage_io::fake_fleet::fail_member("dsm-node-2"); + let refused = admitted_self_loop_operation(&core, mint, delta.clone(), facts, None).await; + let seam_err = refused + .as_ref() + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!( + seam_err.contains("below storage quorum"), + "finish must fail AT PUBLICATION, not earlier, got: {seam_err}" + ); + let pending = core + .device_head() + .expect("head") + .pending_economic_admission() + .cloned() + .expect("the mint is HELD behind its pending admission"); + assert_eq!(pending.operation_digest, op_digest, "held for THIS mint"); + let frozen = + client_db::frozen_publication_artifact::find_current_payload_with_prefix_and_purpose( + "immutable::DSM/issuance-authorization-evidence/v1::", + "issuance-authorization-evidence", + ) + .unwrap() + .expect("the exact evidence bytes are frozen for resume"); + + // Heal the fleet; resume completes the SAME admission from frozen bytes. + crate::sdk::storage_io::fake_fleet::heal_member("dsm-node-1"); + crate::sdk::storage_io::fake_fleet::heal_member("dsm-node-2"); + resume_pending_admission(&core, NETWORK, pending) + .await + .expect("resume completes the held mint admission"); + let (position, _root) = client_db::economic_lineage::get_admitted() + .unwrap() + .expect("admitted"); + assert_eq!(position, 2, "the held mint admitted at its signed position"); + assert!( + core.device_head() + .expect("head") + .pending_economic_admission() + .is_none(), + "unfenced after resume" + ); + assert_eq!( + core.device_head().expect("head").balance(&policy_commit), + 25, + "the authorized credit stands" + ); + let frozen_after = + client_db::frozen_publication_artifact::find_current_payload_with_prefix_and_purpose( + "immutable::DSM/issuance-authorization-evidence/v1::", + "issuance-authorization-evidence", + ) + .unwrap() + .expect("still frozen"); + assert_eq!( + frozen, frozen_after, + "resume used the SAME evidence bytes — nothing was re-signed" + ); +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs index ab02fd39a..477c8cae2 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/token_routes.rs @@ -38,23 +38,23 @@ const ALLOWLIST_KIND_INLINE: u8 = 1; const MAX_POLICY_SIGNERS: usize = 16; #[derive(Debug, Clone, Default)] -struct ParsedTokenPolicy { - ticker: String, - alias: String, - decimals: u32, - max_supply: u128, - initial_alloc: u128, - description: Option, - icon_url: Option, - mint_burn_enabled: bool, - transferable: bool, - unlimited_supply: bool, +pub(crate) struct ParsedTokenPolicy { + pub(crate) ticker: String, + pub(crate) alias: String, + pub(crate) decimals: u32, + pub(crate) max_supply: u128, + pub(crate) initial_alloc: u128, + pub(crate) description: Option, + pub(crate) icon_url: Option, + pub(crate) mint_burn_enabled: bool, + pub(crate) transferable: bool, + pub(crate) unlimited_supply: bool, /// Signatures required to authorize a mint or burn (`k` in k-of-n). - mint_burn_threshold: u8, + pub(crate) mint_burn_threshold: u8, /// The `n` in k-of-n: raw SPHINCS+ public keys permitted to mint/burn. - signers: Vec>, + pub(crate) signers: Vec>, /// Inline allowlist of 32-byte device ids; empty when not restricted. - allowlist_device_ids: Vec<[u8; 32]>, + pub(crate) allowlist_device_ids: Vec<[u8; 32]>, } /// Byte-cursor over a policy blob. Every read is bounds-checked and the blob @@ -126,7 +126,7 @@ impl<'a> PolicyReader<'a> { /// u8 allowlist_kind (0 NONE | 1 INLINE) /// u16 allowlist_count, count x 32B device_id /// ``` -fn build_policy_v3_bytes(p: &ParsedTokenPolicy) -> Result, String> { +pub(crate) fn build_policy_v3_bytes(p: &ParsedTokenPolicy) -> Result, String> { if p.signers.is_empty() || p.signers.len() > MAX_POLICY_SIGNERS { return Err(format!( "policy: signer count must be 1..={MAX_POLICY_SIGNERS}, got {}", @@ -213,7 +213,7 @@ fn build_policy_v3_bytes(p: &ParsedTokenPolicy) -> Result, String> { /// Parse a canonical v3 policy blob. Fail-closed on every field: a policy /// that cannot be fully validated is not a policy, because it is the anchored /// definition of an asset's rules. -fn parse_token_policy(raw_proto: &[u8]) -> Option { +pub(crate) fn parse_token_policy(raw_proto: &[u8]) -> Option { let policy = generated::TokenPolicyV3::decode(raw_proto).ok()?; let mut r = PolicyReader::new(&policy.policy_bytes); @@ -486,7 +486,7 @@ async fn try_fetch_policy_from_network(anchor: &[u8; 32]) -> Result dsm::types::policy_types::PolicyFile { @@ -1631,12 +1631,11 @@ impl AppRouterImpl { signature: authorization, }; - // 3.5b (owner ruling): initial creator supply is REFUSED — - // the new asset's credit has no authenticated issuance/source - // predicate yet, and an operation that cannot be admitted - // must not strand an active economic lineage. Token creation - // metadata + its ERA fee are supported now; creator supply - // waits for the issuance predicate (0x0029). + // Initial creator supply is REFUSED: supply at creation has + // no issuance source. The lifecycle is create-with-zero then + // `token.mint`, whose credit carries a 0x0029 authorization + // the verifier reruns — one issuance operation, one source + // predicate. if initial_alloc_u64 > 0 { return err(format!( "token.create: {}", @@ -1698,6 +1697,12 @@ impl AppRouterImpl { &self.core_sdk, create_op, deltas[0].clone(), + |_| { + Ok(( + dsm::economic::write_set::CreditSourceFacts::None, + Vec::new(), + )) + }, Some(&insert_registry), ) .await @@ -1951,32 +1956,243 @@ impl AppRouterImpl { .map_err(|e| format!("unknown token {token_id}: {e}")) } - /// `token.mint` — REFUSED until an authenticated issuance predicate exists. + /// `token.mint` — THE canonical issuance producer. /// - /// A mint creates units, so it is the one operation whose entire effect is - /// a credit with no prior holder. `R_econ` funds a credit only through a - /// `CreditSource`, and the arm that would carry issuance - /// (`0x0023 AuthorizedIssuance`) fails closed because its evidence class - /// `0x0029` is not written: nothing can yet prove a token policy authorized - /// this exact issuance. + /// A mint creates units, so its authority cannot be asserted by the + /// operation itself: the `0x0029` signatures cover this operation's + /// digest, which is why they live in a separate evidence bundle and why + /// `Operation::Mint` carries no authorization fields at all. The producer + /// ordering is load-bearing and acyclic: /// - /// This route is DEFENSE IN DEPTH. The authoritative refusal is at the - /// accepting layer (`DeviceState::advance`), which protects every caller - /// rather than this one; deleting this arm must leave minting refused. + /// ```text + /// Mint frozen -> operation_digest -> 0x0029 body (at the TARGET economic + /// position) -> signature -> evidence object -> admission + /// ``` /// - /// The mint-construction body was DELETED rather than left unreachable. It - /// signed a self-authorization with the caller's own device key and applied - /// a credit delta — the shape that has to change completely once issuance - /// carries `0x0029` evidence, so keeping it would preserve a path whose - /// only remaining purpose was the thing being refused. - async fn handle_token_mint(&self, _i: AppInvoke) -> AppResult { - err( - "token.mint: issuance is unavailable — no authenticated issuance predicate exists \ - yet (class 0x0029), so nothing could prove a token policy authorized this mint. \ - Minted units would be unspendable in every validated lineage, and holding them \ - would permanently block this device from activating its economic state." - .into(), + /// Every pre-flight failure happens BEFORE anything durable — no advance, + /// no fence, no frozen artifact. The evidence bytes are frozen in the SAME + /// transaction as the advance and the pending admission, so either the + /// mint never became locally accepted, or the mint, its admission and its + /// exact evidence all exist durably for resume. The route reports success + /// only after ECON_ADMITTED. + async fn handle_token_mint(&self, i: AppInvoke) -> AppResult { + let arg_pack = match generated::ArgPack::decode(&*i.args) { + Ok(p) => p, + Err(e) => return err(format!("decode ArgPack failed: {e}")), + }; + let req = match generated::TokenMintRequest::decode(&*arg_pack.body) { + Ok(r) => r, + Err(e) => return err(format!("decode TokenMintRequest failed: {e}")), + }; + if req.amount == 0 { + return err("token.mint: amount must be > 0".into()); + } + let policy_commit = match self.resolve_token_for_value_op(&req.token_id) { + Ok(c) => c, + Err(e) => return err(format!("token.mint: {e}")), + }; + // BUILTINS FAIL CLOSED BEFORE ANYTHING IS SIGNED. ERA must not become + // self-mintable merely because this device can sign something: ERA + // enters through the faucet's bootstrap tickets, and dBTC issuance + // arrives with the Bitcoin tap integration. + if let Some(name) = + dsm::core::token::token_state_manager::builtin_token_id_for_policy_commit( + &policy_commit, + ) + { + return err(format!( + "token.mint: {name} is a builtin — its issuance is not self-authorizable; ERA \ + is distributed by the faucet and dBTC issuance arrives with the Bitcoin tap" + )); + } + // THE EXACT COMMITTED POLICY BYTES, verified against their own commit. + // The evidence carries these bytes verbatim — never a reconstruction + // from parsed fields, never a mutable metadata row. + let canonical_policy_bytes = + match crate::storage::client_db::token_registry::load_policy_verified(&policy_commit) { + Ok(Some(b)) => b, + Ok(None) => { + return err(format!( + "token.mint: the anchored policy bytes for {} are not available on this \ + device, so the issuance evidence cannot carry them", + req.token_id + )); + } + Err(e) => return err(format!("token.mint: policy load failed: {e}")), + }; + // Run the CORE parser and support matrix against the committed bytes, + // so an unsupported V1 shape (finite cap, disabled mint/burn, an + // allowlist excluding this device, an unsatisfiable authority) fails + // HERE rather than after local mutation. + let policy = match dsm::economic::issuance::parse_issuance_policy(&canonical_policy_bytes) { + Ok(p) => p, + Err(e) => return err(format!("token.mint: committed policy: {e}")), + }; + let own_devid = self.device_id_bytes; + if let Err(e) = dsm::economic::issuance::check_issuance_permitted( + &policy, "mint", req.amount, &own_devid, + ) { + return err(format!("token.mint: {e}")); + } + // This wallet must actually HOLD the issuing authority: its signing + // key must be one the policy names, and the threshold must be + // satisfiable with the keys held locally (exactly one). A policy this + // device adopted but cannot satisfy gets a clean refusal, not a + // signature the verifier will not count. + let signer_public_key = match crate::sdk::signing_authority::current_public_key() { + Ok(pk) => pk, + Err(e) => return err(format!("token.mint: signing identity unavailable: {e}")), + }; + if !policy.signers.iter().any(|s| s == &signer_public_key) { + return err(format!( + "token.mint: this wallet does not hold the issuing authority for {} — its \ + signing key is not among the policy's committed signers", + req.token_id + )); + } + if policy.threshold > 1 { + return err(format!( + "token.mint: the policy requires {} distinct authority signatures and this \ + wallet holds one policy key — a k-of-n issuance needs the other signers' \ + signatures, which no local producer can supply", + policy.threshold + )); + } + + // The COMMITTED operation carries the CANONICAL token id, not the + // alias the caller typed: a mint addressed by ticker and one addressed + // by id must freeze IDENTICAL operation bytes, and the advance-path + // policy engine is keyed by the canonical id. The registry row is the + // same one strict resolution just verified a policy for. + let canonical_token_id = + crate::storage::client_db::token_registry::get_token(&req.token_id) + .ok() + .flatten() + .or_else(|| { + crate::storage::client_db::token_registry::get_token_by_ticker(&req.token_id) + .ok() + .flatten() + }) + .map(|row| row.token_id) + .unwrap_or_else(|| req.token_id.clone()); + + // FREEZE the exact Mint. Nothing may be inserted into it afterward — + // its digest is about to be committed inside the signed body. + let ref_hash = self + .core_sdk + .device_head() + .map(|s| s.genesis_digest()) + .unwrap_or([0u8; 32]); + let op = dsm::types::operations::Operation::Mint { + amount: dsm::types::token_types::Balance::from_state(req.amount, ref_hash), + token_id: canonical_token_id.as_bytes().to_vec(), + policy_commit, + message: req.message.clone(), + }; + let operation_digest = dsm::economic::faucet::dsm_operation_digest(&op.to_bytes()); + let (issuer_genesis, issuer_devid) = match self.core_sdk.device_head() { + Some(h) => (h.genesis_digest(), h.devid()), + Option::None => return err("token.mint: no device head".into()), + }; + // The delta credits EXACTLY the strict-resolved asset; conservation + // re-checks this against the signed operation inside `advance`. + let delta = dsm::types::device_state::BalanceDelta { + policy_commit, + direction: dsm::types::device_state::BalanceDirection::Credit, + amount: req.amount, + }; + let amount = req.amount; + + let outcome = match crate::sdk::economic_admission_flow::admitted_self_loop_operation( + &self.core_sdk, + op, + delta, + // Runs once the TARGET POSITION is fixed — the same coordinate the + // admission seam CAS-checks — and before anything durable. The + // body binds this issuance to that write-once register cell and to + // the exact frozen operation, which is the whole non-reuse story. + move |target_position| { + let body = dsm::economic::issuance::IssuanceAuthorizationBody { + policy_commit, + issuer_genesis, + issuer_devid, + issuer_economic_position: target_position, + recipient_operation_digest: operation_digest, + amount, + }; + let body_ccb = body.encode().map_err(|e| { + dsm::types::error::DsmError::invalid_operation(format!( + "issuance body encode: {e}" + )) + })?; + let digest = body.signing_digest().map_err(|e| { + dsm::types::error::DsmError::invalid_operation(format!( + "issuance signing digest: {e}" + )) + })?; + let secret_key = crate::sdk::signing_authority::current_secret_key()?; + let signature = + dsm::crypto::sphincs::sphincs_sign(&secret_key, &digest).map_err(|e| { + dsm::types::error::DsmError::crypto( + format!("issuance authorization signing failed: {e}"), + Option::::None, + ) + })?; + let evidence_bytes = generated::IssuanceAuthorizationEvidenceV1 { + canonical_policy_bytes, + authorization_body_ccb: body_ccb, + signatures: vec![generated::PolicySignerSignatureV1 { + signer_public_key, + signature, + }], + } + .encode_to_vec(); + // INNER identity — the evidence-DAG addressing form the + // resolver's fetch derives its store key from. + let issuance_authorization_addr = dsm::storage_object::immutable_inner( + dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, + &evidence_bytes, + ); + let object_key = crate::sdk::economic_registers::immutable_object_key( + dsm::common::domain_tags::TAG_DSM_ISSUANCE_AUTHORIZATION_EVIDENCE, + &evidence_bytes, + ); + Ok(( + dsm::economic::write_set::CreditSourceFacts::AuthorizedIssuance { + issuance_authorization_addr, + }, + vec![( + object_key, + evidence_bytes, + "issuance-authorization-evidence", + )], + )) + }, + None, ) + .await + { + Ok((o, _admitted)) => o, + Err(e) => return err(format!("token.mint: {e}")), + }; + + let new_balance = outcome.new_device_state.balance(&policy_commit); + self.write_token_projection( + &own_devid, + &req.token_id, + &policy_commit, + &outcome, + new_balance, + ); + + pack_envelope_ok(generated::envelope::Payload::TokenMintResponse( + generated::TokenMintResponse { + success: true, + token_id: req.token_id, + new_balance, + message: "Minted under the policy's issuing authority".to_string(), + }, + )) } async fn handle_token_burn(&self, i: AppInvoke) -> AppResult { @@ -2044,6 +2260,12 @@ impl AppRouterImpl { &self.core_sdk, op, deltas[0].clone(), + |_| { + Ok(( + dsm::economic::write_set::CreditSourceFacts::None, + Vec::new(), + )) + }, None, ) .await @@ -2134,6 +2356,53 @@ mod tests { } } + // ── SDK -> core issuance-parser conformance (owner control) ────── + // + // `policy_commit` hashes the exact bytes the SOLE production packer + // emits, and the 0x0029 verifier parses those SAME bytes in core. These + // two tests are the round-trip control the owner froze with the format: + // packer -> commit -> core `parse_issuance_policy` -> exact semantic + // fields, for BOTH allowlist shapes. The mismatch this pins against was + // real: core once read no count for kind NONE and refused every + // allowlist-free policy as trailing bytes — a blob no user token could + // ever issue under, invisible until the bytes crossed the crate boundary. + + #[test] + fn core_issuance_parser_reads_the_packed_none_allowlist_policy() { + let src = ParsedTokenPolicy { + unlimited_supply: true, + max_supply: 0, + initial_alloc: 0, + ..fungible_fixture() + }; + let proto = v3_policy(src.clone()); + let policy = dsm::economic::issuance::parse_issuance_policy(&proto) + .expect("core must parse the canonical packed NONE-allowlist policy"); + assert_eq!(policy.threshold, u32::from(src.mint_burn_threshold)); + assert_eq!(policy.signers, src.signers); + assert!(policy.mint_burn_enabled); + assert!(policy.transferable); + assert!(policy.unlimited_supply); + assert!(policy.allowlist_device_ids.is_empty()); + } + + #[test] + fn core_issuance_parser_reads_the_packed_inline_allowlist_policy() { + let src = ParsedTokenPolicy { + unlimited_supply: true, + max_supply: 0, + initial_alloc: 0, + allowlist_device_ids: vec![[0x11; 32], [0x22; 32]], + ..fungible_fixture() + }; + let proto = v3_policy(src.clone()); + let policy = dsm::economic::issuance::parse_issuance_policy(&proto) + .expect("core must parse the canonical packed INLINE-allowlist policy"); + assert_eq!(policy.allowlist_device_ids, src.allowlist_device_ids); + assert_eq!(policy.signers, src.signers); + assert!(policy.unlimited_supply); + } + // ── v3 round trip ──────────────────────────────────────────────── #[test] diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs index 84d5d39a5..b2bc10da6 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/core_sdk.rs @@ -1102,33 +1102,22 @@ impl CoreSDK { ))) } DsmOperation::Mint { - token_id, - amount, - policy_commit, - authorized_by, - proof_of_authorization, - .. + token_id, amount, .. } => { let token_id = Self::canonical_token_id_str(token_id).ok_or_else(|| { DsmError::invalid_operation( "Policy enforcement rejected: malformed or empty token_id", ) })?; + // The amount facts stay so supply-shaped conditions keep their + // inputs. NO authorization witness: mint's authority is the + // 0x0029 issuance evidence verified during economic admission, + // and the TokenAuthority condition no longer gates "mint" — + // inserting the legacy witness here would be a second + // authorization channel beside the one that actually decides. let amount_u64 = amount.value(); context.insert("amount_u64".to_string(), amount_u64.to_le_bytes().to_vec()); context.insert("amount".to_string(), amount_u64.to_string().into_bytes()); - context.insert("authorized_by".to_string(), authorized_by.clone()); - // Authorisation witness for the TokenAuthority condition. The - // enforcer rebuilds the signed preimage from these, so it - // verifies the message actually being executed. - Self::insert_auth_witness( - &mut context, - policy_commit, - token_id.as_bytes(), - amount_u64, - authorized_by, - proof_of_authorization, - ); Ok(Some((token_id.to_string(), "mint".to_string(), context))) } DsmOperation::Burn { @@ -2325,11 +2314,6 @@ impl CoreSDK { }; self.write_genesis_device_head(genesis_state_hash)?; - // Optional dev-only seeding (idempotent) - if let Err(e) = self.maybe_dev_seed_after_genesis().await { - log::warn!("Dev seeding skipped: {}", e); - } - Ok(GenesisInfo { genesis_hash: genesis_state.hash.to_vec(), device_id, @@ -2361,95 +2345,6 @@ impl CoreSDK { Ok(genesis_state.hash) } - /// Dev-only seeding of ERA token for local testing, idempotent via flag file - async fn maybe_dev_seed_after_genesis(&self) -> Result<(), DsmError> { - // Gate via env var DSM_DEV_SEED=1 - let enabled = std::env::var("DSM_DEV_SEED") - .ok() - .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")); - if !enabled { - return Ok(()); - } - - // Determine flag path - let flag_path = std::env::var("DSM_DEV_SEED_DIR") - .ok() - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(".dsm_dev")); - let _ = std::fs::create_dir_all(&flag_path); - let flag_file = flag_path.join("seeded.flag"); - if flag_file.exists() { - return Ok(()); - } - - // Construct a Mint operation for ERA - use dsm::types::operations::Operation as O; - use dsm::types::token_types::Balance as Bal; - - // Ensure we have a current state - let _cur = self.get_current_state()?; - - let mut amt = Bal::zero(); - amt.update(1_000_000, true); // 1_000_000 units for local testing - - let mint = O::Mint { - amount: amt, - token_id: b"ERA".to_vec(), - policy_commit: dsm::core::token::builtin_policy_commit_for_token("ERA").ok_or_else( - || DsmError::internal("ERA is a builtin token", None::), - )?, - authorized_by: crate::util::text_id::encode_base32_crockford( - &self.device_info.device_id, - ) - .into_bytes(), - proof_of_authorization: blake3_cat(&[b"dev-seed", &self.device_info.device_id]) - .to_vec(), - message: "dev seed".to_string(), - }; - - // Execute mint via relationship path (self-loop for authority mint) - let dev_id = self.device_info.device_id; - let rel_key = dsm::core::bilateral_transaction_manager::compute_smt_key(&dev_id, &dev_id); - let era_pc = dsm::core::token::token_state_manager::resolve_policy_commit("ERA")?; - let deltas = [dsm::types::device_state::BalanceDelta { - policy_commit: era_pc, - direction: dsm::types::device_state::BalanceDirection::Credit, - amount: 1_000_000, - }]; - let init_tip = dsm::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( - &dev_id, &dev_id, - ); - let mut sm = self.state_machine.lock(); - // Same fail-closed prepare → write → commit pattern as - // `execute_on_relationship`. The dev-seed mint participates in BCR - // archival so reader paths see the seeded balance. - let outcome = sm.prepare_advance_relationship( - rel_key, - dev_id, - mint, - &deltas, - Some(init_tip), - None, // anchor_leaf — dev-seed mint is an ordinary ingress transition - None, // offline_spend — online mint, no allocation draw - None, - )?; - // Dev-seed Mint is ingress (no capsule bump needed): bump_capsule = false. - Self::dual_write_advance_outcome(&outcome, false)?; - sm.commit_advance(&outcome); - let new_hash = outcome.new_chain_state.compute_chain_tip(); - log::info!("Dev seeding applied; new chain tip {:02x?}", &new_hash[..4]); - - // Write flag to ensure idempotence - std::fs::write(flag_file, b"seeded=1").map_err(|e| { - DsmError::internal( - format!("Failed to write seed flag: {e}"), - None::, - ) - })?; - - Ok(()) - } - /// Strict range query; no time, fail-closed if history unsupported pub async fn query_state_range( &self, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs index 6f9f2d139..6134509a5 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs @@ -380,15 +380,33 @@ pub(crate) fn build_dsm_admission( }) } -/// One ADMITTED self-loop operation (Burn, CreateToken fee), end to end: -/// resume any pending admission, assemble prerequisites, run the fence- +/// One ADMITTED self-loop operation (Burn, CreateToken fee, Mint), end to +/// end: resume any pending admission, assemble prerequisites, run the fence- /// coupled advance through the generalized seam, publish/register/validate, /// admit. The route gets the advance outcome back for its response -/// projection. The debit registers BEFORE the route reports success. +/// projection. The operation registers BEFORE the route reports success. +/// +/// `facts_for_position` supplies the operation's credit-source facts plus any +/// extra evidence artifacts, given the TARGET ECONOMIC POSITION this +/// admission will occupy. It runs after the position and operation digest are +/// fixed and before anything durable — a Mint builds and signs its `0x0029` +/// authorization here, binding the signed body to exactly the position the +/// admission seam CAS-checks. Pure operations pass +/// `|_| Ok((CreditSourceFacts::None, Vec::new()))`. The returned artifacts +/// are frozen in the SAME transaction as the advance and the pending +/// admission, so the crash invariant holds: either the operation never +/// became locally accepted, or the operation, its pending admission and its +/// exact evidence bytes all exist durably. pub(crate) async fn admitted_self_loop_operation( core: &CoreSDK, operation: Operation, delta: dsm::types::device_state::BalanceDelta, + facts_for_position: impl FnOnce( + u64, + ) -> Result< + (CreditSourceFacts, Vec<(String, Vec, &'static str)>), + DsmError, + >, in_tx_extra: Option< &(dyn Fn( &rusqlite::Transaction<'_>, @@ -415,6 +433,7 @@ pub(crate) async fn admitted_self_loop_operation( let authority = authority_material(&network_id, &genesis)?; let target_position = validated.economic_position() + 1; let op_digest = dsm::economic::faucet::dsm_operation_digest(&operation.to_bytes()); + let (facts, extra_artifacts) = facts_for_position(target_position)?; let prepared = PendingEconomicAdmission::prepared( dsm::economic::admission::PendingAdmissionKind::DsmBacked, target_position, @@ -434,9 +453,9 @@ pub(crate) async fn admitted_self_loop_operation( &operation, &pre_balances, &mut tree, - &CreditSourceFacts::None, + &facts, &authority, - Vec::new(), + extra_artifacts, )?; let coords = parts.coords; let artifacts = parts.artifacts.clone(); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/smart_commitment_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/smart_commitment_sdk.rs index 867611813..801e235a9 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/smart_commitment_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/smart_commitment_sdk.rs @@ -144,19 +144,16 @@ impl SmartCommitmentSDK { amount, token_id, policy_commit, - authorized_by, - proof_of_authorization, message, } => { buf.extend_from_slice(b"MINT"); Self::push_bytes(&mut buf, token_id); // The asset being minted is part of what is signed; without it - // a signature would not say WHICH asset it authorises. + // a signature would not say WHICH asset it authorises. Mint + // carries no authorization bytes — its authority is the 0x0029 + // admission evidence, outside the operation by construction. buf.extend_from_slice(policy_commit); buf.extend_from_slice(&amount.value().to_le_bytes()); - Self::push_bytes(&mut buf, authorized_by); - buf.extend_from_slice(&(proof_of_authorization.len() as u32).to_le_bytes()); - buf.extend_from_slice(proof_of_authorization); Self::push_str(&mut buf, message); } Operation::Burn { @@ -545,8 +542,6 @@ mod tests { amount: Balance::from_state(500, [0u8; 32]), token_id: b"ROOT".to_vec(), policy_commit: dsm::core::token::builtin_policy_commit_for_token("ERA").unwrap(), - authorized_by: b"authority".to_vec(), - proof_of_authorization: b"auth_proof".to_vec(), message: "mint".to_string(), } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/token_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/token_sdk.rs index c1e6ed646..5762525e6 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/token_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/token_sdk.rs @@ -1085,94 +1085,24 @@ impl TokenSDK { Ok(new_state) } - TokenOperation::Mint { - token_id, - recipient: _, - amount, - .. - } => { - if token_id == "ERA" { - let era_token = self.era_token.read(); - if era_token.circulating_supply.value() + *amount - > era_token.total_supply.value() - { - return Err(DsmError::invalid_operation( - "Minting would exceed total ERA supply", - )); - } - } - - let policy_commit = self.resolve_policy_commit_strict(token_id)?; - let signer_pk = current_state.device_info.public_key.clone(); - let authorized_by = current_state.device_info.device_id.to_vec(); - let signing_key = crate::sdk::signing_authority::current_secret_key()?; - let mut mint_msg = b"mint|v2|".to_vec(); - mint_msg.extend_from_slice(&authorized_by); - mint_msg.extend_from_slice(token_id.as_bytes()); - mint_msg.extend_from_slice(&amount.to_le_bytes()); - mint_msg.extend_from_slice(&state_hash); - let mint_hash = - dsm::crypto::blake3::token_domain_hash(&policy_commit, "mint", &mint_msg); - let mint_sig = - dsm::crypto::sphincs::sphincs_sign(&signing_key, mint_hash.as_bytes()) - .map_err(|e| { - DsmError::crypto( - format!("Failed to sign mint authorization: {e}"), - None::, - ) - })?; - - let op = Operation::Mint { - amount: Balance::from_state(*amount, state_hash), - token_id: token_id.as_bytes().to_vec(), - policy_commit, - authorized_by, - proof_of_authorization: encode_embedded_proof(&signer_pk, &mint_sig)?, - message: "Mint operation via TokenSDK".to_string(), - }; - - // Mint: relationship is device↔CPTA-authority (self for self-mint) - let mint_rel_key = dsm::core::bilateral_transaction_manager::compute_smt_key( - ¤t_state.device_info.device_id, - ¤t_state.device_info.device_id, - ); - let mint_deltas = [dsm::types::device_state::BalanceDelta { - policy_commit, - direction: dsm::types::device_state::BalanceDirection::Credit, - amount: *amount, - }]; - let mint_init_tip = - dsm::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids( - ¤t_state.device_info.device_id, - ¤t_state.device_info.device_id, - ); - let (new_state, _) = self.core_sdk.execute_on_relationship( - mint_rel_key, - current_state.device_info.device_id, - op, - &mint_deltas, - Some(mint_init_tip), - )?; - self.project_balance_cache_from_state( - current_state.device_info.device_id, - &new_state, - )?; - - if token_id == "ERA" { - let mut era_token = self.era_token.write(); - let new_circulation = Balance::from_state( - era_token.circulating_supply.value() + *amount, - new_state.hash, - ); - era_token.circulating_supply = new_circulation; - } - - { - let mut history = self.transaction_history.write(); - history.push((operation.clone(), crate::util::deterministic_time::tick())); - } - - Ok(new_state) + TokenOperation::Mint { token_id, .. } => { + // OWNER RULING (0x0029 producer cut): token.mint is the ONE + // mint producer. This surface used to sign the legacy + // `mint|v2|` self-authorization and advance a raw credit via + // `execute_on_relationship` — a positive mint with NO economic + // admission, which the accepting layer refuses since the + // producer cut. Its remaining caller chain is the Bitcoin/dBTC + // deposit completion, and dBTC is a BUILTIN whose issuance is + // not self-authorizable at all: that success path was already + // structurally impossible, and it becomes honest here. dBTC + // issuance into R_econ arrives with the Bitcoin tap + // integration; user-token issuance goes through `token.mint`. + Err(DsmError::invalid_operation(format!( + "TokenSDK mint of {token_id} is not a producer: a positive mint enters \ + canonical state only through token.mint's economic admission, whose \ + 0x0029 issuance evidence a verifier reruns — and builtin dBTC issuance \ + arrives with the Bitcoin tap integration" + ))) } TokenOperation::Burn { token_id, amount, .. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_rebuild.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_rebuild.rs index 24bb0eab0..9c360d43e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_rebuild.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/canonical_rebuild.rs @@ -158,7 +158,12 @@ pub fn rebuild_head_from_checkpoint( authority_policy: Option::None, .. } if to_device_id.as_slice() == head.devid().as_slice() - ) || matches!(&state.operation, Operation::FaucetClaim { .. }); + ) || matches!(&state.operation, Operation::FaucetClaim { .. }) + // An admitted Mint is admission-gated the same way (0x0029 + // producer cut): without this arm, a head rebuild TRUNCATES at the + // first historical mint — the replay hits the accepting gate with + // no fence attached and stops the whole reconstruction. + || matches!(&state.operation, Operation::Mint { .. }); if is_gated_credit { head = head.with_pending_economic_admission(Some( dsm::economic::admission::PendingEconomicAdmission::prepared( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_list_metadata.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_list_metadata.rs index 0cdc5bcf1..9ee733eee 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_list_metadata.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/balance_list_metadata.rs @@ -69,7 +69,7 @@ fn fund_era(r: &AppRouterImpl) { /// Install a HELD custom token directly: a registry row plus a fixture /// balance in base units. `token.create` can no longer produce one here — -/// creator supply is refused pending the issuance predicate (0x0029), and +/// creator supply is refused (issuance goes through `token.mint`), and /// the creation fee is an ADMITTED economic debit integration tests cannot /// run (no fake register fleet) — and these are READ-path tests: the /// balance list is indifferent to how the units arrived. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_authority_enforcement.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_authority_enforcement.rs index c30e9272d..8ee19bfdb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_authority_enforcement.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_authority_enforcement.rs @@ -9,6 +9,12 @@ //! caller's OWN proof, which authorises anybody able to sign with a key they //! generated themselves. //! +//! Since the 0x0029 producer cut, `TokenAuthority` gates BURN and +//! CREATE_TOKEN only: a mint's authority is the policy-signed issuance +//! evidence verified during economic admission, so the mechanism tests here +//! run against "burn" — the operation that still carries the embedded +//! witness — and one test pins the severance itself. +//! //! These tests exercise the enforcer directly, because that is where the //! guarantee lives. Each pins a property that the old design failed: //! @@ -90,8 +96,8 @@ async fn authorized_signer_is_allowed() { signers: vec![pk.clone()], threshold: 1, }; - let sig = sign_for(&sk, "mint", 100); - assert!(check(&cond, &ctx("mint", 100, witness(&pk, &sig))).await); + let sig = sign_for(&sk, "burn", 100); + assert!(check(&cond, &ctx("burn", 100, witness(&pk, &sig))).await); } /// THE CORE FLAW, CLOSED. A non-signer presenting a perfectly valid signature @@ -107,9 +113,9 @@ async fn non_signer_with_a_valid_self_signed_proof_is_denied() { threshold: 1, }; // Cryptographically valid — just not by anyone the policy names. - let sig = sign_for(&attacker_sk, "mint", 100); + let sig = sign_for(&attacker_sk, "burn", 100); assert!( - !check(&cond, &ctx("mint", 100, witness(&attacker_pk, &sig))).await, + !check(&cond, &ctx("burn", 100, witness(&attacker_pk, &sig))).await, "the verifying key must come from the policy, never from the proof" ); } @@ -123,25 +129,25 @@ async fn signature_over_a_different_amount_is_denied() { signers: vec![pk.clone()], threshold: 1, }; - let sig_for_1 = sign_for(&sk, "mint", 1); + let sig_for_1 = sign_for(&sk, "burn", 1); assert!( - !check(&cond, &ctx("mint", 1_000_000, witness(&pk, &sig_for_1))).await, + !check(&cond, &ctx("burn", 1_000_000, witness(&pk, &sig_for_1))).await, "a signature authorising 1 must not authorise 1,000,000" ); } -/// Nor onto the opposite operation. +/// Nor onto a different operation: the preimage names the op. #[tokio::test] -async fn signature_for_mint_does_not_authorize_a_burn() { +async fn signature_for_burn_does_not_authorize_a_create() { let (pk, sk) = keypair(1); let cond = PolicyCondition::TokenAuthority { signers: vec![pk.clone()], threshold: 1, }; - let mint_sig = sign_for(&sk, "mint", 50); + let burn_sig = sign_for(&sk, "burn", 50); assert!( - !check(&cond, &ctx("burn", 50, witness(&pk, &mint_sig))).await, - "a mint authorisation must not authorise a burn" + !check(&cond, &ctx("create_token", 50, witness(&pk, &burn_sig))).await, + "a burn authorisation must not authorise a token creation" ); } @@ -157,10 +163,10 @@ async fn threshold_counts_distinct_signers() { }; // One signer, twice → still one distinct signer. - let mut doubled = witness(&pk1, &sign_for(&sk1, "mint", 7)); - doubled.extend_from_slice(&witness(&pk1, &sign_for(&sk1, "mint", 7))); + let mut doubled = witness(&pk1, &sign_for(&sk1, "burn", 7)); + doubled.extend_from_slice(&witness(&pk1, &sign_for(&sk1, "burn", 7))); assert!( - !check(&cond, &ctx("mint", 7, doubled)).await, + !check(&cond, &ctx("burn", 7, doubled)).await, "the same key twice must not satisfy a 2-of-2 threshold" ); @@ -168,15 +174,15 @@ async fn threshold_counts_distinct_signers() { assert!( !check( &cond, - &ctx("mint", 7, witness(&pk1, &sign_for(&sk1, "mint", 7))) + &ctx("burn", 7, witness(&pk1, &sign_for(&sk1, "burn", 7))) ) .await ); // Both → satisfied. - let mut both = witness(&pk1, &sign_for(&sk1, "mint", 7)); - both.extend_from_slice(&witness(&pk2, &sign_for(&sk2, "mint", 7))); - assert!(check(&cond, &ctx("mint", 7, both)).await); + let mut both = witness(&pk1, &sign_for(&sk1, "burn", 7)); + both.extend_from_slice(&witness(&pk2, &sign_for(&sk2, "burn", 7))); + assert!(check(&cond, &ctx("burn", 7, both)).await); } /// No witness at all must be denied, not waved through. @@ -187,11 +193,33 @@ async fn missing_authorization_is_denied() { signers: vec![pk], threshold: 1, }; - let mut c = ctx("mint", 10, Vec::new()); + let mut c = ctx("burn", 10, Vec::new()); c.data.remove(witness_keys::AUTHORIZATIONS); assert!(!check(&cond, &c).await); } +/// THE SEVERANCE PIN. `TokenAuthority` does NOT gate "mint" any more: a mint +/// with no witness at all passes this CONDITION, because a mint's authority is +/// the 0x0029 issuance evidence verified during economic admission — and a +/// second, embedded channel beside it is exactly what the producer cut +/// deleted. (The mint itself is still gated: the accepting layer requires the +/// attached admission, whose source the economic verifier proves.) +#[tokio::test] +async fn token_authority_does_not_gate_mint() { + let (pk, _sk) = keypair(1); + let cond = PolicyCondition::TokenAuthority { + signers: vec![pk], + threshold: 1, + }; + let mut c = ctx("mint", 10, Vec::new()); + c.data.remove(witness_keys::AUTHORIZATIONS); + assert!( + check(&cond, &c).await, + "TokenAuthority must not demand a witness from an operation whose \ + authorization channel is the 0x0029 admission evidence" + ); +} + // ── supply cap ────────────────────────────────────────────────────────────── fn supply_ctx(op: &str, amount: u64, circulating: u64) -> EnforcementContext { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_decimal_scaling.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_decimal_scaling.rs index 5e1189ba6..27ae867b0 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_decimal_scaling.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_decimal_scaling.rs @@ -120,8 +120,9 @@ fn create( // The creation-credit scaling tests (display alloc -> base-unit credit) and // the mint/burn base-unit accounting tests are DELETED with their capability: -// under 3.5b creator supply is refused pending the issuance predicate -// (0x0029), and custom-token mint/burn has no economically admissible path. +// creator supply is refused (issuance goes through `token.mint` under a +// 0x0029 authorization), and this integration fixture has no fleet to admit +// a mint through. // The cap-scaling half of the contract (display cap -> BASE-unit registry // cap) is no longer pinned on a SUCCESSFUL creation anywhere: a capped policy // is refused in beta, so no creation that reaches the registry carries a cap diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_forget.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_forget.rs index ad20637a2..3af33793e 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_forget.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_forget.rs @@ -176,7 +176,7 @@ fn a_token_with_a_balance_cannot_be_forgotten() { // Hold the token: an adopted identity plus an installed balance. The // route can no longer CREATE a held token here — under 3.5b creator - // supply is refused pending the issuance predicate (0x0029) and the fee + // supply is refused (issuance goes through `token.mint`) and the fee // is an admitted debit integration tests cannot run — and this test is // about the FORGET rule, which reads canonical holdings however they // arrived. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_mint_burn_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_mint_burn_routes.rs index 395b3ee3e..c9c0a68bb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_mint_burn_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/token_mint_burn_routes.rs @@ -7,7 +7,7 @@ //! pins is therefore the REFUSAL surface: //! //! * creation with `initial_supply > 0` gets the named issuance-predicate -//! refusal (owner ruling — creator supply waits for `0x0029`); +//! refusal (create with zero supply, then issue through `token.mint`); //! * a fee-bearing creation with no economic ancestry fails closed; //! * minting an unknown token fails closed; //! * a burn that cannot be admitted is refused, never performed locally. @@ -169,8 +169,8 @@ fn burn(router: &AppRouterImpl, token_id: &str, amount: u64) -> dsm_sdk::bridge: fn creation_with_initial_supply_gets_the_named_refusal() { // 3.5b (owner ruling): the new asset's supply credit has no // authenticated issuance/source predicate, so creator supply is REFUSED - // with the exact named error — token metadata + the ERA fee are the - // supported creation shape until 0x0029 exists. + // with the exact named error — the supply route is `token.mint` under a + // 0x0029 authorization, never supply-at-creation. init_test_storage(); let router = new_router(); fund_era(&router); @@ -201,32 +201,43 @@ fn fee_bearing_creation_without_economic_ancestry_fails_closed() { ); } -/// `token.mint` refuses BEFORE it resolves anything, and says why. +/// `token.mint` resolves FIRST and refuses each bad shape for ITS OWN reason. /// -/// This used to assert that an unknown ticker fails closed. It cannot test that -/// any more: the route now refuses issuance outright, before token resolution -/// is reached, so an unknown ticker and a perfectly good one take the identical -/// path. Asserting `!success` on an unknown ticker would therefore be vacuous — -/// green whether or not resolution works at all. -/// -/// What is still true and worth pinning is that the refusal is the ISSUANCE -/// one, for a KNOWN token as much as an unknown one, and that it names the -/// missing predicate rather than failing for an incidental reason. +/// The predecessor of this test pinned the pre-producer blanket refusal, whose +/// doc predicted exactly this re-cut: with the 0x0029 producer live, the route +/// resolves the ticker before anything else, so an unknown token gets the +/// resolution refusal and a builtin gets the named builtin refusal — two +/// distinct reasons where there used to be one indiscriminate wall. #[test] #[serial] -fn mint_refuses_issuance_before_it_resolves_the_token() { +fn mint_refuses_each_bad_shape_for_its_own_reason() { init_test_storage(); let router = new_router(); fund_era(&router); - for ticker in ["ERA", "no-such-token"] { - let res = mint(&router, ticker, 10); - assert!(!res.success, "minting {ticker} must fail"); - let msg = res.error_message.as_deref().unwrap_or_default(); - assert!( - msg.contains("0x0029"), - "the refusal names the missing issuance predicate for {ticker}: {msg}" - ); - } + + let unknown = mint(&router, "no-such-token", 10); + assert!(!unknown.success, "minting an unknown token must fail"); + let msg = unknown.error_message.as_deref().unwrap_or_default(); + assert!( + msg.contains("unknown token"), + "an unknown ticker fails at RESOLUTION, got: {msg}" + ); + + let builtin = mint(&router, "ERA", 10); + assert!(!builtin.success, "minting a builtin must fail"); + let msg = builtin.error_message.as_deref().unwrap_or_default(); + assert!( + msg.contains("builtin") && msg.contains("not self-authorizable"), + "a builtin fails on the NAMED builtin refusal before anything is signed, got: {msg}" + ); + + let zero = mint(&router, "ERA", 0); + assert!(!zero.success, "a zero mint must fail"); + let msg = zero.error_message.as_deref().unwrap_or_default(); + assert!( + msg.contains("amount must be > 0"), + "a zero amount is refused before resolution, got: {msg}" + ); } #[test]