From 2917f5e40703b6351ef4c95426adc4299121dfdc Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:44:52 -0400 Subject: [PATCH] =?UTF-8?q?feat(economic):=20the=200x0029=20issuance=20pro?= =?UTF-8?q?ducer=20=E2=80=94=20token.mint=20is=20the=20one=20mint=20channe?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer half of the 0x0029 core/producer split. After this cut, the only way a positive user-token Mint enters canonical DeviceState is through an economic admission whose CreditSource is AuthorizedIssuance and whose immutable 0x0029 evidence independently proves the committed policy authorized that exact asset, amount, recipient lineage/device, operation digest, and write-once economic position. Operation::Mint is now {amount, token_id (CANONICAL id), policy_commit, message}. The legacy authorization channel is deleted outright: the authorized_by/proof_of_authorization wire fields (tag-4 layout change under the beta wipe), the mint|v2| verifiers in transition.rs and token_state_manager, TokenAuthority's gating of "mint" (burn/create_token keep it, with a severance pin test), the TokenSDK direct-mint arm (named refusal; its only caller was the dBTC deposit completion, whose success was already structurally impossible), and the dev-seed ERA mint. A second channel inside the operation is unrepresentable, not merely forbidden. token.mint follows the frozen acyclic order: resolve -> refuse builtins -> load the exact committed policy bytes -> core pre-flight (parse_issuance_policy + check_issuance_permitted) -> own key among the policy's signers, locally satisfiable threshold -> freeze the Mint -> digest -> 0x0029 body at the TARGET position -> sign -> bundle -> admission. admitted_self_loop_operation gains facts_for_position; the evidence freezes in the accept transaction; success only after ECON_ADMITTED. The accepting layer gates positive non-builtin mints on require_attached_dsm_admission; canonical_rebuild whitelists Mint so head rebuilds no longer truncate at the first historical mint. Three defects in the merged #748 verifier, all found by this cut's own tests and none by #748's green suite: (1) verify_operation_write_set had no (AuthorizedIssuance, Mint) arm — no issuance witness could ever have validated; the defined-but-unsatisfied-predicate test was re-cut to the provenance layer where the refusal now correctly lives; (2) the evidence address used the outer immutable_addr while the whole evidence DAG and the resolver fetch path use the inner immutable_inner — every live fetch missed by construction (the producer-less 0x0026/0x0027 arms carry the identical mismatch; their fix belongs to the PR5/PR6 producer cuts); (3) core's policy-blob parser refused the SDK packer's always-present u16 allowlist count as trailing bytes, leaving every allowlist-free token permanently unissuable — core now parses the committed format, pinned by SDK-to-core round-trip controls for both allowlist shapes. Producer suite: honest mint e2e foreign-walked through the live 0x0023 arm; policy shapes refused by name with an allowlist positive control; atomicity (a failed evidence build leaves nothing durable); held-then-resume completing the same admission with byte-identical evidence. Mutation control: the accepting gate neutralized -> the named test goes red by actually crediting 1,000 units from air -> restored. Boards on the final tree: workspace --release 3925/0 across 72 suites (REAL_EXIT=0), node --release 270/0, make lint exit 0, production safety exit 0. --- .../src/commitments/parameter_comparison.rs | 7 - .../dsm/src/core/state_machine/transition.rs | 95 +-- .../core/token/policy/policy_enforcement.rs | 16 +- .../dsm/src/core/token/token_state_manager.rs | 83 +-- .../dsm/src/economic/credit.rs | 9 +- .../dsm/src/economic/issuance.rs | 78 +-- .../dsm/src/economic/mod.rs | 20 +- .../dsm/src/economic/provenance.rs | 37 +- .../dsm/src/economic/write_set.rs | 28 + .../dsm/src/recovery/pdsmt_posting.rs | 5 +- .../dsm/src/types/device_state.rs | 106 ++-- .../dsm/src/types/operations.rs | 44 +- .../dsm/src/types/policy_types.rs | 8 +- .../dsm/tests/economic_admission_lifecycle.rs | 26 +- .../dsm/tests/economic_authorized_issuance.rs | 42 +- .../dsm/tests/economic_write_set.rs | 4 - .../dsm_sdk/src/handlers/dlv_routes.rs | 6 +- .../src/handlers/sender_admission_tests.rs | 550 +++++++++++++++++- .../dsm_sdk/src/handlers/token_routes.rs | 359 ++++++++++-- .../dsm_sdk/src/sdk/core_sdk.rs | 119 +--- .../src/sdk/economic_admission_flow.rs | 29 +- .../dsm_sdk/src/sdk/smart_commitment_sdk.rs | 11 +- .../dsm_sdk/src/sdk/token_sdk.rs | 106 +--- .../storage/client_db/canonical_rebuild.rs | 7 +- .../dsm_sdk/tests/balance_list_metadata.rs | 2 +- .../tests/token_authority_enforcement.rs | 66 ++- .../dsm_sdk/tests/token_decimal_scaling.rs | 5 +- .../dsm_sdk/tests/token_forget.rs | 2 +- .../dsm_sdk/tests/token_mint_burn_routes.rs | 57 +- 29 files changed, 1235 insertions(+), 692 deletions(-) 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]