From 9b1666ebecbeb43382e6aa7e4dd1283e2c3c2f6a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:15:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(economic):=20the=20DLV=20market-leg=20toke?= =?UTF-8?q?n-policy=20conjunct=20=E2=80=94=20enforced=20at=20three=20layer?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero DLV value operations consulted the token policy of any asset they moved, at any layer: the four ops fell through build_token_policy_context's wildcard, dlv.create accepted any 32 random bytes as a funding leg, and a non-transferable token that online Transfer fully blocks funded, traded, folded and drained through markets freely. SoFi places the "applicable token policy" conjunct on every market successor and every release (Def 4.1, Req 4.4, Req 4.6, 6.25, 21.10/21.14); it is now enforced. The model, owner-frozen: token anchors are PUBLIC identifiers (the CoinGecko model) — discovery external, adoption open, rooting the precondition of interaction. Policy bytes come from a device's OWN rooting, never a counterparty's evidence: the new ProvenanceResolver::anchored_policy_bytes serves the verifier's local anchoring or a fetch from the authoritative content-addressed path, re-hashed under TAG_DSM_POLICY against the authenticated commit before anything trusts a byte — a locator, never authority. Unavailable is Incomplete (root, then retry). ERA and dBTC are pre-rooted on every device, the sole exceptions. allowlist_device_ids is issuance-recipient-scoped and has NO market meaning; transferable is the one movement-relevant commitment, and a non-transferable asset is not a market leg in any role. One matrix (check_market_leg_permitted) and one leg extraction (market_leg_commits), three layers: a central conjunct in advance_validated — central rather than per-arm, so fund and close are foreign-verifiable despite their SameTransitionMove credits carrying no evidence channel, with zero wire changes; the advance-funnel twin from local rooting, covering every caller; and fetch-and-root pre-flights at dlv.create / dlv.unlockRouted / dlv.reconcile / dlv.close, the only network-touching layer. Proofs: the conjunct driven through the FULL validation stack with a real DlvFund witness (rooted transferable validates; ERA+dBTC validates against an EMPTY anchor store — builtins never consult the resolver; non-transferable, unrooted, and wrong-hash bytes each refused by name); three route refusal shapes with a positive control; a DIRECT funnel drive bypassing all routes. Two mutation controls red-then-restored: the lineage conjunct neutralized lets reserve encumbrance of a non-transferable asset VALIDATE, and the funnel neutralized lets the same asset sail to the balance check. The shared funded_vault_fixture now roots real transferable policies — its arbitrary byte-pair was the hole itself, and 20 tests fired on it as designed. Deferred honestly: settle/reconcile/close dedicated route drives arrive with the PR6 e2e fixtures (the shared helper is proven at create and the funnel covers those ops for every caller); the 0x0026/0x0027 addressing defect stays with the PR5/PR6 producer cuts. Boards on the final tree: workspace --release 3934/0 across 72 suites (REAL_EXIT=0), node --release 270/0, make lint exit 0, production safety exit 0. --- .../dsm/src/economic/issuance.rs | 54 ++++ .../dsm/src/economic/lineage.rs | 9 + .../dsm/src/economic/peer_lineage.rs | 15 + .../dsm/src/economic/provenance.rs | 101 +++++++ .../dsm/tests/economic_admission_lifecycle.rs | 277 ++++++++++++++++++ .../dsm/tests/economic_authorized_issuance.rs | 9 + .../economic_dlv_owner_apply_provenance.rs | 18 ++ .../tests/economic_dlv_settle_provenance.rs | 9 + .../dsm/tests/economic_peer_evidence.rs | 18 ++ .../tests/economic_provenance_semantics.rs | 9 + .../dsm/tests/era_faucet_wire.rs | 18 ++ .../dsm_sdk/src/handlers/dlv_routes.rs | 100 ++++++- .../src/handlers/sender_admission_tests.rs | 89 ++++++ .../dsm_sdk/src/handlers/token_routes.rs | 4 +- .../dsm_sdk/src/sdk/core_sdk.rs | 51 ++++ .../dsm_sdk/src/sdk/economic_registers.rs | 68 +++++ .../dsm_sdk/src/sdk/funded_vault_fixture.rs | 48 ++- .../dsm_sdk/tests/vault_funding_routes.rs | 126 ++++++++ 18 files changed, 1018 insertions(+), 5 deletions(-) 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 4ae6cab9..c10e9c09 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/issuance.rs @@ -309,6 +309,60 @@ pub fn parse_issuance_policy(policy_proto: &[u8]) -> Result) -> core::fmt::Result { + match self { + Self::NotTransferable => write!( + f, + "the committed policy marks the asset non-transferable (mint/burn only), so it \ + cannot be a market leg — a DLV successor moves control between parties" + ), + } + } +} + +impl std::error::Error for MarketLegRefusal {} + +/// Decide whether the committed policy permits its asset as a DLV MARKET LEG +/// (fund, settle, owner-apply, close) — the "applicable token policy" +/// conjunct SoFi Def 4.1 / Req 4.4 / Req 4.6 place on every market successor +/// and every release. +/// +/// The matrix is deliberately small, and what it does NOT consult is a +/// ruling, not an omission (owner, 2026-08-30 — the CoinGecko model): token +/// anchors are PUBLIC identifiers, discovery is external, adoption is open — +/// anyone holding the anchor may root to the token. Consequently: +/// +/// - `allowlist_device_ids` is ISSUANCE-RECIPIENT-scoped (who may be minted +/// to, enforced by 0x0029) and has no market meaning — an +/// allowlisted-issuance asset trades freely once issued. +/// - `mint_burn_enabled`, `TokenAuthority` and the supply fields govern +/// issuance and are likewise none of the market's business. +/// - `transferable` is the ONE movement-relevant commitment the policy +/// carries, and it binds here. +/// +/// Rooting itself is the caller's precondition: this function takes a PARSED +/// policy, so reaching it already required the canonical bytes that re-hash +/// to the leg's commit. ERA and dBTC are pre-rooted on every device by +/// construction and never reach this matrix. +pub fn check_market_leg_permitted(policy: &IssuancePolicy) -> Result<(), MarketLegRefusal> { + if !policy.transferable { + return Err(MarketLegRefusal::NotTransferable); + } + Ok(()) +} + /// Decide whether the committed policy permits THIS issuance. /// /// This is the V1 support matrix, and it is the ONLY one. diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs index 36336ae7..94146798 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs @@ -579,6 +579,15 @@ pub fn advance_validated( substrate_b_pair: accepted.dsm_successor_pair(), verified_operation: accepted.dsm_verified_operation(), }; + // THE MARKET-LEG TOKEN-POLICY CONJUNCT: a DLV successor's legs must + // satisfy the applicable token policy (SoFi Def 4.1 / Req 4.4 / Req 4.6). + // Central, on the VERIFIED operation, so fund and close are bound even + // though their SameTransitionMove credits carry no evidence channel. + // Non-DLV operations pass vacuously. + if let Some(op) = accepted.dsm_verified_operation() { + crate::economic::provenance::verify_market_leg_policies(op, resolver) + .map_err(EconomicValidationError::Provenance)?; + } let funded = verify_transition_provenance(witness, resolver, &ctx) .map_err(EconomicValidationError::Provenance)?; diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs index f0191502..07129541 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/peer_lineage.rs @@ -84,6 +84,14 @@ pub trait PeerEvidenceFetcher { namespace: TaggedHashDomain<'static>, addr: &[u8; 32], ) -> Result, PeerLineageFailure>; + /// The canonical `TokenPolicyV3` bytes rooted under `policy_commit` — + /// the walker's own anchoring (local store or the authoritative + /// content-addressed path). The verifier re-hashes against the commit; + /// unavailable is `Incomplete`, never `Invalid`. + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure>; } /// A trusted starting memo: a coordinate THIS verifier validated earlier @@ -163,6 +171,13 @@ impl ProvenanceResolver for WalkingResolver<'_> { ) -> Result, PeerLineageFailure> { self.fetcher.immutable(namespace, addr) } + + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + self.fetcher.anchored_policy_bytes(policy_commit) + } } /// Validate a peer's lineage up to `target_position` and return that step's 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 3ca96b8b..fc325966 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/provenance.rs @@ -206,6 +206,21 @@ pub trait ProvenanceResolver { namespace: crate::crypto::domain::TaggedHashDomain<'static>, addr: &[u8; 32], ) -> Result, PeerLineageFailure>; + + /// The canonical `TokenPolicyV3` bytes rooted under `policy_commit` — + /// the VERIFIER'S OWN anchoring in the token's public anchor, never + /// counterparty-supplied bytes. Anchors are public identifiers (the + /// CoinGecko model): anyone holding the commit may root to the token, and + /// a verifier holding a `V_n`-authenticated commit IS a holder — so the + /// resolver serves its local rooting or fetches from the authoritative + /// content-addressed path. The verifier re-hashes whatever arrives + /// against the commit before trusting a byte; the resolver is a locator, + /// never authority. Unavailable bytes are `Incomplete` — an availability + /// condition, not a permission. + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure>; } /// Why a credit is not funded. @@ -215,6 +230,12 @@ pub enum ProvenanceError { /// the policy bytes, the signed body, the V1 support matrix, or the /// k-of-N threshold over the exact issuance. AuthorizedIssuanceInvalid(String), + /// A DLV successor's leg fails the applicable token policy — the SoFi + /// Def 4.1 / Req 4.4 / Req 4.6 conjunct on every market movement and + /// every release. The anchored bytes did not re-hash to the committed + /// leg, did not parse, or the parsed policy refuses the asset as a + /// market leg. + MarketLegPolicy(String), /// The verifier holds no validated transition for the named peer position. /// NOT a failure of the peer — a failure of *this* verifier to have /// established the prerequisite, and it fails closed. @@ -301,6 +322,9 @@ impl core::fmt::Display for ProvenanceError { Self::AuthorizedIssuanceInvalid(m) => { write!(f, "authorized-issuance credit is invalid: {m}") } + Self::MarketLegPolicy(m) => { + write!(f, "market leg token policy: {m}") + } Self::PeerTransitionNotValidated { peer_economic_position, failure, @@ -1407,6 +1431,83 @@ fn requires_consumed_source_record(source: &CreditSource) -> bool { ) } +/// THE MARKET-LEG TOKEN-POLICY CONJUNCT (SoFi Def 4.1, Req 4.4, Req 4.6): +/// every DLV successor's legs must satisfy the applicable token policy, and +/// this is where a foreign verifier reruns that decision — centrally, on the +/// VERIFIED operation, so fund and close are covered even though their +/// credits are `SameTransitionMove` and carry no evidence channel. +/// +/// Per leg: a builtin commit (ERA, dBTC) is pre-rooted on every device by +/// construction and passes; any other commit requires the verifier's OWN +/// anchoring — `resolver.anchored_policy_bytes` — whose bytes must re-hash +/// under `TAG_DSM_POLICY` to the committed leg (the resolver locates, never +/// authorizes), parse as a v3 policy, and pass +/// [`crate::economic::issuance::check_market_leg_permitted`]. +/// +/// Non-DLV operations have no market legs and pass vacuously; their policy +/// conjuncts live elsewhere (0x0023 for issuance, the transfer path's +/// enforcement for sends). +/// The market-leg policy commits a DLV value operation moves — empty for +/// every non-DLV operation. ONE extraction, shared by the core conjunct, the +/// SDK advance funnel and the route pre-flights, so the four ops cannot +/// drift apart across layers. +pub fn market_leg_commits(operation: &crate::types::operations::Operation) -> Vec<[u8; 32]> { + use crate::types::operations::Operation; + match operation { + Operation::DlvCreateFundedV2 { + leg_a_policy_commit, + leg_b_policy_commit, + .. + } + | Operation::DlvClose { + leg_a_policy_commit, + leg_b_policy_commit, + .. + } => vec![*leg_a_policy_commit, *leg_b_policy_commit], + Operation::DlvSettle { + input_policy_commit, + output_policy_commit, + .. + } + | Operation::DlvOwnerApplyV2 { + input_policy_commit, + output_policy_commit, + .. + } => vec![*input_policy_commit, *output_policy_commit], + _ => Vec::new(), + } +} + +pub fn verify_market_leg_policies( + operation: &crate::types::operations::Operation, + resolver: &dyn ProvenanceResolver, +) -> Result<(), ProvenanceError> { + for pc in market_leg_commits(operation) { + if crate::core::token::token_state_manager::builtin_token_id_for_policy_commit(&pc) + .is_some() + { + continue; + } + let bytes = resolver + .anchored_policy_bytes(&pc) + .map_err(ProvenanceError::OwnerLineage)?; + if crate::crypto::blake3::domain_hash_bytes( + crate::common::domain_tags::TAG_DSM_POLICY, + &bytes, + ) != pc + { + return Err(ProvenanceError::MarketLegPolicy( + "anchored policy bytes do not hash to the committed leg".into(), + )); + } + let policy = crate::economic::issuance::parse_issuance_policy(&bytes) + .map_err(|e| ProvenanceError::MarketLegPolicy(format!("leg policy: {e}")))?; + crate::economic::issuance::check_market_leg_permitted(&policy) + .map_err(|e| ProvenanceError::MarketLegPolicy(e.to_string()))?; + } + Ok(()) +} + /// Verify provenance for an entire transition. /// /// Returns the funded credits in source order. Checks, beyond each source 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 1bb079eb..6d5a117e 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 @@ -331,6 +331,15 @@ impl ProvenanceResolver for OneTicket { "no evidence store in this fixture".into(), )) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } fn accepted_for(op: &Operation) -> AcceptedSubstrate { @@ -664,3 +673,271 @@ fn build_transition(operation_digest: [u8; 32]) -> (EconomicTransitionWitness, [ .expect("valid witness"); (witness, post_root) } + +// ─── The market-leg token-policy conjunct (SoFi Def 4.1 / Req 4.4 / 4.6) ──── +// +// `advance_validated` binds every DLV successor's legs to the applicable +// token policy, resolved through the VERIFIER'S OWN anchoring — these tests +// drive the full validation stack with a real `DlvFund` witness, so the +// conjunct's reachability is proven, not assumed. + +/// A resolver that roots exactly the policies the fixture installs — the +/// verifier's own anchor store, in miniature. +struct MarketRooted { + policies: std::collections::HashMap<[u8; 32], Vec>, +} + +impl ProvenanceResolver for MarketRooted { + fn validated_peer_transition( + &self, + _peer_genesis: &[u8; 32], + _peer_devid: &[u8; 32], + _peer_economic_position: u64, + ) -> Result { + Err(PeerLineageFailure::Incomplete("no peers here".into())) + } + fn winning_faucet_ticket( + &self, + _faucet_id: &[u8; 32], + _ticket_index: u64, + ) -> Option { + None + } + fn winning_settlement_slot_claim( + &self, + _vault_id: &[u8; 32], + _parent_sequence: u64, + ) -> Option { + None + } + fn immutable_evidence( + &self, + _namespace: dsm::crypto::domain::TaggedHashDomain<'static>, + _addr: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete("no evidence store".into())) + } + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + self.policies.get(policy_commit).cloned().ok_or_else(|| { + PeerLineageFailure::Incomplete( + "not rooted to this token's anchor — root, then retry".into(), + ) + }) + } +} + +/// A canonical v3 policy proto (the exact packed layout, count included), +/// varying only the transferable flag. +fn market_policy_proto(transferable: bool) -> Vec { + let mut flags = 0x01u8; // mint_burn + if transferable { + flags |= 0x02; + } + flags |= 0x08; // unlimited + let signer = vec![0xE1u8; 64]; + let mut b = vec![3u8, 0u8, flags, 1u8, 1u8]; + b.extend_from_slice(&(signer.len() as u16).to_be_bytes()); + b.extend_from_slice(&signer); + b.push(3); + b.extend_from_slice(b"TKX"); + let alias = b"Token X"; + b.extend_from_slice(&(alias.len() as u16).to_be_bytes()); + b.extend_from_slice(alias); + b.push(0); + b.extend_from_slice(&0u128.to_be_bytes()); + b.extend_from_slice(&0u128.to_be_bytes()); + b.extend_from_slice(&0u16.to_be_bytes()); + b.extend_from_slice(&0u16.to_be_bytes()); + b.push(0); + b.extend_from_slice(&0u16.to_be_bytes()); + use prost::Message; + dsm::types::proto::TokenPolicyV3 { policy_bytes: b }.encode_to_vec() +} + +/// A complete, well-formed DlvFund validation drive: two funded legs, the +/// REAL write-set builder, and a predecessor rehydrated at the pre-root, so +/// the only open question is the market-leg policy conjunct. +#[allow(clippy::type_complexity)] +fn dlv_fund_drive( + leg_x_pc: [u8; 32], + leg_y_pc: [u8; 32], + resolver: &MarketRooted, +) -> Result< + ( + dsm::economic::lineage::ValidatedEconomicRoot, + Vec, + ), + EconomicValidationError, +> { + use dsm::economic::write_set::{build_write_set, CreditSourceFacts, EconomicPreState}; + let (lo, hi) = if leg_x_pc < leg_y_pc { + (leg_x_pc, leg_y_pc) + } else { + (leg_y_pc, leg_x_pc) + }; + let mut balances = std::collections::BTreeMap::new(); + balances.insert(lo, 1_000u64); + balances.insert(hi, 1_000u64); + let mut tree = EconomicSmt::new(); + for (pc, amount) in &balances { + let leaf = + EconomicLeafState::Balance(EconomicBalanceState::new(*pc, *amount).expect("balance")); + tree.insert(leaf.leaf_key(&G, &DEV), leaf.leaf_value().expect("value")); + } + let pre_root = tree.root(); + + let op = Operation::DlvCreateFundedV2 { + vault_id: vec![0x77; 32], + creator_public_key: vec![0xE2; 64], + parameters_hash: vec![0xE3; 32], + fulfillment_condition: Vec::new(), + leg_a_policy_commit: lo, + leg_a_amount: 250, + leg_b_policy_commit: hi, + leg_b_amount: 400, + fee_bps: 30, + signature: vec![0xE4; 8], + mode: TransactionMode::Unilateral, + }; + let op_digest = dsm::economic::faucet::dsm_operation_digest(&op.to_bytes()); + let econ_op_id = dsm::economic::faucet::dsm_economic_operation_id(&G, &DEV, &C_DSM_PLUS); + let built = build_write_set( + &op, + &G, + &DEV, + &econ_op_id, + &EconomicPreState::balances_only(&balances), + &mut tree, + &CreditSourceFacts::None, + ) + .expect("the real builder builds the fund write set"); + let witness = EconomicTransitionWitness::new( + pre_root, + built.post_root, + econ_op_id, + op_digest, + built.mutations, + built.credit_sources, + ) + .expect("valid witness"); + let manifest = manifest_for(&witness); + let registered = registered_for(&manifest, 8, built.post_root); + let accepted = AcceptedSubstrate::from_verified_dsm_successor( + op, + C_DSM_PLUS, + EMBEDDED_PARENT, + SUBSTRATE_ADDR, + ); + let previous = + dsm::economic::lineage::ValidatedEconomicRoot::rehydrate_from_admitted_store(7, pre_root); + advance_validated( + &previous, + ®istered, + &manifest, + &witness, + &accepted, + resolver, + &G, + &DEV, + b"dsm-testnet", + &[0x55; 64], + ) +} + +fn tokx_pc(proto: &[u8]) -> [u8; 32] { + dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, proto) +} + +/// The honest case: one builtin leg, one rooted transferable leg — validates +/// end to end. The resolver's map holds ONLY the non-builtin policy, so this +/// simultaneously proves the builtin leg never consults the resolver. +#[test] +fn a_dlv_fund_with_a_rooted_transferable_leg_validates() { + let proto = market_policy_proto(true); + let pc = tokx_pc(&proto); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let resolver = MarketRooted { + policies: [(pc, proto)].into_iter().collect(), + }; + let (root, funded) = dlv_fund_drive(pc, era, &resolver) + .expect("a rooted transferable leg beside a builtin validates"); + assert_eq!(root.economic_position(), 8); + assert_eq!(funded.len(), 2, "both reserve credits funded by SameMove"); +} + +/// Both legs builtin (ERA + dBTC): the resolver roots NOTHING and is never +/// asked — pre-rooted by construction, the sole exceptions. +#[test] +fn builtin_legs_never_consult_the_resolver() { + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let dbtc = dsm::core::token::builtin_policy_commit_for_token("dBTC").expect("dBTC"); + let resolver = MarketRooted { + policies: std::collections::HashMap::new(), + }; + dlv_fund_drive(era, dbtc, &resolver) + .expect("a builtin pair validates with no anchoring at all"); +} + +/// THE MARKET RULE: a non-transferable asset cannot be a market leg — its +/// committed policy restricts it to mint/burn, and a DLV successor moves +/// control between parties. +/// +/// MUTATION CONTROL for the lineage conjunct: comment out the +/// `verify_market_leg_policies` call in `advance_validated` and this test +/// goes red by validating reserve encumbrance of a non-transferable asset. +#[test] +fn a_non_transferable_leg_is_refused_as_a_market_leg() { + let proto = market_policy_proto(false); + let pc = tokx_pc(&proto); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let resolver = MarketRooted { + policies: [(pc, proto)].into_iter().collect(), + }; + let err = dlv_fund_drive(pc, era, &resolver) + .expect_err("a non-transferable market leg must be refused"); + let msg = format!("{err:?}"); + assert!( + msg.contains("MarketLegPolicy") && msg.contains("non-transferable"), + "the refusal names the market rule, got: {msg}" + ); +} + +/// An unrooted leg fails CLOSED as `Incomplete` — an availability condition +/// (root, then retry), never a validity verdict. +#[test] +fn an_unrooted_market_leg_fails_closed_as_incomplete() { + let proto = market_policy_proto(true); + let pc = tokx_pc(&proto); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let resolver = MarketRooted { + policies: std::collections::HashMap::new(), + }; + let err = dlv_fund_drive(pc, era, &resolver).expect_err("unrooted leg fails closed"); + let msg = format!("{err:?}"); + assert!( + msg.contains("Incomplete") && msg.contains("not rooted"), + "the refusal is retryable unavailability, got: {msg}" + ); +} + +/// Bytes that do not re-hash to the committed leg are refused: the resolver +/// locates, it never authorizes. +#[test] +fn policy_bytes_that_do_not_hash_to_the_leg_are_refused() { + let proto = market_policy_proto(true); + let pc = tokx_pc(&proto); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let wrong = market_policy_proto(false); // valid bytes, wrong commit + let resolver = MarketRooted { + policies: [(pc, wrong)].into_iter().collect(), + }; + let err = dlv_fund_drive(pc, era, &resolver).expect_err("mismatched bytes refused"); + let msg = format!("{err:?}"); + assert!( + msg.contains("do not hash to the committed leg"), + "the refusal is the hash binding, got: {msg}" + ); +} 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 847e0eeb..f1f4d6e3 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 @@ -143,6 +143,15 @@ impl ProvenanceResolver for IssuanceResolver { Err(PeerLineageFailure::Incomplete("unknown address".into())) } } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } /// One honest issuance: 2-of-3 over 1_000 units of an uncapped policy. diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_owner_apply_provenance.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_owner_apply_provenance.rs index 155e378b..75ec7500 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_owner_apply_provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_owner_apply_provenance.rs @@ -252,6 +252,15 @@ impl ProvenanceResolver for ApplyResolver { Err(PeerLineageFailure::Incomplete("unknown address".into())) } } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } fn resolver_for(fx: &Fixture) -> ApplyResolver { @@ -493,6 +502,15 @@ fn an_unresolvable_trader_lineage_fails_closed() { ) -> Result, PeerLineageFailure> { Err(PeerLineageFailure::Incomplete("outage".into())) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } match verify_transition_provenance(&fx.witness, &Nothing, &ctx) { Err(ProvenanceError::OwnerLineage(PeerLineageFailure::Incomplete(_))) => {} diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_settle_provenance.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_settle_provenance.rs index 2670298a..ca65cb8d 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_settle_provenance.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_dlv_settle_provenance.rs @@ -484,6 +484,15 @@ impl ProvenanceResolver for SettleResolver { Err(PeerLineageFailure::Incomplete("unknown address".into())) } } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } fn resolver_for(fx: &Fixture) -> SettleResolver { diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_peer_evidence.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_peer_evidence.rs index 3790b785..fed396a1 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_peer_evidence.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_peer_evidence.rs @@ -388,6 +388,15 @@ impl ProvenanceResolver for OnePeer { ) -> Result, PeerLineageFailure> { Err(PeerLineageFailure::Incomplete("no evidence store".into())) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } /// A peer VPT whose witness has one exact debit, with `verified_operation` @@ -595,6 +604,15 @@ fn the_addr_checked_acceptance_bytes_must_hash_to_the_descriptor_address() { ) -> Result, PeerLineageFailure> { Ok(vec![0xEE; 64]) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } let resolver = WrongBytes { vpt: peer_vpt(transfer_to(DEV_RECIP, 40), 40), diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs index e51f75ac..4cb6d3df 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_provenance_semantics.rs @@ -64,6 +64,15 @@ impl ProvenanceResolver for NoPeers { "no evidence store in this fixture".into(), )) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } const NETWORK: &[u8] = b"dsm-testnet"; diff --git a/dsm_client/deterministic_state_machine/dsm/tests/era_faucet_wire.rs b/dsm_client/deterministic_state_machine/dsm/tests/era_faucet_wire.rs index b837fc88..5305dacd 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/era_faucet_wire.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/era_faucet_wire.rs @@ -155,6 +155,15 @@ impl ProvenanceResolver for OneTicket { "no evidence store in this fixture".into(), )) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } fn ctx<'a>(position: u64, ak: &'a [u8]) -> ProvenanceContext<'a> { @@ -378,6 +387,15 @@ fn no_quorum_winner_fails_closed_and_out_of_range_is_refused() { "no evidence store in this fixture".into(), )) } + + fn anchored_policy_bytes( + &self, + _policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + Err(PeerLineageFailure::Incomplete( + "this fixture roots no token anchors".into(), + )) + } } assert!(matches!( verify_credit_source( 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 bd9327bd..d3d3ee16 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 @@ -38,6 +38,62 @@ fn display_name_for(policy_commit: &[u8; 32]) -> String { } } +/// THE MARKET-LEG TOKEN-POLICY PRE-FLIGHT (SoFi Def 4.1 / Req 4.4 / Req 4.6). +/// +/// A non-builtin leg requires this device's OWN rooting in the token's public +/// anchor: locally anchored bytes, or a fetch from the authoritative +/// content-addressed path — re-hashed against the commit before anything +/// trusts a byte, then persisted (anchors are public; anyone holding one may +/// root). The parsed policy must then permit the asset as a market leg — +/// today that means `transferable`, the one movement-relevant commitment a +/// token policy carries. ERA and dBTC are pre-rooted on every device and +/// skip. +/// +/// This is the ROUTE layer of a three-layer rule: the advance funnel enforces +/// the same matrix from local rooting for every caller, and the economic +/// verifier reruns it foreign-verifiably in `advance_validated`. Only this +/// layer may touch the network, which is why the fetch lives here. +async fn require_rooted_market_leg(route: &str, pc: &[u8; 32]) -> Result<(), String> { + if dsm::core::token::token_state_manager::builtin_token_id_for_policy_commit(pc).is_some() { + return Ok(()); + } + let bytes = match crate::storage::client_db::token_registry::load_policy_verified(pc) { + Ok(Some(b)) => b, + _ => { + let fetched = crate::handlers::token_routes::try_fetch_policy_from_network(pc) + .await + .map_err(|e| { + format!( + "{route}: policy fetch failed for market leg {}: {e}", + crate::util::text_id::encode_base32_crockford(pc) + ) + })?; + let Some(b) = fetched else { + return Err(format!( + "{route}: market leg {} is not rooted and its policy is not retrievable \ + from the anchor path — root this device to the token, then retry", + crate::util::text_id::encode_base32_crockford(pc) + )); + }; + if dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, &b) + != *pc + { + return Err(format!( + "{route}: fetched policy bytes do not hash to market leg {} — refusing", + crate::util::text_id::encode_base32_crockford(pc) + )); + } + let _ = crate::storage::client_db::token_registry::upsert_policy(pc, &b); + b + } + }; + let policy = dsm::economic::issuance::parse_issuance_policy(&bytes) + .map_err(|e| format!("{route}: market leg policy: {e}"))?; + dsm::economic::issuance::check_market_leg_permitted(&policy) + .map_err(|e| format!("{route}: {e}"))?; + Ok(()) +} + impl AppRouterImpl { /// Dispatch handler for `dlv.*` query (read-only) routes. pub(crate) async fn handle_dlv_query(&self, q: crate::bridge::AppQuery) -> AppResult { @@ -546,6 +602,16 @@ impl AppRouterImpl { } } + // Both legs must satisfy the applicable token policy BEFORE anything + // durable — and a leg must be a real, rooted asset at all: until this + // gate, any 32 random bytes with a balance under them was an + // acceptable funding leg. + for (pc, _) in &funding { + if let Err(e) = require_rooted_market_leg("dlv.create", pc).await { + return err(e); + } + } + // Accept-or-sign (Track C.4) — when the trader-supplied signature // was empty, sign the draft's `parameters_hash` with the wallet's // SPHINCS+ secret key. `parameters_hash` is the same value @@ -1593,6 +1659,14 @@ impl AppRouterImpl { )) } }; + // The fold moves reserve value under both pair assets; each must + // satisfy the applicable token policy before the apply is derived. + for pc in [pair.a(), pair.b()] { + if let Err(e) = require_rooted_market_leg("dlv.reconcile", &pc).await { + return err(e); + } + } + let mutation = dsm::types::device_state::VaultReserveMutation::ApplySettlement { vault_id, input_policy_commit: receipt.trade.input_policy_commit, @@ -2259,6 +2333,15 @@ impl AppRouterImpl { // close's identity for this generation. let close_commitment = x_close; + // The release must satisfy the applicable token policy (Req 4.6 / + // Req 21.14): both legs checked before anything is signed. The owner + // rooted both at creation; this fails closed rather than assuming. + for pc in [pair.a(), pair.b()] { + if let Err(e) = require_rooted_market_leg("dlv.close", &pc).await { + return err(e); + } + } + // The canonical operation: derived, then signed. let op = dsm::types::operations::Operation::DlvClose { vault_id: vault_id.to_vec(), @@ -2729,6 +2812,17 @@ impl AppRouterImpl { return err("dlv.unlockRouted: routed settlement requires a verified AMM hop".into()); }; + // Both traded assets must satisfy the applicable token policy, and + // the TRADER must be rooted in their public anchors — before the + // first-writer claim, where a refusal still costs nothing. A trader + // may root here for the first time: adoption is open to anyone + // holding the commit, and the hop just authenticated both commits. + for pc in [&settle.input_policy_commit, &settle.output_policy_commit] { + if let Err(e) = require_rooted_market_leg("dlv.unlockRouted", pc).await { + return err(e); + } + } + // FIRST-WRITER CLAIM, immediately before the advance. Everything after // this moves value; everything before it is reversible by stopping. A // contested slot means another trade already holds this parent sequence, @@ -6388,7 +6482,11 @@ mod funded_creation_tests { /// the new one must round-trip byte-for-byte. #[test] fn the_additive_display_fields_are_wire_compatible_in_both_directions() { - let (pc_a, pc_b) = crate::sdk::funded_vault_fixture::pair_commits(); + // Fixed byte patterns, NOT the rooted fixture pair: this test's + // subject is the WIRE (the naive tag scan below reads raw bytes, and + // a real commit can legitimately contain 0x8A/0x92 in its hash). No + // route or policy is involved here. + let (pc_a, pc_b) = ([0xA1u8; 32], [0xB2u8; 32]); // OLD -> NEW: a producer that never heard of tags 17/18. Encoding a summary with // the fields empty is byte-identical to what the pre-change encoder emitted, 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 d5a7cfa9..f13aa791 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 @@ -1045,3 +1045,92 @@ async fn a_failed_finish_holds_the_mint_and_resume_completes_the_same_admission( "resume used the SAME evidence bytes — nothing was re-signed" ); } + +/// THE FUNNEL IS THE GATE, NOT THE ROUTE: a DLV advance driven DIRECTLY +/// through the state-machine funnel — no route, no pre-flight — still refuses +/// a non-transferable market leg. This is the every-caller property the mint +/// gate taught: a route guard alone leaves any future caller free to reopen +/// the hole. +/// +/// MUTATION CONTROL: comment out the `enforce_market_leg_policies_local` call +/// in `execute_on_relationship_inner` and this goes red — the refusal +/// (if any) stops naming the market rule. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial] +async fn a_direct_dlv_advance_with_a_non_transferable_leg_is_refused_at_the_funnel() { + let (core, _fleet) = crate::handlers::faucet_flow_tests_support::setup(0xE6); + + // Root a NON-transferable policy — the funnel reads local rooting only. + let proto = { + use prost::Message; + let packed = crate::handlers::token_routes::build_policy_v3_bytes( + &crate::handlers::token_routes::ParsedTokenPolicy { + ticker: "NTFR".into(), + alias: "No Transfer".into(), + decimals: 0, + max_supply: 0, + initial_alloc: 0, + description: Option::None, + icon_url: Option::None, + mint_burn_enabled: true, + transferable: false, + unlimited_supply: true, + mint_burn_threshold: 1, + signers: vec![vec![0xE1; 64]], + allowlist_device_ids: Vec::new(), + }, + ) + .expect("pack"); + crate::generated::TokenPolicyV3 { + policy_bytes: packed, + } + .encode_to_vec() + }; + let pc = + dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, &proto); + client_db::token_registry::upsert_policy(&pc, &proto).expect("root"); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let (lo, hi) = if era < pc { (era, pc) } else { (pc, era) }; + + let dev = core.device_head().expect("head").devid(); + let rel_key = dsm::core::bilateral_transaction_manager::compute_smt_key(&dev, &dev); + let tip = + dsm::core::bilateral_transaction_manager::initial_chain_tip_from_device_ids(&dev, &dev); + let op = core + .sign_operation_sphincs(dsm::types::operations::Operation::DlvCreateFundedV2 { + vault_id: vec![0x77; 32], + creator_public_key: vec![0xE2; 64], + parameters_hash: vec![0xE3; 32], + fulfillment_condition: Vec::new(), + leg_a_policy_commit: lo, + leg_a_amount: 100, + leg_b_policy_commit: hi, + leg_b_amount: 100, + fee_bps: 30, + signature: Vec::new(), + mode: dsm::types::operations::TransactionMode::Unilateral, + }) + .expect("sign"); + let refused = core.execute_on_relationship_with_reserve_mutation( + rel_key, + dev, + op, + &[], + Some(tip), + Some(dsm::types::device_state::VaultReserveMutation::Fund { + vault_id: [0x77; 32], + legs: vec![(lo, 100), (hi, 100)], + vault_sequence: 0, + pair: dsm::types::device_state::VaultStatePair::new(lo, hi, 30).expect("pair"), + }), + None, + ); + let msg = refused + .err() + .map(|e| e.to_string()) + .expect("the funnel must refuse a non-transferable market leg"); + assert!( + msg.contains("non-transferable") && msg.contains("market leg"), + "the funnel refusal names the market rule, got: {msg}" + ); +} 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 477c8cae..a432e5cb 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 @@ -435,7 +435,9 @@ async fn try_publish_policy_to_network(body: &[u8], expected_anchor: &[u8; 32]) publish_policy_to_network(body, expected_anchor).await == PublishOutcome::Published } -async fn try_fetch_policy_from_network(anchor: &[u8; 32]) -> Result>, String> { +pub(crate) async fn try_fetch_policy_from_network( + anchor: &[u8; 32], +) -> Result>, String> { let urls = match crate::sdk::storage_node_sdk::StorageNodeConfig::from_env_config().await { Ok(cfg) => cfg.node_urls, Err(e) => { 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 b2bc10da..0d693b3a 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 @@ -1328,6 +1328,49 @@ impl CoreSDK { Some(u64::try_from(circulating).unwrap_or(u64::MAX)) } + /// Refuse a DLV value operation whose non-builtin legs are not locally + /// rooted, do not re-hash to their commits, do not parse, or whose + /// committed policy refuses the asset as a market leg (SoFi Def 4.1 / + /// Req 4.4 / Req 4.6 — the "applicable token policy" conjunct). ERA and + /// dBTC are pre-rooted on every device and skip. Non-DLV operations pass + /// vacuously; their policy conjuncts run in `enforce_policy_for_operation`. + fn enforce_market_leg_policies_local( + operation: &dsm::types::operations::Operation, + ) -> Result<(), DsmError> { + for pc in dsm::economic::provenance::market_leg_commits(operation) { + if dsm::core::token::token_state_manager::builtin_token_id_for_policy_commit(&pc) + .is_some() + { + continue; + } + let Ok(Some(bytes)) = + crate::storage::client_db::token_registry::load_policy_verified(&pc) + else { + return Err(DsmError::policy_violation( + crate::util::text_id::encode_base32_crockford(&pc), + "market leg is not rooted on this device — root to the token's public \ + anchor, then retry", + None::, + )); + }; + let policy = dsm::economic::issuance::parse_issuance_policy(&bytes).map_err(|e| { + DsmError::policy_violation( + crate::util::text_id::encode_base32_crockford(&pc), + format!("market leg policy: {e}"), + None::, + ) + })?; + dsm::economic::issuance::check_market_leg_permitted(&policy).map_err(|e| { + DsmError::policy_violation( + crate::util::text_id::encode_base32_crockford(&pc), + e.to_string(), + None::, + ) + })?; + } + Ok(()) + } + fn enforce_policy_for_operation( &self, operation: &dsm::types::operations::Operation, @@ -1850,6 +1893,14 @@ impl CoreSDK { // execution path skipped policy checks. let current_state_hash = sm.device_head().map(|ds| ds.root()).unwrap_or([0u8; 32]); self.enforce_policy_for_operation(&operation, current_state_hash)?; + // The market-leg token-policy gate for DLV value operations — the + // funnel-level twin of the core verifier's conjunct, so EVERY caller + // is covered, not just the routes that pre-flight. Local rooting + // only: an owner or trader legitimately moving an asset is already + // rooted to its anchor (the routes fetch-and-root on first contact); + // an unrooted leg fails closed here rather than advancing state the + // economic verifier will refuse. + Self::enforce_market_leg_policies_local(&operation)?; // ── Admission serialization, UNDER the state-machine lock ────────── // A new admission atomically refuses an existing pending one and diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs index 8e9f4baf..2d81261d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_registers.rs @@ -445,6 +445,13 @@ impl dsm::economic::peer_lineage::PeerEvidenceFetcher for LiveRegisterResolver<' ) -> Result, PeerLineageFailure> { self.fetch_bytes(namespace, addr) } + + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + anchored_policy_bytes_local_or_network(policy_commit, &self.runtime) + } } /// The cache-aware walk shared by every fetcher-shaped resolver: cached @@ -628,6 +635,19 @@ impl dsm::economic::peer_lineage::PeerEvidenceFetcher for RecordingResolver<'_> .push((namespace, *addr, bytes.clone())); Ok(bytes) } + + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + // NOT recorded: policy bytes are the VERIFIER'S OWN rooting in a + // public anchor, re-fetchable by anyone holding the commit — they are + // not part of the peer's evidence closure and owe no q-durability. + dsm::economic::peer_lineage::PeerEvidenceFetcher::anchored_policy_bytes( + self.inner, + policy_commit, + ) + } } impl ProvenanceResolver for LiveRegisterResolver<'_> { @@ -689,6 +709,54 @@ impl ProvenanceResolver for LiveRegisterResolver<'_> { ) -> Result, PeerLineageFailure> { self.fetch_bytes(namespace, addr) } + + fn anchored_policy_bytes( + &self, + policy_commit: &[u8; 32], + ) -> Result, PeerLineageFailure> { + anchored_policy_bytes_local_or_network(policy_commit, &self.runtime) + } +} + +/// The verifier's OWN rooting in a token's public anchor: local anchored +/// bytes first, else a fetch from the authoritative content-addressed path, +/// re-hashed against the commit before anything trusts a byte (the network +/// is a locator, never authority). Successfully fetched bytes are persisted +/// — anchors are public, and anyone holding one may root to the token — so +/// the rooting is one-time per device. Unavailable is `Incomplete`: an +/// availability condition, never a permission. +pub(crate) fn anchored_policy_bytes_local_or_network( + policy_commit: &[u8; 32], + runtime: &tokio::runtime::Handle, +) -> Result, PeerLineageFailure> { + if let Ok(Some(bytes)) = + crate::storage::client_db::token_registry::load_policy_verified(policy_commit) + { + return Ok(bytes); + } + let pc = *policy_commit; + let fetched = tokio::task::block_in_place(|| { + runtime.block_on(crate::handlers::token_routes::try_fetch_policy_from_network(&pc)) + }) + .map_err(PeerLineageFailure::Incomplete)?; + let Some(bytes) = fetched else { + return Err(PeerLineageFailure::Incomplete( + "anchored policy bytes unavailable — root this device to the token's public \ + anchor, then retry" + .into(), + )); + }; + if dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, &bytes) + != pc + { + return Err(PeerLineageFailure::Incomplete( + "fetched policy bytes do not hash to the anchor — treating as unavailable".into(), + )); + } + // Root durably (best-effort): the bytes verified against the public + // anchor this device already holds. + let _ = crate::storage::client_db::token_registry::upsert_policy(&pc, &bytes); + Ok(bytes) } /// Base32 path helpers shared by live and fake paths. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/funded_vault_fixture.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/funded_vault_fixture.rs index 2810b996..5fbb05e3 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/funded_vault_fixture.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/funded_vault_fixture.rs @@ -81,10 +81,52 @@ pub(crate) fn token_pair() -> (Vec, Vec) { (b"AAA".to_vec(), b"BBB".to_vec()) } -/// Policy commits for that pair. Distinct, deterministic, and deliberately NOT -/// derived from the ticker bytes — a ticker is not an identity. +/// Policy commits for that pair: REAL anchors of real transferable policies, +/// rooted on this device — because a market leg now requires exactly that. +/// The old fixture used two arbitrary byte strings, which is the precise hole +/// the market-leg gate closed; a fixture that kept them would be testing +/// against assets that cannot exist. Distinct, deterministic (the protos are +/// fixed), returned in canonical (lex) order, and best-effort rooted on every +/// call so any test that reaches `dlv.create` finds the device anchored. pub(crate) fn pair_commits() -> ([u8; 32], [u8; 32]) { - ([0xA1u8; 32], [0xB2u8; 32]) + fn rooted_transferable(ticker: &str) -> [u8; 32] { + use prost::Message; + let packed = crate::handlers::token_routes::build_policy_v3_bytes( + &crate::handlers::token_routes::ParsedTokenPolicy { + ticker: ticker.into(), + alias: format!("{ticker} Test Asset"), + decimals: 0, + max_supply: 0, + initial_alloc: 0, + description: None, + icon_url: None, + mint_burn_enabled: true, + transferable: true, + unlimited_supply: true, + mint_burn_threshold: 1, + signers: vec![vec![0xE1; 64]], + allowlist_device_ids: Vec::new(), + }, + ) + .expect("fixture policy packs"); + let proto = crate::generated::TokenPolicyV3 { + policy_bytes: packed, + } + .encode_to_vec(); + let pc = dsm::crypto::blake3::domain_hash_bytes( + dsm::common::domain_tags::TAG_DSM_POLICY, + &proto, + ); + let _ = crate::storage::client_db::token_registry::upsert_policy(&pc, &proto); + pc + } + let x = rooted_transferable("AAA"); + let y = rooted_transferable("BBB"); + if x < y { + (x, y) + } else { + (y, x) + } } /// A device holding `a` / `b` base units of the pair and nothing encumbered. diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/tests/vault_funding_routes.rs b/dsm_client/deterministic_state_machine/dsm_sdk/tests/vault_funding_routes.rs index a4473a8f..55eca0ca 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/tests/vault_funding_routes.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/tests/vault_funding_routes.rs @@ -573,3 +573,129 @@ fn is_unknown_route(msg: &str) -> bool { m.contains("unknown") && (m.contains("invoke") || m.contains("query path") || m.contains("route")) } + +// ─── The market-leg token-policy gate at the route ────────────────────────── + +/// A canonical v3 policy proto (packed layout, allowlist count included), +/// varying only the transferable flag. +fn market_policy_proto(transferable: bool) -> Vec { + let mut flags = 0x01u8; + if transferable { + flags |= 0x02; + } + flags |= 0x08; + let signer = vec![0xE1u8; 64]; + let mut b = vec![3u8, 0u8, flags, 1u8, 1u8]; + b.extend_from_slice(&(signer.len() as u16).to_be_bytes()); + b.extend_from_slice(&signer); + b.push(3); + b.extend_from_slice(b"TKX"); + let alias = b"Token X"; + b.extend_from_slice(&(alias.len() as u16).to_be_bytes()); + b.extend_from_slice(alias); + b.push(0); + b.extend_from_slice(&0u128.to_be_bytes()); + b.extend_from_slice(&0u128.to_be_bytes()); + b.extend_from_slice(&0u16.to_be_bytes()); + b.extend_from_slice(&0u16.to_be_bytes()); + b.push(0); + b.extend_from_slice(&0u16.to_be_bytes()); + generated::TokenPolicyV3 { policy_bytes: b }.encode_to_vec() +} + +fn create_req_for_pair(a: &[u8; 32], b: &[u8; 32], fee_bps: u32) -> Vec { + let spec = generated::DlvSpecV1 { + policy_digest: vec![0x11u8; 32], + fulfillment_bytes: amm_fulfillment_bytes(a, b, fee_bps), + ..Default::default() + }; + generated::DlvInstantiateV1 { + spec: Some(spec), + creator_public_key: vec![0xABu8; 64], + signature: Vec::new(), + funding_legs: vec![ + generated::DlvFundingLegV1 { + policy_commit: a.to_vec(), + amount: 1_000, + }, + generated::DlvFundingLegV1 { + policy_commit: b.to_vec(), + amount: 1_000, + }, + ], + } + .encode_to_vec() +} + +/// THE HOLE THIS GATE CLOSES: any 32 random bytes with a balance under them +/// used to be an acceptable funding leg. A leg this device is not rooted to +/// is now refused by name, before any balance is touched. +#[test] +#[serial_test::serial] +fn an_unrooted_market_leg_is_refused_at_creation() { + runtime::dsm_init_runtime(); + init_test_storage(); + let r = new_router(); + let phantom = [0x44u8; 32]; + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let res = invoke( + &r, + "dlv.create", + pack(create_req_for_pair(&era, &phantom, 30)), + ); + assert!(!res.success, "an unrooted leg must not create a vault"); + let msg = res.error_message.unwrap_or_default(); + assert!( + msg.contains("not rooted"), + "the refusal names the missing rooting, got: {msg}" + ); +} + +/// THE MARKET RULE AT THE ROUTE: a rooted but NON-TRANSFERABLE asset is +/// refused as a leg — its committed policy restricts it to mint/burn. +#[test] +#[serial_test::serial] +fn a_non_transferable_leg_is_refused_at_creation() { + runtime::dsm_init_runtime(); + init_test_storage(); + let r = new_router(); + let proto = market_policy_proto(false); + let pc = + dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, &proto); + dsm_sdk::storage::client_db::token_registry::upsert_policy(&pc, &proto).expect("root"); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let res = invoke(&r, "dlv.create", pack(create_req_for_pair(&era, &pc, 30))); + assert!( + !res.success, + "a non-transferable leg must not create a vault" + ); + let msg = res.error_message.unwrap_or_default(); + assert!( + msg.contains("non-transferable") && msg.contains("market leg"), + "the refusal names the market rule, got: {msg}" + ); +} + +/// POSITIVE CONTROL: the same shape with a rooted TRANSFERABLE leg gets past +/// the policy gate — its refusal (this fixture funds nothing) is the balance +/// check, proving the two refusals above are the policy rules and not a +/// broken request. +#[test] +#[serial_test::serial] +fn a_rooted_transferable_leg_passes_the_policy_gate() { + runtime::dsm_init_runtime(); + init_test_storage(); + let r = new_router(); + let proto = market_policy_proto(true); + let pc = + dsm::crypto::blake3::domain_hash_bytes(dsm::common::domain_tags::TAG_DSM_POLICY, &proto); + dsm_sdk::storage::client_db::token_registry::upsert_policy(&pc, &proto).expect("root"); + let era = dsm::core::token::builtin_policy_commit_for_token("ERA").expect("ERA"); + let res = invoke(&r, "dlv.create", pack(create_req_for_pair(&era, &pc, 30))); + assert!(!res.success, "unfunded fixture cannot actually create"); + let msg = res.error_message.unwrap_or_default(); + assert!( + !msg.contains("not rooted") && !msg.contains("market leg"), + "a rooted transferable leg must pass the policy gate, got: {msg}" + ); +}