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.