From 884cef68349faace95bdd3b3147fc2463eac91d1 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:26:56 -0400 Subject: [PATCH] fix(sofi): a vault creation cannot contradict itself (F9.x) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SofiVaultCreate` already planned two real balance debits against the admitted `R_econ` and bound the funded AMOUNTS to the genesis reserves, on the owner's path and on foreign walks alike. Five other facts it stated were taken on the caller's word. The sharpest was the funding ASSETS. `funding_a/b_policy_commit` went straight into `pair_legs`, which checks only canonical order and non-zero, and `write_set.rs` named `market_policy` nowhere. So a creation could debit X and Y while declaring a market in A and B — and a later close credits the owner the MARKET's pair, which it never funded. Fund with junk, close for real assets. That binding had existed, in `genesis_accepted`: if funding.pair() != (token_a, token_b) { return Err(GenesisError::FundingIsNotTheMarketPair); } #955 deleted that function correctly — it could not derive provenance and was never callable — but the deletion took a working check with it. Only the KEPT error variants were checked for remaining references, and those references were the SDK producer. Deleting an architectural dead end without relocating the valid invariant it happened to enforce is the defect, not the deletion. Core now refuses unless all of it holds, before any debit is planned: the three preimages strictly decode; the owner coordinates are the authenticated local ones; `create_position` is the position the operation actually lands at; the record names the vault the preimage derives; `genesis_root` is recomputed rather than accepted; the funded amounts are the genesis reserves; the state's owner coordinates equal the preimage's; the carried policy re-addresses to what the state commits; and the decoded pair is the pair being funded. The operation carries `market_policy_preimage`, so acceptance is a function of the operation's bytes and the authenticated pre-state alone — no resolver, no storage availability, no foreign-walk liveness between a local decision and its answer. 72 fixed-width bytes against the 49,856-byte signature already there. Coverage is automatic: `operation_signing_bytes` is the whole canonical encoding with the signature cleared, so one line in the encode arm is necessary and sufficient — and a test proves it by altering only that field after signing. No decode-and-re-encode check. `MarketPolicy` is 72 bytes with no optional position, its decoder pins the envelope and the beta family before reconstruction, enforces `a < b` and refuses trailing bytes, so `encode(decode(P)) == P` always and the assertion would have no reachable rejection case. ADDRESS EQUALITY AND PAIR EQUALITY ARE SEPARATE BINDINGS. One proves the carried bytes are the policy object the state named; the other proves the assets debited are the two that policy authorizes. Their mutations red disjoint test sets. The creation fixture now takes the MARKET pair separately from the FUNDING pair. A fixture that conflated them could not express the defect, which is why seven existing tests passed over it. --- .../dsm/src/economic/lineage.rs | 1 + .../dsm/src/economic/write_set.rs | 403 +++++++++++++++++- .../dsm/src/sofi/signature.rs | 42 ++ .../dsm/src/types/operations.rs | 37 +- .../dsm/tests/economic_admission_lifecycle.rs | 2 +- .../dsm/tests/economic_authorized_issuance.rs | 4 +- .../dsm/tests/economic_write_set.rs | 139 ++++-- .../dsm/tests/sofi_v8_operations.rs | 17 +- .../src/sdk/economic_admission_flow.rs | 18 +- .../dsm_sdk/src/sdk/sofi_sdk.rs | 4 + 10 files changed, 613 insertions(+), 54 deletions(-) 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 ab50f8c85..5b8b94798 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/lineage.rs @@ -668,6 +668,7 @@ pub fn advance_validated( genesis, device_id, witness, + registered.economic_position(), ) .map_err(EconomicValidationError::WriteSet)?; } diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs index cf652e6b6..9272f875e 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/write_set.rs @@ -215,11 +215,19 @@ pub enum CreditSourceFacts { /// Merkle verification. pub struct EconomicPreState<'a> { pub balances: &'a BTreeMap<[u8; 32], u64>, + /// The position the operation being built LANDS AT — the successor of the + /// admitted predecessor this pre-state came from. It travels with the + /// pre-state because it is the same fact: a pre-state at `p` can only be + /// the pre-state of the transition at `p + 1`. + pub economic_position: u64, } impl<'a> EconomicPreState<'a> { - pub fn new(balances: &'a BTreeMap<[u8; 32], u64>) -> Self { - Self { balances } + pub fn new(balances: &'a BTreeMap<[u8; 32], u64>, economic_position: u64) -> Self { + Self { + balances, + economic_position, + } } } @@ -326,6 +334,7 @@ fn semantic_write_set( operation: &Operation, local_genesis: &[u8; 32], local_devid: &[u8; 32], + economic_position: u64, ) -> Result { match operation { Operation::Transfer { @@ -452,6 +461,7 @@ fn semantic_write_set( Operation::SofiVaultCreate { genesis_preimage, creation, + market_policy_preimage, funding_a_policy_commit, funding_b_policy_commit, .. @@ -491,6 +501,84 @@ fn semantic_write_set( detail: "the funded amounts are not the genesis reserves", }); } + // THE PREIMAGE AGREES WITH ITSELF. `vault_id` derives from the + // OUTER owner coordinates, and the close path re-derives it from + // the INNER ones (`sofi::validation`). Two spellings of the same + // fact that nothing required to agree is a fork in who owns the + // vault, so they are held equal here rather than at one of the + // two readers. + if preimage.state.owner_genesis != preimage.owner_genesis + || preimage.state.owner_device_id != preimage.owner_device_id + || preimage.state.create_position != preimage.create_position + { + return Err(WriteSetError::MalformedVaultOperation { + detail: "the genesis state names different owner coordinates than the \ + preimage it sits in", + }); + } + // `p_create` IS THE POSITION THIS OPERATION LANDS AT. It is not a + // coordinate the caller may choose: `vault_id` derives from it, so + // a creation free to name any position could mint a second vault + // id from one transition, and the close path would then derive an + // owner for a vault the lineage never created at that position. + if preimage.create_position != economic_position { + return Err(WriteSetError::MalformedVaultOperation { + detail: "the creation names a position other than the one it lands at", + }); + } + // `R_0` IS DERIVED, NEVER ACCEPTED. The creation record states a + // genesis root; recomputing it from the canonical state is the + // only thing that makes it a fact rather than the caller's + // assertion, and every input is already in hand. + let derived_root = crate::sofi::lineage::genesis_root(&vault_id, &preimage.state) + .map_err(|_| WriteSetError::MalformedVaultOperation { + detail: "a genesis state that does not encode has no root", + })?; + if record.genesis_root != derived_root { + return Err(WriteSetError::MalformedVaultOperation { + detail: "the creation record states a genesis root the state does not derive", + }); + } + // THE POLICY BYTES ARE THE ONES THE STATE NAMED. Re-address them + // under the market-policy namespace and require the address the + // vault state commits. This is checked BEFORE anything is read out + // of them: bytes that do not authenticate to what was asked for + // establish nothing, so they are never decoded on their own word. + let derived_addr = crate::ccb::decode::policy_object_address( + crate::ccb::class::MARKET_POLICY, + market_policy_preimage, + ) + .ok_or(WriteSetError::MalformedVaultOperation { + detail: "the market policy class has no addressing rule", + })?; + if derived_addr != preimage.state.market_policy { + return Err(WriteSetError::MalformedVaultOperation { + detail: "the carried market policy is not the one the genesis state commits", + }); + } + // Strict decode. 72 fixed-width bytes, a pinned envelope, a pinned + // beta family and version, a strictly ordered pair and no trailing + // byte — so an accepted policy has exactly one encoding and the + // address above identifies it uniquely. + let market = + crate::ccb::decode::decode_market_policy(market_policy_preimage).map_err(|_| { + WriteSetError::MalformedVaultOperation { + detail: "a market policy that is not canonical authorizes no pair", + } + })?; + // THE FUNDING IS THE AUTHORIZED PAIR. A SEPARATE binding from the + // address: that one proves these are the named policy's bytes, + // this one proves the assets actually debited are the two that + // policy authorizes. Without it a creation may debit X and Y while + // declaring a market in A and B — and a later close credits the + // owner A and B, which it never funded. + if *funding_a_policy_commit != *market.token_a() + || *funding_b_policy_commit != *market.token_b() + { + return Err(WriteSetError::MalformedVaultOperation { + detail: "the funded assets are not the pair the market policy authorizes", + }); + } let (vault_id, leg_a, leg_b) = pair_legs( &vault_id, (*funding_a_policy_commit, record.amount_a), @@ -542,7 +630,8 @@ pub fn build_write_set( facts: &CreditSourceFacts, ) -> Result { let pre_balances = pre_state.balances; - let semantic = semantic_write_set(operation, genesis, device_id)?; + let economic_position = pre_state.economic_position; + let semantic = semantic_write_set(operation, genesis, device_id, economic_position)?; let mut planned: Vec = Vec::new(); @@ -831,6 +920,7 @@ pub fn verify_operation_write_set( genesis: &[u8; 32], device_id: &[u8; 32], witness: &EconomicTransitionWitness, + economic_position: u64, ) -> Result<(), WriteSetError> { // THE TRIPWIRE, ON THE REAL PATH AND BEFORE ANYTHING ELSE. // @@ -850,7 +940,7 @@ pub fn verify_operation_write_set( ) .map_err(WriteSetError::Tripwire)?; - let semantic = semantic_write_set(operation, genesis, device_id)?; + let semantic = semantic_write_set(operation, genesis, device_id, economic_position)?; // Classify every mutation. The legal leaf classes are VARIANT-DRIVEN, and // EACH SOFI LEAF IS LEGAL FOR EXACTLY ONE OPERATION. The closure below @@ -1188,6 +1278,7 @@ mod sofi_refusal_tests { Operation::SofiVaultCreate { genesis_preimage: vec![0x5A, 0x00], creation: vec![0x5B, 0x00], + market_policy_preimage: vec![0x00, 0x07], funding_a_policy_commit: [0x5C; 32], funding_b_policy_commit: [0x5D; 32], signature: vec![0xA1; 8], @@ -1219,7 +1310,7 @@ mod sofi_refusal_tests { EconomicEffect::ClosedWriteSet, "{name}: it does move value under a closed write set" ); - match (&op, semantic_write_set(&op, &[0x11; 32], &[0x22; 32])) { + match (&op, semantic_write_set(&op, &[0x11; 32], &[0x22; 32], 0)) { (Operation::SofiFulfill { .. }, Err(e)) => assert_eq!( e, WriteSetError::SofiWriteSetBelongsToTheResolvedPath, @@ -1252,3 +1343,305 @@ mod sofi_refusal_tests { ); } } + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal +mod vault_create_binding_tests { + //! THE FIRST BEHAVIOURAL COVERAGE OF `SofiVaultCreate`. + //! + //! Before this module the arm had none. The only `SofiVaultCreate` any test + //! built carried bytes that do not decode, so it exercised classification + //! and never reached a single binding — which is how several of them came to + //! be enforced in the SDK producer alone, or not at all. + //! + //! Each test removes exactly one thing from a valid creation and names the + //! rule that refuses it. + use super::*; + use crate::ccb::state::{FeePolicy, MarketPolicy, ReleasePolicy}; + use crate::sofi::wire::{VaultCreation, VaultGenesisPreimage, VaultStateLeaf}; + use crate::sofi::wire::VAULT_STATUS_ACTIVE; + + const G: [u8; 32] = [0x11; 32]; + const DEV: [u8; 32] = [0x22; 32]; + const POS: u64 = 7; + const X: u64 = 1_000; + const Y: u64 = 2_000; + + fn tok(b: u8) -> [u8; 32] { + [b; 32] + } + + fn addr(class: u16, bytes: &[u8]) -> [u8; 32] { + crate::ccb::decode::policy_object_address(class, bytes).expect("a policy class") + } + + /// A creation whose every binding holds, plus the pieces a test needs to + /// break exactly one of them. + struct Valid { + state: VaultStateLeaf, + policy_bytes: Vec, + pair: ([u8; 32], [u8; 32]), + } + + fn valid() -> Valid { + let (a, b) = (tok(0x40), tok(0x41)); + let market = MarketPolicy::beta_constant_product(a, b).unwrap(); + let policy_bytes = market.encode(); + let fee = FeePolicy::new(30).unwrap(); + let release = ReleasePolicy::beta_owner_local_full_close(); + let state = VaultStateLeaf { + owner_genesis: G, + owner_device_id: DEV, + create_position: POS, + market_policy: addr(crate::ccb::class::MARKET_POLICY, &policy_bytes), + fee_policy: addr(crate::ccb::class::FEE_POLICY, &fee.encode()), + release_policy: addr(crate::ccb::class::RELEASE_POLICY, &release.encode()), + storage_set_id: tok(0x77), + generation: 0, + reserve_a: X, + reserve_b: Y, + status: VAULT_STATUS_ACTIVE, + }; + Valid { + state, + policy_bytes, + pair: (a, b), + } + } + + /// Assemble the operation from parts, so a test can perturb any one of them. + fn op_from( + v: &Valid, + state: &VaultStateLeaf, + policy_bytes: &[u8], + pair: ([u8; 32], [u8; 32]), + amounts: (u64, u64), + root: Option<[u8; 32]>, + vault_id: Option<[u8; 32]>, + ) -> Operation { + let _ = v; + let preimage = VaultGenesisPreimage { + owner_genesis: G, + owner_device_id: DEV, + create_position: POS, + state: state.clone(), + }; + let derived = preimage.vault_id(); + let creation = VaultCreation { + vault_id: vault_id.unwrap_or(derived), + genesis_root: root + .unwrap_or_else(|| crate::sofi::lineage::genesis_root(&derived, state).unwrap()), + amount_a: amounts.0, + amount_b: amounts.1, + }; + Operation::SofiVaultCreate { + genesis_preimage: preimage.encode().unwrap(), + creation: creation.encode(), + market_policy_preimage: policy_bytes.to_vec(), + funding_a_policy_commit: pair.0, + funding_b_policy_commit: pair.1, + signature: vec![0xA1; 8], + } + } + + fn good(v: &Valid) -> Operation { + op_from(v, &v.state, &v.policy_bytes, v.pair, (X, Y), None, None) + } + + fn refusal(op: &Operation) -> &'static str { + match semantic_write_set(op, &G, &DEV, POS) { + Err(WriteSetError::MalformedVaultOperation { detail }) => detail, + Err(e) => panic!("expected a malformed-creation refusal, got {e:?}"), + Ok(_) => panic!("expected a refusal, got a write set"), + } + } + + #[test] + fn a_creation_whose_every_binding_holds_produces_the_write_set() { + let v = valid(); + match semantic_write_set(&good(&v), &G, &DEV, POS) { + Ok(SemanticWriteSet::SofiVaultCreate { + leg_a, + leg_b, + creation, + .. + }) => { + assert_eq!(leg_a, (v.pair.0, X), "leg a is the market's token a"); + assert_eq!(leg_b, (v.pair.1, Y)); + assert_eq!(creation.amount_a, X); + } + Ok(_) => panic!("expected a creation write set, got another variant"), + Err(e) => panic!("expected a creation write set, got {e:?}"), + } + } + + /// M1's subject. The assets debited must be the two the market policy + /// authorizes — otherwise a creation funds with X and Y while declaring a + /// market in A and B, and a later close credits the owner A and B. + #[test] + fn funding_assets_that_are_not_the_markets_pair_are_refused() { + let v = valid(); + let impostor = (tok(0x60), tok(0x61)); + assert_ne!(impostor, v.pair); + let op = op_from(&v, &v.state, &v.policy_bytes, impostor, (X, Y), None, None); + assert_eq!( + refusal(&op), + "the funded assets are not the pair the market policy authorizes" + ); + } + + /// MA's subject, and a SEPARATE binding from the pair equality. These are + /// canonical, decodable, correctly ordered policy bytes that authorize + /// exactly the pair being funded — and they are still refused, because + /// they are not the policy object this vault's state names. + #[test] + fn policy_bytes_that_are_not_the_ones_the_state_names_are_refused() { + let v = valid(); + let (c, d) = (tok(0x50), tok(0x51)); + let other = MarketPolicy::beta_constant_product(c, d).unwrap().encode(); + assert_ne!(other, v.policy_bytes); + // Fund the pair THOSE bytes authorize, so only the address binding can + // refuse this: pair equality holds against the carried policy. + let op = op_from(&v, &v.state, &other, (c, d), (X, Y), None, None); + assert_eq!( + refusal(&op), + "the carried market policy is not the one the genesis state commits" + ); + } + + #[test] + fn a_market_policy_preimage_that_is_not_canonical_is_refused() { + let v = valid(); + // Each of these re-addresses to something other than what the state + // commits, so the address binding catches them first; the point is + // that no malformed policy ever reaches a decode on its own word. + for bad in [ + Vec::new(), + v.policy_bytes[..v.policy_bytes.len() - 1].to_vec(), + [v.policy_bytes.clone(), vec![0x00]].concat(), + ] { + let op = op_from(&v, &v.state, &bad, v.pair, (X, Y), None, None); + assert_eq!( + refusal(&op), + "the carried market policy is not the one the genesis state commits", + "a non-canonical policy preimage must never authorize a pair" + ); + } + } + + #[test] + fn a_state_naming_other_owner_coordinates_than_its_preimage_is_refused() { + let v = valid(); + for mutate in [0u8, 1, 2] { + let mut state = v.state.clone(); + match mutate { + 0 => state.owner_genesis = tok(0x99), + 1 => state.owner_device_id = tok(0x99), + _ => state.create_position = POS + 1, + } + let op = op_from(&v, &state, &v.policy_bytes, v.pair, (X, Y), None, None); + assert_eq!( + refusal(&op), + "the genesis state names different owner coordinates than the preimage it sits in" + ); + } + } + + #[test] + fn a_creation_by_another_owner_is_refused() { + let v = valid(); + let op = good(&v); + for (g, d) in [(tok(0x99), DEV), (G, tok(0x99))] { + match semantic_write_set(&op, &g, &d, POS) { + Err(WriteSetError::MalformedVaultOperation { detail }) => { + assert_eq!(detail, "a creation debits its own owner's balances") + } + Err(e) => panic!("expected an owner refusal, got {e:?}"), + Ok(_) => panic!("expected an owner refusal, got a write set"), + } + } + } + + #[test] + fn a_creation_naming_a_position_it_does_not_land_at_is_refused() { + let v = valid(); + let op = good(&v); + match semantic_write_set(&op, &G, &DEV, POS + 1) { + Err(WriteSetError::MalformedVaultOperation { detail }) => assert_eq!( + detail, + "the creation names a position other than the one it lands at" + ), + Err(e) => panic!("expected a position refusal, got {e:?}"), + Ok(_) => panic!("expected a position refusal, got a write set"), + } + } + + #[test] + fn a_creation_stating_a_genesis_root_the_state_does_not_derive_is_refused() { + let v = valid(); + let op = op_from( + &v, + &v.state, + &v.policy_bytes, + v.pair, + (X, Y), + Some(tok(0xBE)), + None, + ); + assert_eq!( + refusal(&op), + "the creation record states a genesis root the state does not derive" + ); + } + + #[test] + fn a_creation_naming_another_vault_than_the_preimage_derives_is_refused() { + let v = valid(); + let op = op_from( + &v, + &v.state, + &v.policy_bytes, + v.pair, + (X, Y), + None, + Some(tok(0xAD)), + ); + assert_eq!( + refusal(&op), + "the creation record names another vault than the preimage derives" + ); + } + + #[test] + fn funded_amounts_that_are_not_the_genesis_reserves_are_refused() { + let v = valid(); + for amounts in [(X + 1, Y), (X, Y + 1)] { + let op = op_from(&v, &v.state, &v.policy_bytes, v.pair, amounts, None, None); + assert_eq!( + refusal(&op), + "the funded amounts are not the genesis reserves" + ); + } + } + + #[test] + fn a_funding_pair_out_of_canonical_order_is_refused() { + let v = valid(); + // Swap the market's own pair: pair equality then fails before the + // ordering check, which is the correct precedence — the authority is + // the policy, and order is a property of what it authorizes. + let op = op_from( + &v, + &v.state, + &v.policy_bytes, + (v.pair.1, v.pair.0), + (X, Y), + None, + None, + ); + assert_eq!( + refusal(&op), + "the funded assets are not the pair the market policy authorizes" + ); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs index 306fbece0..a6d9c027b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/signature.rs @@ -395,6 +395,7 @@ mod tests { let operation = Operation::SofiVaultCreate { genesis_preimage: vec![0x01, 0x02], creation: vec![0x03, 0x04], + market_policy_preimage: vec![0x00, 0x07], funding_a_policy_commit: [0x5C; 32], funding_b_policy_commit: [0x5D; 32], signature: Vec::new(), @@ -403,6 +404,7 @@ mod tests { let signed = Operation::SofiVaultCreate { genesis_preimage: vec![0x01, 0x02], creation: vec![0x03, 0x04], + market_policy_preimage: vec![0x00, 0x07], funding_a_policy_commit: [0x5C; 32], funding_b_policy_commit: [0x5D; 32], signature: sphincs_sign(&sk, &bytes).unwrap(), @@ -410,6 +412,46 @@ mod tests { assert_eq!(verify_operation(&signed, &pk), Ok(())); } + /// THE CARRIED MARKET POLICY IS UNDER THE SIGNATURE, and asserting it is + /// not tautological. + /// + /// Coverage is structural — `operation_signing_bytes` is the whole + /// canonical encoding with the signature cleared — but only while the + /// field is IN that encoding. Deleting its line from `Operation::to_bytes` + /// would silently take it back out from under the signature, leaving the + /// policy object substitutable after signing while every other check still + /// passed. This test is what goes red if that happens. + #[test] + fn altering_the_carried_market_policy_after_signing_is_refused() { + let (pk, sk) = keys(); + let build = |policy: Vec, signature: Vec| Operation::SofiVaultCreate { + genesis_preimage: vec![0x01, 0x02], + creation: vec![0x03, 0x04], + market_policy_preimage: policy, + funding_a_policy_commit: [0x5C; 32], + funding_b_policy_commit: [0x5D; 32], + signature, + }; + let honest = vec![0x00, 0x07, 0x00, 0x01]; + let bytes = crate::core::state_machine::transition::operation_signing_bytes(&build( + honest.clone(), + Vec::new(), + )); + let signature = sphincs_sign(&sk, &bytes).unwrap(); + assert_eq!( + verify_operation(&build(honest.clone(), signature.clone()), &pk), + Ok(()) + ); + + // ONLY the policy bytes differ, and the signature is the same one. + let swapped = vec![0x00, 0x07, 0x00, 0x02]; + assert_ne!(swapped, honest); + assert!( + verify_operation(&build(swapped, signature), &pk).is_err(), + "the carried market policy must be covered by the operation signature" + ); + } + /// The body's own key cannot introduce itself: a signature that verifies /// under the key the body names is still refused when that is not the /// signer the caller proved. diff --git a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs index 245cb534e..feea5f6d4 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/types/operations.rs @@ -668,18 +668,36 @@ pub enum Operation { genesis_preimage: Vec, /// Canonical `VaultCreation` bytes (class `0x005B`). creation: Vec, + /// Canonical `MarketPolicy` bytes (class `0x0007`) — the EXACT policy + /// object the genesis state names by content address. + /// + /// CARRIED, so that acceptance is a function of the operation's bytes + /// and the authenticated pre-state alone. `semantic_write_set` is + /// pure: it cannot resolve `VaultStateLeaf.market_policy` to a pair, + /// and making it fetch would put a resolver, storage availability and + /// foreign-walk liveness between a local acceptance decision and its + /// answer. 72 fixed-width bytes against the ~50KB signature this + /// operation already carries. + /// + /// NOT a second source of market truth: Core re-addresses these bytes + /// under the market-policy namespace and refuses unless the address is + /// the one `state.market_policy` commits. Bytes that do not + /// authenticate to what the state named establish nothing. + market_policy_preimage: Vec, /// The two assets the funding is debited from, in canonical order /// (`a < b`). /// /// SIGNED EXECUTION COORDINATES, not a second source of market truth. - /// `semantic_write_set` is pure — it cannot resolve - /// `VaultStateLeaf.market_policy` to a pair — so without these the - /// debit P15-12 requires cannot be derived from the operation at all, - /// and balances would move outside any declared write set. + /// Without them the debit P15-12 requires could not be derived from + /// the operation at all, and balances would move outside any declared + /// write set. /// - /// The authority remains the market policy the vault state commits: - /// `genesis_accepted` resolves it by content address and refuses - /// unless these two equal the pair it decodes. + /// The authority is the market policy the vault state commits: + /// `semantic_write_set` decodes `market_policy_preimage` — after + /// holding it to that address — and refuses unless these two equal + /// the pair it reads out. Until that binding landed the check lived + /// only in the SDK producer, so a different producer could name any + /// two assets and Core refused nothing. funding_a_policy_commit: [u8; 32], funding_b_policy_commit: [u8; 32], /// SPHINCS+ over the operation's canonical unsigned bytes. A creation @@ -1015,6 +1033,7 @@ impl Operation { SofiVaultCreate { genesis_preimage, creation, + market_policy_preimage, funding_a_policy_commit, funding_b_policy_commit, signature, @@ -1022,6 +1041,7 @@ impl Operation { put_u8(&mut out, 35); put_bytes(&mut out, genesis_preimage); put_bytes(&mut out, creation); + put_bytes(&mut out, market_policy_preimage); put_bytes(&mut out, funding_a_policy_commit); put_bytes(&mut out, funding_b_policy_commit); put_bytes(&mut out, signature); @@ -2213,6 +2233,9 @@ impl Operation { 35 => SofiVaultCreate { genesis_preimage: get_bytes(&mut input)?, creation: get_bytes(&mut input)?, + // In the encoder's order. The policy object the genesis state + // names, carried so acceptance needs no resolver. + market_policy_preimage: get_bytes(&mut input)?, // Both funding commits, in the encoder's order. Dropping // either would decode to an operation that debits different // assets than the one whose signature was checked. 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 4f7f9b95d..d48ed996a 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 @@ -802,7 +802,7 @@ fn a_setup_transition_binds_its_position_and_its_derived_root() { &G, &DEV, &dsm::economic::admission::dsm_economic_operation_id(&G, &DEV, &C_DSM_PLUS), - &dsm::economic::write_set::EconomicPreState::new(&std::collections::BTreeMap::new()), + &dsm::economic::write_set::EconomicPreState::new(&std::collections::BTreeMap::new(), 0), &mut build_tree, &dsm::economic::write_set::CreditSourceFacts::None, ) 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 a9f85da9f..3ac286474 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 @@ -231,7 +231,7 @@ fn fixture( &G, &DEV, &[0x42u8; 32], - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, 0), &mut tree, &CreditSourceFacts::AuthorizedIssuance { issuance_authorization_addr: evidence_addr, @@ -313,7 +313,7 @@ fn fixture_with_stranger(signer_count: usize, amount: u64) -> Fixture { &G, &DEV, &[0x42u8; 32], - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, 0), &mut tree, &CreditSourceFacts::AuthorizedIssuance { issuance_authorization_addr: evidence_addr, diff --git a/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs b/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs index 88b0bfdb5..ac407cc1d 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/economic_write_set.rs @@ -119,14 +119,15 @@ fn round_trip( &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, facts, ) .expect("buildable"); let witness = witness_for(pre_root, built, operation); verify_mutation_sequence(&witness.mutation_sequence(), &G, &DEV).expect("sequence verifies"); - verify_operation_write_set(operation, &G, &DEV, &witness).expect("exact effect verifies"); + verify_operation_write_set(operation, &G, &DEV, &witness, P_CREATE) + .expect("exact effect verifies"); witness } @@ -226,7 +227,7 @@ fn create_token_with_initial_supply_gets_the_exact_named_refusal() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -247,7 +248,7 @@ fn create_token_with_initial_supply_gets_the_exact_named_refusal() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&b), + &EconomicPreState::new(&b, P_CREATE), &mut t, &CreditSourceFacts::None, ) @@ -256,7 +257,7 @@ fn create_token_with_initial_supply_gets_the_exact_named_refusal() { }; drop(tree2); assert_eq!( - verify_operation_write_set(&create_token(1_000, 500), &G, &DEV, &some_witness), + verify_operation_write_set(&create_token(1_000, 500), &G, &DEV, &some_witness, P_CREATE), Err(WriteSetError::CreateTokenInitialSupplyRequiresIssuancePredicate) ); } @@ -275,7 +276,7 @@ fn a_debit_with_an_extra_mutation_is_refused() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -302,7 +303,7 @@ fn a_debit_with_an_extra_mutation_is_refused() { // ascend); when it does form, the verifier must refuse it. if let Ok(forged) = forged { assert!(matches!( - verify_operation_write_set(&burn(50, era()), &G, &DEV, &forged), + verify_operation_write_set(&burn(50, era()), &G, &DEV, &forged, P_CREATE), Err(WriteSetError::WrongWriteSet { .. }) )); } @@ -314,11 +315,16 @@ fn a_debit_of_the_wrong_amount_or_asset_is_refused() { let op = burn(40, era()); let witness = round_trip(&op, tree, balances, &CreditSourceFacts::None); // Same witness, different claimed operations. - assert!(verify_operation_write_set(&burn(41, era()), &G, &DEV, &witness).is_err()); - assert!(verify_operation_write_set(&burn(40, [0xEE; 32]), &G, &DEV, &witness).is_err()); + assert!(verify_operation_write_set(&burn(41, era()), &G, &DEV, &witness, P_CREATE).is_err()); + assert!( + verify_operation_write_set(&burn(40, [0xEE; 32]), &G, &DEV, &witness, P_CREATE).is_err() + ); // And the role near-miss: the same delta presented as a transfer TO us // (credit role) is refused. - assert!(verify_operation_write_set(&transfer(DEV, 40, era()), &G, &DEV, &witness).is_err()); + assert!( + verify_operation_write_set(&transfer(DEV, 40, era()), &G, &DEV, &witness, P_CREATE) + .is_err() + ); } #[test] @@ -341,7 +347,7 @@ fn a_recipient_credit_without_its_consumed_source_is_refused() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &facts, ) @@ -374,7 +380,7 @@ fn a_recipient_credit_without_its_consumed_source_is_refused() { ) .unwrap(); assert!(matches!( - verify_operation_write_set(&op, &G, &DEV, &stripped), + verify_operation_write_set(&op, &G, &DEV, &stripped, P_CREATE), Err(WriteSetError::WrongWriteSet { .. }) )); } @@ -406,7 +412,7 @@ fn a_mint_builds_a_credit_but_demands_its_issuance_facts() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree.clone(), &CreditSourceFacts::None, ) @@ -419,7 +425,7 @@ fn a_mint_builds_a_credit_but_demands_its_issuance_facts() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::AuthorizedIssuance { issuance_authorization_addr: [0xA9; 32], @@ -445,7 +451,7 @@ fn a_mint_builds_a_credit_but_demands_its_issuance_facts() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::AuthorizedIssuance { issuance_authorization_addr: [0xA9; 32], @@ -465,7 +471,7 @@ fn insufficient_balance_refuses_the_exact_debit() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::None, ), @@ -636,12 +642,31 @@ fn sofi_setup_operation() -> Operation { } } -fn sofi_genesis_state() -> dsm::sofi::wire::VaultStateLeaf { +/// The REAL market policy this vault's state names. It has to be real now: +/// Core re-addresses the bytes the operation carries and requires the address +/// the state commits, so a literal placeholder address authorizes nothing. +fn sofi_market_policy(market: ([u8; 32], [u8; 32])) -> dsm::ccb::state::MarketPolicy { + dsm::ccb::state::MarketPolicy::beta_constant_product(market.0, market.1) + .expect("an ordered beta pair") +} + +fn sofi_market_policy_addr(market: ([u8; 32], [u8; 32])) -> [u8; 32] { + dsm::ccb::decode::policy_object_address( + dsm::ccb::class::MARKET_POLICY, + &sofi_market_policy(market).encode(), + ) + .expect("a policy class") +} + +/// The MARKET pair is now a parameter, separate from the FUNDING pair the +/// operation claims. That separation is the point of the binding under test: +/// before it, the funding assets were free of the vault's declared market. +fn sofi_genesis_state(market: ([u8; 32], [u8; 32])) -> dsm::sofi::wire::VaultStateLeaf { dsm::sofi::wire::VaultStateLeaf { owner_genesis: G, owner_device_id: DEV, create_position: P_CREATE, - market_policy: [0x31; 32], + market_policy: sofi_market_policy_addr(market), fee_policy: [0x32; 32], release_policy: [0x33; 32], storage_set_id: [0x77; 32], @@ -653,12 +678,13 @@ fn sofi_genesis_state() -> dsm::sofi::wire::VaultStateLeaf { } fn sofi_create_operation( + market: ([u8; 32], [u8; 32]), funding_a: [u8; 32], funding_b: [u8; 32], amount_a: u64, amount_b: u64, ) -> Operation { - let state = sofi_genesis_state(); + let state = sofi_genesis_state(market); let preimage = dsm::sofi::wire::VaultGenesisPreimage { owner_genesis: G, owner_device_id: DEV, @@ -675,6 +701,7 @@ fn sofi_create_operation( Operation::SofiVaultCreate { genesis_preimage: preimage.encode().expect("preimage encodes"), creation: creation.encode(), + market_policy_preimage: sofi_market_policy(market).encode(), funding_a_policy_commit: funding_a, funding_b_policy_commit: funding_b, signature: vec![0xA1; 8], @@ -723,7 +750,7 @@ fn a_setup_refuses_to_replace_an_existing_relationship() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -734,7 +761,7 @@ fn a_setup_refuses_to_replace_an_existing_relationship() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &CreditSourceFacts::None, ); @@ -750,7 +777,7 @@ fn a_vault_creation_debits_the_pair_and_inserts_the_record() { let (a, b) = ([0x40; 32], [0x41; 32]); let (tree, balances) = sofi_funded(&[(a, 5_000), (b, 9_000)]); let witness = round_trip( - &sofi_create_operation(a, b, 1_000, 2_000), + &sofi_create_operation((a, b), a, b, 1_000, 2_000), tree, balances, &CreditSourceFacts::None, @@ -795,7 +822,7 @@ fn a_creation_is_refused_on_each_missing_conjunct() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&bal), + &EconomicPreState::new(&bal, P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -805,7 +832,7 @@ fn a_creation_is_refused_on_each_missing_conjunct() { // Amounts that are not the genesis reserves would mint reserves. assert!(matches!( build( - &sofi_create_operation(a, b, 999, 2_000), + &sofi_create_operation((a, b), a, b, 999, 2_000), balances(5_000, 9_000) ), Err(WriteSetError::MalformedVaultOperation { .. }) @@ -813,7 +840,7 @@ fn a_creation_is_refused_on_each_missing_conjunct() { // An unordered pair. assert!(matches!( build( - &sofi_create_operation(b, a, 1_000, 2_000), + &sofi_create_operation((a, b), b, a, 1_000, 2_000), balances(5_000, 9_000) ), Err(WriteSetError::MalformedVaultOperation { .. }) @@ -821,14 +848,14 @@ fn a_creation_is_refused_on_each_missing_conjunct() { // The same asset twice. assert!(matches!( build( - &sofi_create_operation(a, a, 1_000, 2_000), + &sofi_create_operation((a, b), a, a, 1_000, 2_000), balances(5_000, 9_000) ), Err(WriteSetError::MalformedVaultOperation { .. }) )); // A balance that cannot cover the funding. assert!(build( - &sofi_create_operation(a, b, 1_000, 2_000), + &sofi_create_operation((a, b), a, b, 1_000, 2_000), balances(10, 9_000) ) .is_err()); @@ -844,14 +871,14 @@ fn a_sofi_leaf_is_refused_under_the_wrong_operation() { let (mut tree, balances) = sofi_funded(&[(a, 5_000), (b, 9_000)]); // The witness a CREATION produces, verified against a SETUP. - let create = sofi_create_operation(a, b, 1_000, 2_000); + let create = sofi_create_operation((a, b), a, b, 1_000, 2_000); let pre_root = tree.root(); let built = build_write_set( &create, &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&balances), + &EconomicPreState::new(&balances, P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -860,7 +887,7 @@ fn a_sofi_leaf_is_refused_under_the_wrong_operation() { let setup = sofi_setup_operation(); assert!( matches!( - verify_operation_write_set(&setup, &G, &DEV, &creation_witness), + verify_operation_write_set(&setup, &G, &DEV, &creation_witness, P_CREATE), Err(WriteSetError::UnexpectedLeafClass) ), "a setup may not carry a creation record" @@ -874,7 +901,7 @@ fn a_sofi_leaf_is_refused_under_the_wrong_operation() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &CreditSourceFacts::None, ) @@ -882,7 +909,7 @@ fn a_sofi_leaf_is_refused_under_the_wrong_operation() { let setup_witness = witness_for(pre_root, built, &setup); assert!( matches!( - verify_operation_write_set(&create, &G, &DEV, &setup_witness), + verify_operation_write_set(&create, &G, &DEV, &setup_witness, P_CREATE), Err(WriteSetError::UnexpectedLeafClass) ), "a creation may not carry a relationship leaf" @@ -912,7 +939,7 @@ fn a_creation_verifies_when_key_order_opposes_asset_order() { let (tree, balances) = sofi_funded(&[(a, 5_000), (b, 9_000)]); let witness = round_trip( - &sofi_create_operation(a, b, 1_000, 2_000), + &sofi_create_operation((a, b), a, b, 1_000, 2_000), tree, balances, &CreditSourceFacts::None, @@ -920,6 +947,48 @@ fn a_creation_verifies_when_key_order_opposes_asset_order() { assert_eq!(witness.mutations.len(), 3); } +/// THE ASSET-SUBSTITUTION DOOR, at the real seam. +/// +/// Fund the creation from assets the device genuinely HOLDS, in canonical +/// order, in the exact amounts the genesis reserves state — and have the vault +/// declare a market in two entirely different assets. Every other binding +/// holds. It must still be refused, because a later close credits the owner +/// the MARKET's pair, which this creation never funded. +/// +/// Until the funding pair was bound to the decoded market policy, this +/// operation was accepted by both write-set halves. The binding had lived in +/// `genesis_accepted`, whose deletion removed it, and after that only the SDK +/// producer enforced it — which is no enforcement at all against a different +/// producer. +#[test] +fn funding_assets_outside_the_declared_market_are_refused_even_when_funded() { + let market = ([0x40; 32], [0x41; 32]); + let (junk_a, junk_b) = ([0x70; 32], [0x71; 32]); + assert!( + junk_a < junk_b, + "the substitute pair is canonically ordered" + ); + assert_ne!(junk_a, market.0); + + // The device really holds the substitutes, in ample amount. + let (mut tree, balances) = sofi_funded(&[(junk_a, 5_000), (junk_b, 9_000)]); + let op = sofi_create_operation(market, junk_a, junk_b, 1_000, 2_000); + let err = build_write_set( + &op, + &G, + &DEV, + &[0xE0; 32], + &EconomicPreState::new(&balances, P_CREATE), + &mut tree, + &CreditSourceFacts::None, + ) + .expect_err("a creation may not fund from outside its declared market"); + assert!( + matches!(err, WriteSetError::MalformedVaultOperation { .. }), + "got {err:?}" + ); +} + /// A SETUP BINDS BOTH ITS COORDINATES, not just the device. /// /// The leaf's KEY is derived from the authenticated `(G, DevID)` while `h⁰` @@ -950,7 +1019,7 @@ fn a_setup_naming_a_foreign_genesis_is_refused() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &CreditSourceFacts::None, ); @@ -977,7 +1046,7 @@ fn a_setups_root_is_the_root_its_own_transition_produces() { &G, &DEV, &econ_op_id(), - &EconomicPreState::new(&BTreeMap::new()), + &EconomicPreState::new(&BTreeMap::new(), P_CREATE), &mut tree, &CreditSourceFacts::None, ) diff --git a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs index b2e7adec5..3674caefd 100644 --- a/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs +++ b/dsm_client/deterministic_state_machine/dsm/tests/sofi_v8_operations.rs @@ -24,6 +24,7 @@ fn sofi_operations() -> Vec { Operation::SofiVaultCreate { genesis_preimage: vec![0x5A, 0x00], creation: vec![0x5B, 0x00], + market_policy_preimage: vec![0x00, 0x07, 0x00, 0x01], funding_a_policy_commit: [0x5C; 32], funding_b_policy_commit: [0x5D; 32], signature: Vec::new(), @@ -253,6 +254,7 @@ fn each_sofi_operation_signs_its_own_rule_and_not_the_other() { let unsigned_create = Operation::SofiVaultCreate { genesis_preimage: vec![0x5A, 0x00], creation: vec![0x5B, 0x00], + market_policy_preimage: vec![0x00, 0x07, 0x00, 0x01], funding_a_policy_commit: [0x5C; 32], funding_b_policy_commit: [0x5D; 32], signature: Vec::new(), @@ -345,16 +347,21 @@ fn every_sofi_operation_round_trips_through_the_byte_codec() { } } -/// The creation's TWO funding commits both survive the round trip, in order. +/// The creation's carried market policy and its TWO funding commits all +/// survive the round trip, in order. /// /// They are the fields most likely to be dropped or transposed by a decoder /// written from memory: the operation would still decode, and it would name -/// different assets than the signature covered. +/// different assets than the signature covered. The market policy preimage +/// sits BETWEEN `creation` and the funding commits, which is where a +/// positional mistake is silent rather than loud — drop it and both commits +/// still decode, from the wrong offsets. #[test] fn a_creation_round_trips_both_funding_commits_in_order() { let op = Operation::SofiVaultCreate { genesis_preimage: vec![0x5A, 0x00], creation: vec![0x5B, 0x00], + market_policy_preimage: vec![0x00, 0x07, 0x00, 0x01], funding_a_policy_commit: [0xA1; 32], funding_b_policy_commit: [0xB2; 32], signature: SIG.to_vec(), @@ -362,10 +369,16 @@ fn a_creation_round_trips_both_funding_commits_in_order() { let decoded = Operation::from_bytes(&op.to_bytes()).expect("decodes"); match decoded { Operation::SofiVaultCreate { + market_policy_preimage, funding_a_policy_commit, funding_b_policy_commit, .. } => { + assert_eq!( + market_policy_preimage, + vec![0x00, 0x07, 0x00, 0x01], + "the carried policy survives, and is not read from the wrong offset" + ); assert_eq!(funding_a_policy_commit, [0xA1; 32]); assert_eq!(funding_b_policy_commit, [0xB2; 32], "not transposed"); } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs index 57391a678..e70015659 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/economic_admission_flow.rs @@ -163,12 +163,15 @@ pub(crate) fn producer_tree(validated: &ValidatedEconomicRoot) -> Result, + /// The position the next transition LANDS AT: the successor of the + /// admitted root these balances were decoded from. + pub economic_position: u64, } impl AdmittedPreState { /// The borrowed view `build_write_set` takes. pub fn as_write_set_pre_state(&self) -> dsm::economic::write_set::EconomicPreState<'_> { - dsm::economic::write_set::EconomicPreState::new(&self.balances) + dsm::economic::write_set::EconomicPreState::new(&self.balances, self.economic_position) } } @@ -183,7 +186,18 @@ pub(crate) fn producer_tree_and_pre_state( validated: &ValidatedEconomicRoot, ) -> Result<(EconomicSmt, AdmittedPreState), DsmError> { let mut tree = EconomicSmt::new(); - let mut pre = AdmittedPreState::default(); + let mut pre = AdmittedPreState { + // The NEXT position: a pre-state at `p` is the pre-state of `p + 1`. + // CHECKED: a counter with no successor is a refusal, never a clamp — + // a saturated position would silently claim to be its own successor. + economic_position: validated + .economic_position() + .checked_add(1) + .ok_or_else(|| { + DsmError::invalid_operation("economic position has no successor".to_string()) + })?, + ..AdmittedPreState::default() + }; if validated.economic_position() == 0 { return Ok((tree, pre)); } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs index 4b9117a52..b0d389bcc 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs @@ -332,6 +332,10 @@ pub fn build_vault_create( let operation = Operation::SofiVaultCreate { genesis_preimage: preimage.encode()?, creation: creation.encode(), + // The EXACT bytes whose address the state commits — carried, not + // referenced, so Core re-addresses and decodes them itself instead of + // taking this producer's word for the pair. + market_policy_preimage: market_policy_bytes.to_vec(), funding_a_policy_commit: token_a, funding_b_policy_commit: token_b, signature: Vec::new(),