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}" + ); +}