From 616ed1f4df2a32322c3692a6b3458cdbe00e8ed9 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:11:13 -0400 Subject: [PATCH] feat(economic): a device can publish, and a stranger can verify, which leaves its registered root commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A device's economic root is published as a write-once register cell at a named position, so anyone can establish WHICH root it committed. Nothing ever published which LEAVES that root commits. Both directions of a settlement need exactly that and neither could get it: the trader's 0x0026 must prove the owner's two vault reserves, the owner's 0x0027 must prove the trader's settlement receipt, and `EconomicSmt::siblings` had one production caller, inside the witness builder, publishing nothing. This is that object, once, for both directions. EconomicProofArtifactV1 { publisher_genesis, publisher_devid, economic_position, economic_root, leaves[] { state_ccb, siblings[256] } } Three properties are structural rather than conventional: - ONE SNAPSHOT. Every leaf and every path must derive the one root the artifact names. The producer builds it from `tree` inside `finish_admission` — the same post-transition tree the write set advanced and whose root was just registered — so there is no second read and no window in which the tree could move between naming the root and taking the paths. The root equality is stated there rather than assumed, and `EconomicProofArtifact::new` re-derives every path before the bytes exist, so a mixed-snapshot artifact cannot be constructed, let alone published. - NO SELF-ASSERTION. The artifact carries no signature and proves nothing about its own authority. `verify_against` takes the publisher, position and root the READER established independently and refuses an artifact naming anything else, before recomputing. Whatever points at an artifact — an advertisement, an evidence descriptor — is a locator, never a warrant. - KEYS ARE DERIVED. The leaf key comes from the decoded state's own class and the publisher's coordinates, so a publisher cannot present a leaf under a key it chose. Every admission now publishes proof of the externally citable leaves its transition wrote — vault reserves and settlement receipts, the two an evidence type asks another device to verify. Balance and consumed-source leaves are excluded deliberately: nothing cites them, and each path costs 8 KiB, so carrying them would grow every admission to prove what no one reads. The artifact is a post-admit artifact, frozen in the admit transaction and delivered by the existing quorum republish sweep, so it cannot reach the fleet before the root it names is registered. Proven: a stranger holding only the publisher's coordinates, position and root recomputes every leaf and path and gets that root back, on a real admitted funded create fetched by content address; leaves and paths from two snapshots are refused at construction AND at reading; a tampered path, a repeated leaf, an amount the root does not commit, and reading at a position or root the reader did not establish are each refused; decode is strict about shape and does not check inclusion, so shape alone is never evidence. Four mutation cycles, each reddening only its own named test, restored checksum-verified. NOT in this change: no evidence type consumes it yet. The 0x0026 and 0x0027 bundles still carry their own bespoke leaf-and-path fields, and no advertisement carries a locator. Rewiring them to consume this object is the 0x0026 producer cut, which is where a reader first needs to find one. Boards: workspace 74 binaries, 3964 passed / 0 failed / 17 ignored (dsm lib 1692/0, dsm_sdk lib 1813/0/7 ignored), exit 0; node 11 binaries, 276/0, exit 0; make lint exit 0; frontend type-check, lint and jest green; production safety PASS (pinned 1.98.0); ci_scan PASS. --- .../common/domain_tags/dsm/misc/economic.rs | 9 + .../src/common/domain_tags/dsm/misc/mod.rs | 1 + .../dsm/src/economic/mod.rs | 1 + .../dsm/src/economic/proof_artifact.rs | 445 ++++++++++++++++++ .../dsm_sdk/src/handlers/dlv_routes.rs | 151 ++++++ .../src/sdk/economic_admission_flow.rs | 97 +++- .../dsm_sdk/src/sdk/economic_registers.rs | 36 ++ dsm_client/frontend/src/proto/dsm_app_pb.ts | 126 +++++ proto/dsm_app.proto | 32 ++ 9 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 dsm_client/deterministic_state_machine/dsm/src/economic/proof_artifact.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/economic.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/economic.rs index 9702c9af..46d665ad 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/economic.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/economic.rs @@ -128,6 +128,15 @@ pub const TAG_DSM_ECON_SOURCE_VALIDATED_DLV_SETTLEMENT_PAYMENT: TaggedHashDomain /// witness). Transport proto — no CCB class. pub const TAG_DSM_DLV_SETTLEMENT_PAYMENT_EVIDENCE: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/dlv-settlement-payment-evidence/v1"); +/// Immutable namespace for the GENERIC economic-inclusion proof +/// (`EconomicProofArtifactV1`: a publisher, one named economic position and +/// root, and one or more exact economic leaves with their 256-sibling paths). +/// Transport proto — no CCB class. Distinct from the two semantic evidence +/// namespaces above: those state WHY a credit is funded, this one only shows +/// which leaves a validated root commits, and both directions of a settlement +/// use the same object. +pub const TAG_DSM_ECONOMIC_PROOF_ARTIFACT: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/economic-proof-artifact/v1"); pub const TAG_DSM_ECON_SOURCE_SAME_TRANSITION_MOVE: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/econ-source/same-transition-move/v1"); diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs index 1538ebb2..de409daa 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/mod.rs @@ -65,6 +65,7 @@ pub(super) const TAGS: &[TaggedHashDomain<'static>] = &[ TAG_DSM_DLV_RESERVE_CONSUMPTION_EVIDENCE, TAG_DSM_ECON_SOURCE_DLV_RESERVE_CONSUMPTION, TAG_DSM_DLV_SETTLEMENT_PAYMENT_EVIDENCE, + TAG_DSM_ECONOMIC_PROOF_ARTIFACT, TAG_DSM_ECON_SOURCE_SAME_TRANSITION_MOVE, TAG_DSM_ECON_SOURCE_VALIDATED_DLV_SETTLEMENT_PAYMENT, TAG_DSM_ERA_FAUCET_ID, diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs index bc76a045..f0cd0fa0 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/mod.rs @@ -66,6 +66,7 @@ pub mod lineage; pub mod mutation; pub mod peer_acceptance; pub mod peer_lineage; +pub mod proof_artifact; pub mod provenance; pub mod register; pub mod release; diff --git a/dsm_client/deterministic_state_machine/dsm/src/economic/proof_artifact.rs b/dsm_client/deterministic_state_machine/dsm/src/economic/proof_artifact.rs new file mode 100644 index 00000000..08582713 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/economic/proof_artifact.rs @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! THE GENERIC ECONOMIC-INCLUSION PROOF. +//! +//! A device's economic root `R_econ` is published as a write-once register +//! cell at a named position, so any stranger can establish WHAT root a device +//! committed at position n. Nothing published which LEAVES that root commits. +//! Every consumer that needs one — a trader proving the owner's vault +//! reserves (0x0026), an owner proving the trader's settlement receipt +//! (0x0027) — needed the same thing, and neither could get it. +//! +//! This is that object, once, for both directions: a publisher, ONE named +//! economic position and root, and one or more exact economic leaves each +//! with its 256-sibling inclusion path. +//! +//! Three properties are structural, not conventions: +//! +//! 1. **One snapshot.** Every leaf and every path in an artifact must derive +//! the ONE root the artifact names. A producer that mixed two snapshots +//! cannot pass its own construction check, and a reader that recomputes +//! would reject it. There is no per-leaf root. +//! 2. **No self-assertion.** The artifact carries no signature and proves +//! nothing about its own authority. `verify_against` takes the position +//! and root the READER established independently — from the register — and +//! refuses an artifact naming anything else. A locator that points at an +//! artifact (a routing advertisement, an evidence descriptor) makes it +//! findable, never valid. +//! 3. **Keys are derived, never supplied.** The leaf key comes from the +//! decoded state's own class and the publisher's coordinates, so a +//! publisher cannot present a leaf under a key of its choosing. + +use crate::economic::state::EconomicLeafState; +use crate::economic::tree::{leaf_node, root_from_path, ECONOMIC_SMT_HEIGHT}; +use crate::types::proto as generated; +use prost::Message; + +/// One leaf and the path that proves it. +#[derive(Debug, Clone)] +pub struct EconomicProofLeaf { + pub state: EconomicLeafState, + pub siblings: Box<[[u8; 32]; ECONOMIC_SMT_HEIGHT]>, +} + +/// A strictly decoded artifact. Shape only — inclusion is checked by +/// [`EconomicProofArtifact::verify_against`], against a root the reader +/// established for itself. +#[derive(Debug, Clone)] +pub struct EconomicProofArtifact { + pub publisher_genesis: [u8; 32], + pub publisher_devid: [u8; 32], + pub economic_position: u64, + pub economic_root: [u8; 32], + pub leaves: Vec, +} + +fn digest32(bytes: &[u8], what: &str) -> Result<[u8; 32], String> { + bytes + .try_into() + .map_err(|_| format!("{what} must be 32 bytes, got {}", bytes.len())) +} + +impl EconomicProofArtifact { + /// Build and SELF-CHECK. The construction runs the same recomputation a + /// reader will, against the root the producer names, so an artifact whose + /// leaves and paths do not all belong to one snapshot cannot be built — + /// let alone published. This is where the one-snapshot rule is enforced + /// for the producer; `verify_against` enforces it for the reader. + pub fn new( + publisher_genesis: [u8; 32], + publisher_devid: [u8; 32], + economic_position: u64, + economic_root: [u8; 32], + leaves: Vec, + ) -> Result { + let artifact = Self { + publisher_genesis, + publisher_devid, + economic_position, + economic_root, + leaves, + }; + artifact.check_inclusion()?; + Ok(artifact) + } + + /// The canonical bytes. Content-addressed by the caller under + /// `TAG_DSM_ECONOMIC_PROOF_ARTIFACT`. + pub fn encode(&self) -> Vec { + generated::EconomicProofArtifactV1 { + publisher_genesis: self.publisher_genesis.to_vec(), + publisher_devid: self.publisher_devid.to_vec(), + economic_position: self.economic_position, + economic_root: self.economic_root.to_vec(), + leaves: self + .leaves + .iter() + .map(|l| generated::EconomicProofLeafV1 { + // A leaf whose state cannot re-encode never reached here: + // `new` decoded or built it, and `check_inclusion` hashed + // it, both of which take the same encoding. + state_ccb: l.state.encode().unwrap_or_default(), + siblings: l.siblings.iter().map(|s| s.to_vec()).collect(), + }) + .collect(), + } + .encode_to_vec() + } + + /// THE READER'S CHECK, and the only thing that makes an artifact usable. + /// + /// `position` and `root` are what the reader established independently — + /// the publisher's register cell at that position, read at quorum. An + /// artifact naming any other publisher, position or root is refused + /// before a single hash is recomputed, so a locator can never widen what + /// an artifact is evidence OF. + pub fn verify_against( + &self, + publisher_genesis: &[u8; 32], + publisher_devid: &[u8; 32], + position: u64, + root: &[u8; 32], + ) -> Result<(), String> { + if self.publisher_genesis != *publisher_genesis || self.publisher_devid != *publisher_devid + { + return Err("the artifact names a different publisher".into()); + } + if self.economic_position != position { + return Err(format!( + "the artifact names economic position {}, not the {position} it is being read at", + self.economic_position + )); + } + if self.economic_root != *root { + return Err("the artifact names a different economic root".into()); + } + self.check_inclusion() + } + + /// Recompute every leaf key, every leaf commitment and every path, and + /// require each to derive the ONE named root. + fn check_inclusion(&self) -> Result<(), String> { + if self.leaves.is_empty() { + return Err("an economic proof artifact carries no leaves".into()); + } + let mut seen: Vec<[u8; 32]> = Vec::with_capacity(self.leaves.len()); + for (i, leaf) in self.leaves.iter().enumerate() { + // The key is DERIVED from the state's own class and the + // publisher's coordinates — never supplied alongside it. + let key = leaf + .state + .leaf_key(&self.publisher_genesis, &self.publisher_devid); + if seen.contains(&key) { + return Err(format!("leaf {i} repeats a key already in the artifact")); + } + seen.push(key); + let value = leaf + .state + .leaf_value() + .map_err(|e| format!("leaf {i} does not commit: {e:?}"))?; + let derived = root_from_path(&key, &leaf_node(&key, Some(&value)), &leaf.siblings); + if derived != self.economic_root { + return Err(format!( + "leaf {i} does not prove into the root this artifact names" + )); + } + } + Ok(()) + } + + /// The decoded states, for a consumer that knows which leaf it wants. + pub fn states(&self) -> impl Iterator { + self.leaves.iter().map(|l| &l.state) + } +} + +/// Strict decode: canonical re-encode equality, 32-byte coordinates, at least +/// one leaf, exactly 256 fixed 32-byte siblings each, every state decodable. +/// +/// Decoding does NOT check inclusion. A decoded artifact is untrusted bytes +/// until [`EconomicProofArtifact::verify_against`] runs, so no path exists +/// where shape alone reads as proof. +pub fn decode_economic_proof_artifact(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err("empty economic proof artifact".into()); + } + let a = generated::EconomicProofArtifactV1::decode(bytes) + .map_err(|_| "economic proof artifact does not decode".to_string())?; + if a.encode_to_vec() != bytes { + return Err("economic proof artifact is not canonical".into()); + } + if a.leaves.is_empty() { + return Err("an economic proof artifact carries no leaves".into()); + } + let mut leaves = Vec::with_capacity(a.leaves.len()); + for (i, l) in a.leaves.iter().enumerate() { + if l.siblings.len() != ECONOMIC_SMT_HEIGHT { + return Err(format!( + "leaf {i} must carry exactly {ECONOMIC_SMT_HEIGHT} siblings, got {}", + l.siblings.len() + )); + } + let mut siblings = Box::new([[0u8; 32]; ECONOMIC_SMT_HEIGHT]); + for (j, s) in l.siblings.iter().enumerate() { + siblings[j] = digest32(s, &format!("leaf {i} sibling {j}"))?; + } + let state = crate::economic::decode::decode_leaf_state(&l.state_ccb) + .map_err(|e| format!("leaf {i} state: {e}"))?; + leaves.push(EconomicProofLeaf { state, siblings }); + } + Ok(EconomicProofArtifact { + publisher_genesis: digest32(&a.publisher_genesis, "publisher_genesis")?, + publisher_devid: digest32(&a.publisher_devid, "publisher_devid")?, + economic_position: a.economic_position, + economic_root: digest32(&a.economic_root, "economic_root")?, + leaves, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::economic::state::{EconomicBalanceState, EconomicVaultReserveState}; + use crate::economic::tree::EconomicSmt; + + const G: [u8; 32] = [0x61; 32]; + const D: [u8; 32] = [0x62; 32]; + const VAULT: [u8; 32] = [0x63; 32]; + + fn reserve(pc: u8, amount: u64, sequence: u64) -> EconomicLeafState { + EconomicLeafState::VaultReserve(EconomicVaultReserveState { + vault_id: VAULT, + policy_commit: [pc; 32], + amount, + vault_sequence: sequence, + }) + } + + /// A tree holding `states`, and the artifact proving all of them. + fn published(states: &[EconomicLeafState]) -> (EconomicSmt, EconomicProofArtifact) { + let mut tree = EconomicSmt::new(); + for s in states { + tree.insert(s.leaf_key(&G, &D), s.leaf_value().expect("leaf value")); + } + let leaves = states + .iter() + .map(|s| EconomicProofLeaf { + state: s.clone(), + siblings: Box::new(tree.siblings(&s.leaf_key(&G, &D))), + }) + .collect(); + let artifact = + EconomicProofArtifact::new(G, D, 7, tree.root(), leaves).expect("artifact builds"); + (tree, artifact) + } + + /// The whole point: a stranger holding only the publisher's coordinates, + /// position and root — everything the register cell gives it — recomputes + /// every leaf and every path and gets that root back. Round-tripping the + /// bytes changes nothing, because the bytes are all the stranger has. + #[test] + fn an_artifact_proves_its_leaves_into_the_root_a_reader_establishes_itself() { + let (tree, artifact) = published(&[reserve(0xA1, 10_000, 0), reserve(0xB2, 5_000, 0)]); + let bytes = artifact.encode(); + let decoded = decode_economic_proof_artifact(&bytes).expect("decodes"); + decoded + .verify_against(&G, &D, 7, &tree.root()) + .expect("a reader recomputes the named root from the leaves and paths"); + assert_eq!(decoded.states().count(), 2); + } + + /// ONE SNAPSHOT. A path taken before another leaf landed proves into the + /// OLD root, so an artifact that mixes snapshots cannot be built — the + /// producer's own construction check refuses it — and cannot be read. + #[test] + fn leaves_and_paths_from_two_snapshots_cannot_be_published_or_read() { + let a = reserve(0xA1, 10_000, 0); + let b = reserve(0xB2, 5_000, 0); + let mut tree = EconomicSmt::new(); + tree.insert(a.leaf_key(&G, &D), a.leaf_value().expect("v")); + // Path for `a` taken BEFORE `b` lands. + let stale = Box::new(tree.siblings(&a.leaf_key(&G, &D))); + tree.insert(b.leaf_key(&G, &D), b.leaf_value().expect("v")); + let fresh = Box::new(tree.siblings(&b.leaf_key(&G, &D))); + let root = tree.root(); + + let err = EconomicProofArtifact::new( + G, + D, + 7, + root, + vec![ + EconomicProofLeaf { + state: a.clone(), + siblings: stale.clone(), + }, + EconomicProofLeaf { + state: b.clone(), + siblings: fresh.clone(), + }, + ], + ) + .expect_err("a mixed-snapshot artifact must not be constructible"); + assert!(err.contains("does not prove into the root"), "got: {err}"); + + // And the same bytes, assembled by hand, are refused by the reader. + let forged = EconomicProofArtifact { + publisher_genesis: G, + publisher_devid: D, + economic_position: 7, + economic_root: root, + leaves: vec![ + EconomicProofLeaf { + state: a, + siblings: stale, + }, + EconomicProofLeaf { + state: b, + siblings: fresh, + }, + ], + }; + let bytes = forged.encode(); + let err = decode_economic_proof_artifact(&bytes) + .expect("shape is fine") + .verify_against(&G, &D, 7, &root) + .expect_err("the reader must refuse it too"); + assert!(err.contains("does not prove into the root"), "got: {err}"); + } + + /// NO SELF-ASSERTION. The reader's position and root decide what the + /// artifact may be evidence of. An artifact naming another publisher, + /// another position or another root is refused, whatever it contains. + #[test] + fn an_artifact_is_refused_against_coordinates_it_does_not_name() { + let (tree, artifact) = published(&[reserve(0xA1, 10_000, 0)]); + let root = tree.root(); + for (name, e) in [ + ( + "another publisher", + artifact.verify_against(&[0x99; 32], &D, 7, &root), + ), + ( + "another device", + artifact.verify_against(&G, &[0x99; 32], 7, &root), + ), + ( + "another position", + artifact.verify_against(&G, &D, 8, &root), + ), + ( + "another root", + artifact.verify_against(&G, &D, 7, &[0x99; 32]), + ), + ] { + assert!(e.is_err(), "{name} must be refused"); + } + artifact + .verify_against(&G, &D, 7, &root) + .expect("the real coordinates still verify"); + } + + /// A tampered path fails the recomputation, and a leaf presented twice is + /// refused before it can be counted twice. + #[test] + fn a_tampered_path_or_a_repeated_leaf_is_refused() { + let (tree, artifact) = published(&[reserve(0xA1, 10_000, 0), reserve(0xB2, 5_000, 0)]); + let root = tree.root(); + + let mut tampered = artifact.clone(); + tampered.leaves[0].siblings[0][0] ^= 0x01; + let err = tampered + .verify_against(&G, &D, 7, &root) + .expect_err("a tampered sibling must be refused"); + assert!(err.contains("does not prove into the root"), "got: {err}"); + + let mut repeated = artifact.clone(); + repeated.leaves.push(artifact.leaves[0].clone()); + let err = repeated + .verify_against(&G, &D, 7, &root) + .expect_err("a repeated leaf must be refused"); + assert!(err.contains("repeats a key"), "got: {err}"); + } + + /// A leaf the root does not commit at all — the amount it claims is not + /// the amount the tree holds — cannot be dressed up with a real path. + #[test] + fn a_leaf_whose_value_the_root_does_not_commit_is_refused() { + let real = reserve(0xA1, 10_000, 0); + let (tree, _) = published(std::slice::from_ref(&real)); + let inflated = reserve(0xA1, 10_001, 0); + let artifact = EconomicProofArtifact { + publisher_genesis: G, + publisher_devid: D, + economic_position: 7, + economic_root: tree.root(), + leaves: vec![EconomicProofLeaf { + state: inflated, + siblings: Box::new(tree.siblings(&real.leaf_key(&G, &D))), + }], + }; + let err = artifact + .verify_against(&G, &D, 7, &tree.root()) + .expect_err("an amount the root does not commit must be refused"); + assert!(err.contains("does not prove into the root"), "got: {err}"); + } + + /// Decode is strict about shape, and shape alone is never proof: the + /// decode of a well-formed artifact does not check inclusion. + #[test] + fn decode_is_strict_and_does_not_check_inclusion() { + assert!(decode_economic_proof_artifact(&[]).is_err()); + assert!(decode_economic_proof_artifact(&[0xFF, 0xFF]).is_err()); + + let (tree, artifact) = published(&[EconomicLeafState::Balance( + EconomicBalanceState::new([0xC3; 32], 42).expect("balance"), + )]); + let bytes = artifact.encode(); + let mut trailing = bytes.clone(); + trailing.push(0x00); + assert!( + decode_economic_proof_artifact(&trailing).is_err(), + "non-canonical bytes must be refused" + ); + + // Short sibling vector. + let mut short = generated::EconomicProofArtifactV1::decode(bytes.as_slice()).expect("d"); + short.leaves[0].siblings.truncate(255); + let err = decode_economic_proof_artifact(&short.encode_to_vec()) + .expect_err("a short path must be refused"); + assert!(err.contains("exactly 256 siblings"), "got: {err}"); + + // No leaves at all. + let mut empty = generated::EconomicProofArtifactV1::decode(bytes.as_slice()).expect("d"); + empty.leaves.clear(); + assert!(decode_economic_proof_artifact(&empty.encode_to_vec()).is_err()); + + // A well-formed artifact carrying a WRONG root decodes fine; only + // `verify_against` rejects it. Shape is not evidence. + let mut wrong = generated::EconomicProofArtifactV1::decode(bytes.as_slice()).expect("d"); + wrong.economic_root = vec![0x99; 32]; + let decoded = + decode_economic_proof_artifact(&wrong.encode_to_vec()).expect("shape is still valid"); + assert!(decoded.verify_against(&G, &D, 7, &tree.root()).is_err()); + } +} 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 39af9987..65751068 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 @@ -6172,6 +6172,157 @@ mod funded_creation_tests { ); } + /// THE TRANSPORT, END TO END, ON A REAL ADMISSION. + /// + /// A device's register cell publishes WHICH root it committed at a + /// position. It never published which leaves that root commits, so every + /// counterparty that must cite one — a trader proving these reserves — + /// had nothing to fetch. An admitted funded create now publishes an + /// inclusion proof for the externally citable leaves it wrote, and a + /// reader holding only the publisher's coordinates, position and root + /// recomputes them. + /// + /// What is proven here is the reader's side: the artifact is fetched by + /// content address, re-hashed to that address by the fetch, and then + /// every leaf key, commitment and path is recomputed against the root the + /// READER names. The address is a locator; naming a different position or + /// root refuses the same bytes. + #[test] + #[serial_test::serial] + fn an_admitted_create_publishes_an_inclusion_proof_a_stranger_can_verify() { + use prost::Message as _; + + install_identity(); + let owner_dev = participant("owner", 0x47); + let r = owner_dev.router(); + let (pc_a, pc_b) = + crate::sdk::funded_vault_fixture::admitted_device_holding(r, 20_000, 5_000); + let create = generated::DlvInstantiateV1 { + spec: Some(generated::DlvSpecV1 { + policy_digest: Vec::new(), + fulfillment_bytes: amm_fulfillment_bytes(&pc_a, &pc_b, 30), + anchor_enforcement: generated::AnchorEnforcement::Required as i32, + ..Default::default() + }), + creator_public_key: Vec::new(), + signature: Vec::new(), + funding_legs: vec![ + generated::DlvFundingLegV1 { + policy_commit: pc_a.to_vec(), + amount: 10_000, + }, + generated::DlvFundingLegV1 { + policy_commit: pc_b.to_vec(), + amount: 5_000, + }, + ], + }; + let res = crate::runtime::get_runtime().block_on(async { + r.invoke(AppInvoke { + method: "dlv.create".to_string(), + args: pack(create.encode_to_vec()), + }) + .await + }); + assert!(res.success, "create failed: {:?}", res.error_message); + let vault_id = crate::storage::client_db::amm_vault_records::list_amm_vault_records() + .expect("list") + .pop() + .expect("one vault") + .vault_id; + + // What a stranger would establish for itself from the register. + let validated = + crate::sdk::economic_admission_flow::validated_root_or_activate(&r.core_sdk) + .expect("the create admitted a root"); + let head = r.core_sdk.device_head().expect("head"); + let (genesis, devid) = (head.genesis(), head.devid()); + + // The artifact reached the fleet under its own namespace. + let published: Vec> = crate::sdk::storage_io::fake_fleet::put_log() + .into_iter() + .filter(|(_, key, _)| key.starts_with("immutable::DSM/economic-proof-artifact/v1::")) + .filter_map(|(_, key, _)| crate::sdk::storage_io::fake_fleet::any_member_holding(&key)) + .collect(); + assert!( + !published.is_empty(), + "the admitted create must publish an inclusion proof for the reserves it wrote" + ); + let bytes = published.last().expect("one artifact").clone(); + let addr = dsm::storage_object::immutable_inner( + dsm::common::domain_tags::TAG_DSM_ECONOMIC_PROOF_ARTIFACT, + &bytes, + ); + + // THE READER. Fetch by address, verify against coordinates it named. + let artifact = crate::runtime::get_runtime() + .block_on( + crate::sdk::economic_registers::fetch_verified_economic_proof( + &addr, + &genesis, + &devid, + validated.economic_position(), + &validated.economic_root(), + ), + ) + .expect("a stranger verifies the artifact against the registered root"); + + // Both reserve legs are provable, at the create's vault generation. + let mut proven: Vec<([u8; 32], u64, u64)> = artifact + .states() + .filter_map(|s| match s { + dsm::economic::state::EconomicLeafState::VaultReserve(v) => { + assert_eq!(v.vault_id, vault_id); + Some((v.policy_commit, v.amount, v.vault_sequence)) + } + _ => None, + }) + .collect(); + proven.sort(); + let mut want = vec![(pc_a, 10_000u64, 0u64), (pc_b, 5_000u64, 0u64)]; + want.sort(); + assert_eq!( + proven, want, + "both reserve legs are provable at generation 0" + ); + + // Balance leaves are NOT carried: no evidence type asks a stranger to + // prove one, and each path would add 8 KiB to every admission. + assert!( + artifact + .states() + .all(|s| !matches!(s, dsm::economic::state::EconomicLeafState::Balance(_))), + "balance leaves are the device's own state and are deliberately not published" + ); + + // THE LOCATOR IS NOT A WARRANT. The same bytes, read at a position or + // a root the reader did not establish, are refused. + for (name, position, root) in [ + ( + "a position the reader did not establish", + validated.economic_position() + 1, + validated.economic_root(), + ), + ( + "a root the reader did not establish", + validated.economic_position(), + [0x99u8; 32], + ), + ] { + let e = crate::runtime::get_runtime() + .block_on( + crate::sdk::economic_registers::fetch_verified_economic_proof( + &addr, &genesis, &devid, position, &root, + ), + ) + .expect_err(name); + assert!( + format!("{e}").contains("economic proof artifact"), + "{name}: {e}" + ); + } + } + /// One trader's full production settle against `vault_id` at generation /// `seq`, whose reserves the trader believes to be `(ra, rb)`: mirror the /// vault, bind a hop to `(seq, reserves_digest, anchor_digest)`, sign the 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 86efa606..3c1c9e50 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 @@ -769,6 +769,78 @@ pub(crate) async fn admitted_dlv_create_funded( Ok((outcome, admitted)) } +/// Whether a counterparty can ever be asked to verify this leaf's inclusion. +/// +/// Vault reserves fund a trader's settle (0x0026) and settlement receipts +/// fund an owner's apply (0x0027), so both are cited BY ANOTHER DEVICE and +/// need a portable path. A balance leaf is this device's own spendable state +/// and a consumed-source leaf its own spend marker: no evidence type asks a +/// stranger to prove either, and each path costs 8 KiB, so publishing them +/// would grow every admission by that much to prove something nothing reads. +fn leaf_is_externally_citable(state: &dsm::economic::state::EconomicLeafState) -> bool { + use dsm::economic::state::EconomicLeafState as L; + match state { + L::VaultReserve(_) | L::SettlementReceipt(_) => true, + L::Balance(_) | L::ConsumedSource(_) => false, + } +} + +/// The artifact proving every externally citable leaf this transition wrote, +/// or `None` when it wrote none. See the call site for why it is built there. +fn economic_proof_artifact_for( + tree: &EconomicSmt, + witness: &EconomicTransitionWitness, + genesis: &[u8; 32], + devid: &[u8; 32], + validated: &ValidatedEconomicRoot, +) -> Result, &'static str)>, DsmError> { + use dsm::economic::proof_artifact::{EconomicProofArtifact, EconomicProofLeaf}; + let root = validated.economic_root(); + if tree.root() != root { + // Unreachable by construction — the same tree produced this root — + // and stated as a refusal rather than trusted, because a proof taken + // from a tree that is not the registered one is the exact defect + // this object exists to make impossible. + return Err(DsmError::invalid_operation( + "economic proof: the producer tree is not the tree whose root was registered", + )); + } + let mut leaves = Vec::new(); + for m in &witness.mutations { + let Some(state) = &m.post_state else { continue }; + if !leaf_is_externally_citable(state) { + continue; + } + let key = m + .leaf_key(genesis, devid) + .map_err(|e| storage_err("economic proof leaf key", e))?; + leaves.push(EconomicProofLeaf { + state: state.clone(), + siblings: Box::new(tree.siblings(&key)), + }); + } + if leaves.is_empty() { + return Ok(None); + } + let artifact = EconomicProofArtifact::new( + *genesis, + *devid, + validated.economic_position(), + root, + leaves, + ) + .map_err(|e| DsmError::invalid_operation(format!("economic proof: {e}")))?; + let bytes = artifact.encode(); + Ok(Some(( + crate::sdk::economic_registers::immutable_object_key( + dsm::common::domain_tags::TAG_DSM_ECONOMIC_PROOF_ARTIFACT, + &bytes, + ), + bytes, + "economic-proof-artifact", + ))) +} + /// Everything after local acceptance. Separated so recovery re-enters here. #[allow(clippy::too_many_arguments)] pub(crate) async fn finish_admission( @@ -783,7 +855,7 @@ pub(crate) async fn finish_admission( mut pending: PendingEconomicAdmission, // Frozen only in the ADMIT transaction (the RELEASE object): nothing // here may reach the network before ECON_ADMITTED. - post_admit_artifacts: Vec<(String, Vec, &'static str)>, + mut post_admit_artifacts: Vec<(String, Vec, &'static str)>, ) -> Result { let head = core .device_head() @@ -937,9 +1009,30 @@ pub(crate) async fn finish_admission( } } } - let _ = &tree; cache.into_iter().map(|(k, (v, ccb))| (k, v, ccb)).collect() }; + + // ── THE INCLUSION PROOF for the leaves this transition wrote ───────── + // + // The register publishes WHICH root this device committed at this + // position; it says nothing about which leaves that root commits. Every + // counterparty that must cite one of our leaves — a trader proving our + // vault reserves, an owner proving our settlement receipt — needs the + // leaf and its path, and could not get them. + // + // Built HERE, from `tree`, which IS the post-transition tree the write + // set advanced and whose root was just registered. That is the whole + // one-snapshot guarantee: there is no second read, no rebuild and no + // window in which the tree could move between naming the root and + // taking the paths. The equality below states it rather than assuming + // it, and `EconomicProofArtifact::new` re-derives every path against + // that same root before the bytes exist. + if let Some(proof) = + economic_proof_artifact_for(&tree, &witness, &genesis, &devid, &new_validated)? + { + post_admit_artifacts.push(proof); + } + let had_post_admit = !post_admit_artifacts.is_empty(); core.admit_economic_position( new_validated.economic_position(), 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 52e7bb31..7628e985 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 @@ -790,6 +790,42 @@ pub(crate) fn economic_root_path(k_root: &[u8; 32]) -> String { /// The republish-sweep object key for an immutable blob: /// `immutable::{namespace}::{addr_b32}`. Same shape `dlv_routes` uses, so the /// one generic sweep carries faucet evidence too. +/// Fetch an economic proof artifact by content address and verify it against +/// coordinates the caller established INDEPENDENTLY — the publisher's +/// write-once register cell at that position, read at quorum. +/// +/// The address is a locator and nothing more. Wherever it came from (a +/// routing advertisement, an evidence descriptor, a peer message) has no +/// bearing on what the artifact proves: the bytes are re-hashed to the +/// requested identity by the fetch, and then every leaf key, leaf commitment +/// and inclusion path is recomputed against the root the CALLER named. An +/// artifact naming any other publisher, position or root is refused. +pub(crate) async fn fetch_verified_economic_proof( + inner_addr: &[u8; 32], + publisher_genesis: &[u8; 32], + publisher_devid: &[u8; 32], + position: u64, + root: &[u8; 32], +) -> Result { + let payload = crate::sdk::storage_io::fetch_immutable_payload( + dsm::common::domain_tags::TAG_DSM_ECONOMIC_PROOF_ARTIFACT, + inner_addr, + ) + .await? + .ok_or_else(|| { + DsmError::storage( + "economic proof artifact is not published".to_string(), + None::, + ) + })?; + let artifact = dsm::economic::proof_artifact::decode_economic_proof_artifact(&payload) + .map_err(|e| DsmError::verification(format!("economic proof artifact: {e}")))?; + artifact + .verify_against(publisher_genesis, publisher_devid, position, root) + .map_err(|e| DsmError::verification(format!("economic proof artifact: {e}")))?; + Ok(artifact) +} + pub(crate) fn immutable_object_key( namespace: dsm::crypto::domain::TaggedHashDomain<'_>, payload: &[u8], diff --git a/dsm_client/frontend/src/proto/dsm_app_pb.ts b/dsm_client/frontend/src/proto/dsm_app_pb.ts index f123cb5f..733c008c 100644 --- a/dsm_client/frontend/src/proto/dsm_app_pb.ts +++ b/dsm_client/frontend/src/proto/dsm_app_pb.ts @@ -8749,6 +8749,132 @@ export class SettlementPaymentEvidenceV1 extends Message { + /** + * Exact `EconomicLeafState` CCB bytes. The reader derives the leaf key + * from the state's own class and the publisher's coordinates, never from + * a supplied key. + * + * @generated from field: bytes state_ccb = 1; + */ + stateCcb = new Uint8Array(0); + + /** + * Exactly 256, leaf-to-root. + * + * @generated from field: repeated bytes siblings = 2; + */ + siblings: Uint8Array[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "dsm.EconomicProofLeafV1"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "state_ccb", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 2, name: "siblings", kind: "scalar", T: 12 /* ScalarType.BYTES */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): EconomicProofLeafV1 { + return new EconomicProofLeafV1().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): EconomicProofLeafV1 { + return new EconomicProofLeafV1().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): EconomicProofLeafV1 { + return new EconomicProofLeafV1().fromJsonString(jsonString, options); + } + + static equals(a: EconomicProofLeafV1 | PlainMessage | undefined, b: EconomicProofLeafV1 | PlainMessage | undefined): boolean { + return proto3.util.equals(EconomicProofLeafV1, a, b); + } +} + +/** + * @generated from message dsm.EconomicProofArtifactV1 + */ +export class EconomicProofArtifactV1 extends Message { + /** + * @generated from field: bytes publisher_genesis = 1; + */ + publisherGenesis = new Uint8Array(0); + + /** + * @generated from field: bytes publisher_devid = 2; + */ + publisherDevid = new Uint8Array(0); + + /** + * @generated from field: uint64 economic_position = 3; + */ + economicPosition = protoInt64.zero; + + /** + * @generated from field: bytes economic_root = 4; + */ + economicRoot = new Uint8Array(0); + + /** + * @generated from field: repeated dsm.EconomicProofLeafV1 leaves = 5; + */ + leaves: EconomicProofLeafV1[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "dsm.EconomicProofArtifactV1"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "publisher_genesis", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 2, name: "publisher_devid", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 3, name: "economic_position", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, + { no: 4, name: "economic_root", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 5, name: "leaves", kind: "message", T: EconomicProofLeafV1, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): EconomicProofArtifactV1 { + return new EconomicProofArtifactV1().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): EconomicProofArtifactV1 { + return new EconomicProofArtifactV1().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): EconomicProofArtifactV1 { + return new EconomicProofArtifactV1().fromJsonString(jsonString, options); + } + + static equals(a: EconomicProofArtifactV1 | PlainMessage | undefined, b: EconomicProofArtifactV1 | PlainMessage | undefined): boolean { + return proto3.util.equals(EconomicProofArtifactV1, a, b); + } +} + /** * Step 2: the 0x0029 (AuthorizedIssuance) evidence bundle — transport proto, * no CCB class, frozen at `issuance_authorization_addr` under namespace diff --git a/proto/dsm_app.proto b/proto/dsm_app.proto index 2ca670a4..50b09801 100644 --- a/proto/dsm_app.proto +++ b/proto/dsm_app.proto @@ -1247,6 +1247,38 @@ message SettlementPaymentEvidenceV1 { repeated bytes receipt_siblings = 2 [(dsm_fixed_len)=32]; } +// THE GENERIC ECONOMIC-INCLUSION PROOF — transport proto, no CCB class, +// frozen under namespace DSM/economic-proof-artifact/v1. +// +// One artifact carries one publisher, ONE named economic position and root, +// and one or more exact economic leaves each with its 256-sibling inclusion +// path. Every leaf and every path in an artifact comes from the SAME +// validated snapshot: a reader that recomputes them must derive the one +// named root, so a mixed-snapshot artifact cannot verify. +// +// The artifact does not assert its own validity and carries no signature. +// Authority over `economic_root` at `economic_position` belongs to the +// publisher's write-once register cell, which a reader resolves +// independently; this object only lets that reader see WHICH leaves that +// root commits. Anything that points at an artifact — a routing +// advertisement, an evidence descriptor — is a locator, never a warrant. +message EconomicProofLeafV1 { + // Exact `EconomicLeafState` CCB bytes. The reader derives the leaf key + // from the state's own class and the publisher's coordinates, never from + // a supplied key. + bytes state_ccb = 1 [(dsm_max_len)=4096]; + // Exactly 256, leaf-to-root. + repeated bytes siblings = 2 [(dsm_fixed_len)=32]; +} + +message EconomicProofArtifactV1 { + bytes publisher_genesis = 1 [(dsm_fixed_len)=32]; + bytes publisher_devid = 2 [(dsm_fixed_len)=32]; + uint64 economic_position = 3; + bytes economic_root = 4 [(dsm_fixed_len)=32]; + repeated EconomicProofLeafV1 leaves = 5; +} + // Step 2: the 0x0029 (AuthorizedIssuance) evidence bundle — transport proto, // no CCB class, frozen at `issuance_authorization_addr` under namespace // DSM/issuance-authorization-evidence/v1.