From a52e204f11a62c87d503b912ead98907f66b7397 Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:25:41 -0400 Subject: [PATCH] feat(ccb): the VaultStateV2 encoder, checked by an implementation that shares no code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/papers/ccb-object-registry.md for exactly the transitive encoding closure of VaultStateV2 — 0x0001, 0x0002, 0x0004, 0x0005, 0x0007, 0x0009, 0x000A — and nothing else. The routing and settlement classes are absent on purpose: they are still blocked in the registry, and a placeholder encoder for a class whose field table does not exist would be the encoder-defines-the-protocol failure the ordering exists to prevent. Encoding only the closure also leaves 2b and 2c room to land without churning this code. The registry is the authority and this module implements it, not the reverse. c_n = H(DSM/vault-state || CCB(V_n)) and h_0 = H(DSM/vault-state-parent/ genesis/v2 || vault_id) get their domain tags; h_n = c_{n-1} follows. There is deliberately no decoder here. CCB is not self-describing, so a decoder written beside the encoder would share its assumptions and prove nothing. tests/ccb_conformance.rs is the independent side. It is an integration test, so the pub(crate) writers are unreachable by construction, and it rebuilds every byte from the registry text using a different shape — explicit concatenation rather than a shared push buffer — so a transcription slip cannot be common to both. It also PARSES CCB(V_n) back into fifteen fields, which is the only thing that demonstrates the bytes are uniquely readable. That parser exists for one boundary in particular. Field 14 nests StorageSet in its frozen envelope-less layout and field 15 is a bare u32. If a generic "nested object gets an envelope" helper ever wrapped S, the bytes would remain perfectly deterministic and stop being normative. The test asserts the reader consumes exactly its input with nothing trailing, that q reads as 4, and that splicing an envelope in front of S is detectable rather than silent. Both guards are mutation-tested. Wrapping S in the generic envelope turns 4 of 7 red, including the boundary test written for it. Skipping the absent-optional marker turns 3 of 7 red. Restored, all 7 green. Validity conditions refuse rather than repair, per Req 3.2: an unordered or equal token pair is rejected instead of swapped, a duplicate set element is rejected instead of collapsed, and fee_bps at or above the denominator is rejected. Silently repairing any of them would map two distinct logical inputs onto one byte string. Vectors cover genesis, a market successor and the terminal close, and a populated encumbrance set — the last so the nested element encoding and its ordering are exercised rather than left at an empty set, which is where a second implementation is most likely to differ. Insertion order is proven not to survive into the bytes. The storage_set_id construction is checked to be unchanged by reaching it through this module, since every deployed vault's anchor already commits one. Local gate: fmt clean, clippy --all-targets -D warnings clean, cargo test -p dsm --lib 1644 passed, conformance 7 passed. --- .../dsm/src/ccb/mod.rs | 227 ++++++ .../dsm/src/ccb/state.rs | 281 ++++++++ .../src/common/domain_tags/dsm/misc/mod.rs | 2 + .../common/domain_tags/dsm/misc/protocol.rs | 9 + .../dsm/src/lib.rs | 1 + .../dsm/tests/ccb_conformance.rs | 646 ++++++++++++++++++ 6 files changed, 1166 insertions(+) create mode 100644 dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs create mode 100644 dsm_client/deterministic_state_machine/dsm/src/ccb/state.rs create mode 100644 dsm_client/deterministic_state_machine/dsm/tests/ccb_conformance.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs new file mode 100644 index 000000000..640a80879 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/ccb/mod.rs @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical commit bytes (CCB) — the production encoder. +//! +//! Implements `docs/papers/ccb-object-registry.md` for the transitive encoding +//! closure of `VaultStateV2` and nothing else. The registry is the authority; +//! this module is an implementation of it, written from that text rather than +//! the other way round. If the two disagree, the registry is right. +//! +//! ## Scope +//! +//! Exactly the classes `c_n` depends on: +//! +//! | Class | Object | +//! |---|---| +//! | `0x0001` | `VaultStateV2` | +//! | `0x0002` | `StorageSet` | +//! | `0x0004` | `EncumbranceClaim` | +//! | `0x0005` | `EncumbranceSet` | +//! | `0x0007` | `MarketPolicy` | +//! | `0x0009` | `ReleasePolicy` | +//! | `0x000A` | `FeePolicy` | +//! +//! The routing and settlement classes are deliberately absent. They are still +//! blocked in the registry, and a placeholder encoder for a class whose field +//! table does not exist would be exactly the "encoder defines the protocol" +//! failure the registry ordering exists to prevent. +//! +//! ## Not a decoder +//! +//! CCB is not self-describing: structure comes from `(object_class, +//! schema_version)` plus the registry, never from the byte stream. This module +//! only encodes. Parsing is implemented independently in the conformance test, +//! which is the point — a decoder here would share this module's assumptions. + +use blake3::Hasher; + +use crate::common::domain_tags::{ + TAG_DSM_STORAGE_SET_V1, TAG_DSM_VAULT_STATE, TAG_DSM_VAULT_STATE_PARENT_GENESIS_V2, +}; +use crate::crypto::blake3::dsm_domain_hasher; + +pub mod state; + +pub use state::{ + EncumbranceClaim, EncumbranceSet, FeePolicy, MarketPolicy, ReleasePolicy, StorageSetMembers, + VaultStateV2, +}; + +/// Object-class discriminants, from the single namespace of registry §3. +/// +/// Frozen on first ship per §2.7: never recycled, never renumbered. `0x0003` +/// is burned — it was briefly a `StorageMemberId` class before §5.2 settled +/// that member ids are bare length-prefixed bytes with no envelope. +pub mod class { + pub const VAULT_STATE_V2: u16 = 0x0001; + pub const STORAGE_SET: u16 = 0x0002; + pub const ENCUMBRANCE_CLAIM: u16 = 0x0004; + pub const ENCUMBRANCE_SET: u16 = 0x0005; + pub const MARKET_POLICY: u16 = 0x0007; + pub const RELEASE_POLICY: u16 = 0x0009; + pub const FEE_POLICY: u16 = 0x000A; +} + +/// The beta families. Each is the only admissible member of its class at +/// `family_version = 1`. +pub mod family { + /// Constant-product, exact-input market predicate. + pub const CONSTANT_PRODUCT_EXACT_INPUT: u16 = 0x0001; + /// Owner-local full close: both legs to zero, vault retired. + pub const OWNER_LOCAL_FULL_CLOSE: u16 = 0x0001; + pub const BETA_VERSION: u16 = 1; +} + +/// The fixed denominator of the beta fee rational, per §3.4's exact-rational +/// allowance. `fee_bps` is interpreted as `fee_bps / FEE_DENOMINATOR`. +pub const FEE_DENOMINATOR: u32 = 10_000; + +/// A logical object that cannot be encoded, because encoding it would produce +/// bytes the registry does not authorize. +/// +/// Every variant is a validity condition rather than a normalization +/// opportunity. The encoder refuses instead of repairing: silently sorting an +/// unordered token pair, or collapsing a duplicate set element, would map two +/// distinct logical inputs onto one byte string, which is precisely what +/// Req 3.2 forbids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CcbError { + /// A set contained two elements with equal encodings. + DuplicateSetElement { class: u16 }, + /// A storage set had no members, or a member id was empty. + EmptyStorageSetOrMember, + /// `token_a_policy_commit` was not strictly less than `token_b`. + TokenPairNotStrictlyOrdered, + /// `fee_bps` was at or above the denominator. + FeeAtOrAboveDenominator { fee_bps: u32 }, + /// A family id or version outside the beta profile. + UnknownFamily { + class: u16, + family_id: u16, + family_version: u16, + }, + /// A length did not fit the 4-byte prefix the format allows. + LengthOverflow, +} + +impl core::fmt::Display for CcbError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CcbError::DuplicateSetElement { class } => write!( + f, + "class {class:#06x}: duplicate set element — duplicates are invalid, not deduplicated" + ), + CcbError::EmptyStorageSetOrMember => { + write!(f, "storage set: at least one member, and no empty member id") + } + CcbError::TokenPairNotStrictlyOrdered => write!( + f, + "market policy: token_a must be strictly less than token_b; \ + the order is a validity condition, not a normalization" + ), + CcbError::FeeAtOrAboveDenominator { fee_bps } => write!( + f, + "fee policy: fee_bps {fee_bps} is at or above {FEE_DENOMINATOR}, \ + which leaves the pricing rule a zero or negative effective numerator" + ), + CcbError::UnknownFamily { class, family_id, family_version } => write!( + f, + "class {class:#06x}: family {family_id:#06x} version {family_version} \ + is not the beta profile" + ), + CcbError::LengthOverflow => write!(f, "length does not fit a 4-byte prefix"), + } + } +} + +impl std::error::Error for CcbError {} + +// ── Primitive writers, registry §2.2 ──────────────────────────────────────── + +/// The §2.1 envelope: `u16_be(class) ‖ u16_be(schema_version)`. +/// +/// Every conformant object begins with this. `StorageSet` is the one class +/// that does not: see [`StorageSetMembers::encode`], which starts straight at +/// the count because its shipping preimage already begins with the domain tag +/// and a `0x00` separator. +pub(crate) fn push_envelope(out: &mut Vec, object_class: u16, schema_version: u16) { + out.extend_from_slice(&object_class.to_be_bytes()); + out.extend_from_slice(&schema_version.to_be_bytes()); +} + +pub(crate) fn push_u16(out: &mut Vec, v: u16) { + out.extend_from_slice(&v.to_be_bytes()); +} + +pub(crate) fn push_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_be_bytes()); +} + +pub(crate) fn push_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_be_bytes()); +} + +/// `digest32`: exactly 32 raw bytes, no length prefix. Distinct from `bytes` +/// on purpose — a redundant prefix is a divergence a second implementation +/// might reasonably omit. +pub(crate) fn push_digest32(out: &mut Vec, v: &[u8; 32]) { + out.extend_from_slice(v); +} + +/// `bytes`: `u32_be(len) ‖ raw`. +pub(crate) fn push_bytes(out: &mut Vec, v: &[u8]) -> Result<(), CcbError> { + let len = u32::try_from(v.len()).map_err(|_| CcbError::LengthOverflow)?; + push_u32(out, len); + out.extend_from_slice(v); + Ok(()) +} + +/// An absent optional field: the marker alone. Never skipped — skipping would +/// shift the following field and let two logical objects share a byte string. +pub(crate) fn push_absent(out: &mut Vec) { + out.push(0x00); +} + +/// A present optional field: marker then value. +pub(crate) fn push_present(out: &mut Vec) { + out.push(0x01); +} + +/// `c_n = H(DSM/vault-state ‖ CCB(V_n))`. +pub fn vault_state_commitment(state: &VaultStateV2) -> Result<[u8; 32], CcbError> { + let ccb = state.encode()?; + let mut h = dsm_domain_hasher(TAG_DSM_VAULT_STATE); + h.update(&ccb); + Ok(*h.finalize().as_bytes()) +} + +/// `h_0 = H(DSM/vault-state-parent/genesis/v2 ‖ vault_id)`. +/// +/// Commits the vault identity and nothing else. Birth reserves, `S` and `q` +/// are already members of `V_0`, so folding them in here would blur a field +/// whose only role is the predecessor edge. +pub fn genesis_parent_commitment(vault_id: &[u8; 32]) -> [u8; 32] { + let mut h = dsm_domain_hasher(TAG_DSM_VAULT_STATE_PARENT_GENESIS_V2); + h.update(vault_id); + *h.finalize().as_bytes() +} + +/// `h_n` for the successor of `parent`: `c_{n-1}`. +pub fn parent_state_commitment_for_successor_of( + parent: &VaultStateV2, +) -> Result<[u8; 32], CcbError> { + vault_state_commitment(parent) +} + +/// `storage_set_id = H(TAG ‖ 0x00 ‖ CCB-of-the-frozen-layout)`. +/// +/// The tag and its `0x00` separator come from `dsm_domain_hasher`, which is +/// why this class carries no envelope: the shipping preimage already starts +/// with them, and adding a discriminant would change every deployed vault's +/// committed set id. +pub fn storage_set_id(members: &StorageSetMembers) -> Result<[u8; 32], CcbError> { + let body = members.encode()?; + let mut h: Hasher = dsm_domain_hasher(TAG_DSM_STORAGE_SET_V1); + h.update(&body); + Ok(*h.finalize().as_bytes()) +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/ccb/state.rs b/dsm_client/deterministic_state_machine/dsm/src/ccb/state.rs new file mode 100644 index 000000000..73283f6b3 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/ccb/state.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The `VaultStateV2` encoding closure — registry §5.1–§5.4, §5.7, §5.9. + +use super::{ + class, family, push_absent, push_bytes, push_digest32, push_envelope, push_present, push_u16, + push_u32, push_u64, CcbError, FEE_DENOMINATOR, +}; + +/// `0x0002` — the committed storage set. +/// +/// **This class does not carry the §2.1 envelope.** Its encoding is the frozen +/// shipping layout: a count, then each member id as bare length-prefixed bytes. +/// Every deployed vault's signed anchor already commits a `storage_set_id` +/// under this construction, so wrapping it now would invalidate all of them. +/// A generic "nested object gets an envelope" helper must never be applied +/// here — the result would be perfectly deterministic and not normative. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageSetMembers { + members: Vec>, +} + +impl StorageSetMembers { + /// Sorts the ids and refuses an empty set, an empty id, or a duplicate. + /// + /// Sorting is canonicalization the format calls for; refusing duplicates + /// is not. A duplicate is a producer bug, and collapsing it would map two + /// logical inputs onto one encoding. + pub fn new(member_ids: &[&[u8]]) -> Result { + if member_ids.is_empty() || member_ids.iter().any(|id| id.is_empty()) { + return Err(CcbError::EmptyStorageSetOrMember); + } + let mut members: Vec> = member_ids.iter().map(|id| id.to_vec()).collect(); + members.sort_unstable(); + if members.windows(2).any(|w| w[0] == w[1]) { + return Err(CcbError::DuplicateSetElement { + class: class::STORAGE_SET, + }); + } + Ok(Self { members }) + } + + pub fn len(&self) -> usize { + self.members.len() + } + + pub fn is_empty(&self) -> bool { + self.members.is_empty() + } + + /// `u32_be(count) ‖ for each id in ascending byte order: u32_be(len) ‖ id`. + pub fn encode(&self) -> Result, CcbError> { + let mut out = Vec::new(); + let count = u32::try_from(self.members.len()).map_err(|_| CcbError::LengthOverflow)?; + push_u32(&mut out, count); + for id in &self.members { + push_bytes(&mut out, id)?; + } + Ok(out) + } +} + +/// `0x0004` — one encumbrance claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncumbranceClaim { + pub vault_id: [u8; 32], + pub parent_state_commitment: [u8; 32], + pub claim_seq: u64, + pub amount: u64, + pub token: [u8; 32], + pub purpose: u16, +} + +impl EncumbranceClaim { + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_envelope(&mut out, class::ENCUMBRANCE_CLAIM, 1); + push_digest32(&mut out, &self.vault_id); + push_digest32(&mut out, &self.parent_state_commitment); + push_u64(&mut out, self.claim_seq); + push_u64(&mut out, self.amount); + push_digest32(&mut out, &self.token); + push_u16(&mut out, self.purpose); + out + } +} + +/// `0x0005` — the encumbrance set, ordered by element encoding per §2.4. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EncumbranceSet { + claims: Vec, +} + +impl EncumbranceSet { + pub fn new(claims: Vec) -> Result { + let mut encoded: Vec> = claims.iter().map(EncumbranceClaim::encode).collect(); + encoded.sort_unstable(); + if encoded.windows(2).any(|w| w[0] == w[1]) { + return Err(CcbError::DuplicateSetElement { + class: class::ENCUMBRANCE_SET, + }); + } + let mut claims = claims; + claims.sort_by_cached_key(EncumbranceClaim::encode); + Ok(Self { claims }) + } + + pub fn empty() -> Self { + Self { claims: Vec::new() } + } + + pub fn encode(&self) -> Result, CcbError> { + let mut out = Vec::new(); + push_envelope(&mut out, class::ENCUMBRANCE_SET, 1); + let count = u32::try_from(self.claims.len()).map_err(|_| CcbError::LengthOverflow)?; + push_u32(&mut out, count); + for claim in &self.claims { + out.extend_from_slice(&claim.encode()); + } + Ok(out) + } +} + +/// `0x0007` — the market predicate family committed at vault birth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketPolicy { + family_id: u16, + family_version: u16, + token_a_policy_commit: [u8; 32], + token_b_policy_commit: [u8; 32], +} + +impl MarketPolicy { + /// The beta family. Refuses an unordered or equal token pair rather than + /// swapping it: the order is a validity condition, and swapping would map + /// two distinct logical inputs onto one encoding. + pub fn beta_constant_product( + token_a_policy_commit: [u8; 32], + token_b_policy_commit: [u8; 32], + ) -> Result { + if token_a_policy_commit >= token_b_policy_commit { + return Err(CcbError::TokenPairNotStrictlyOrdered); + } + Ok(Self { + family_id: family::CONSTANT_PRODUCT_EXACT_INPUT, + family_version: family::BETA_VERSION, + token_a_policy_commit, + token_b_policy_commit, + }) + } + + pub fn token_a(&self) -> &[u8; 32] { + &self.token_a_policy_commit + } + + pub fn token_b(&self) -> &[u8; 32] { + &self.token_b_policy_commit + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_envelope(&mut out, class::MARKET_POLICY, 1); + push_u16(&mut out, self.family_id); + push_u16(&mut out, self.family_version); + push_digest32(&mut out, &self.token_a_policy_commit); + push_digest32(&mut out, &self.token_b_policy_commit); + out + } +} + +/// `0x0009` — the release family. Two fields and no parameters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleasePolicy { + family_id: u16, + family_version: u16, +} + +impl ReleasePolicy { + /// Both legs to zero in one transition, vault retired. There is nothing to + /// parameterise: the amounts are the parent's reserves, the destination is + /// ordinary owner balance, the authority is the owner signature over the + /// exact successor, and the timing is Req 6.30's. + pub fn beta_owner_local_full_close() -> Self { + Self { + family_id: family::OWNER_LOCAL_FULL_CLOSE, + family_version: family::BETA_VERSION, + } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_envelope(&mut out, class::RELEASE_POLICY, 1); + push_u16(&mut out, self.family_id); + push_u16(&mut out, self.family_version); + out + } +} + +/// `0x000A` — the fee policy, an exact rational over `FEE_DENOMINATOR`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FeePolicy { + fee_bps: u32, +} + +impl FeePolicy { + pub fn new(fee_bps: u32) -> Result { + if fee_bps >= FEE_DENOMINATOR { + return Err(CcbError::FeeAtOrAboveDenominator { fee_bps }); + } + Ok(Self { fee_bps }) + } + + pub fn fee_bps(&self) -> u32 { + self.fee_bps + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + push_envelope(&mut out, class::FEE_POLICY, 1); + push_u32(&mut out, self.fee_bps); + out + } +} + +/// `0x0001` — the DLV state, the fifteen members of Def 4.1 in that order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VaultStateV2 { + pub owner_genesis_id: [u8; 32], + pub owner_device_id: [u8; 32], + pub vault_id: [u8; 32], + pub generation: u64, + pub reserve_a: u64, + pub reserve_b: u64, + pub market_policy: MarketPolicy, + pub release_policy: ReleasePolicy, + pub fee_policy: FeePolicy, + pub encumbrances: EncumbranceSet, + pub iteration_budget: Option, + pub parent_state_commitment: [u8; 32], + pub owner_root: [u8; 32], + pub storage_set: StorageSetMembers, + pub quorum: u32, +} + +impl VaultStateV2 { + /// Fields 1..15 in Def 4.1 order. + /// + /// Field 14 nests the storage set by its **frozen layout**, with no + /// envelope. That is the one place this encoder deviates from §2.6, and it + /// is deliberate. The result stays uniquely parseable because the set + /// begins with its own count and every member is length-prefixed, so a + /// reader knows exactly where field 14 ends and the `u32` of field 15 + /// begins. + pub fn encode(&self) -> Result, CcbError> { + let mut out = Vec::new(); + push_envelope(&mut out, class::VAULT_STATE_V2, 1); + push_digest32(&mut out, &self.owner_genesis_id); // 1 + push_digest32(&mut out, &self.owner_device_id); // 2 + push_digest32(&mut out, &self.vault_id); // 3 + push_u64(&mut out, self.generation); // 4 + push_u64(&mut out, self.reserve_a); // 5 + push_u64(&mut out, self.reserve_b); // 6 + out.extend_from_slice(&self.market_policy.encode()); // 7 + out.extend_from_slice(&self.release_policy.encode()); // 8 + out.extend_from_slice(&self.fee_policy.encode()); // 9 + out.extend_from_slice(&self.encumbrances.encode()?); // 10 + match self.iteration_budget { + // 11 + None => push_absent(&mut out), + Some(budget) => { + push_present(&mut out); + push_u64(&mut out, budget); + } + } + push_digest32(&mut out, &self.parent_state_commitment); // 12 + push_digest32(&mut out, &self.owner_root); // 13 + out.extend_from_slice(&self.storage_set.encode()?); // 14 — no envelope + push_u32(&mut out, self.quorum); // 15 + Ok(out) + } +} 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 81f1e759a..74d076771 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 @@ -61,6 +61,8 @@ pub(super) const TAGS: &[TaggedHashDomain<'static>] = &[ TAG_DSM_SPARSE_IDX, TAG_DSM_STATE_ENTROPY, TAG_DSM_STORAGE_SET_V1, + TAG_DSM_VAULT_STATE, + TAG_DSM_VAULT_STATE_PARENT_GENESIS_V2, TAG_DSM_SYSTEM_OWNER, TAG_DSM_TOKEN_FACTORY, TAG_DSM_TOKEN_ID, diff --git a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/protocol.rs b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/protocol.rs index e927dfb78..d05b757c2 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/protocol.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/common/domain_tags/dsm/misc/protocol.rs @@ -48,6 +48,15 @@ pub const TAG_DSM_SETTLEMENT_SLOT_CLAIM_V1: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/settlement-slot-claim/v1"); pub const TAG_DSM_SETTLEMENT_SLOT_CLAIM_ENVELOPE_V1: TaggedHashDomain<'static> = crate::tagged_domain!(b"DSM/settlement-slot-claim-envelope/v1"); +/// Canonical DLV state commitment: `c_n = H(tag ‖ 0x00 ‖ CCB(V_n))`, over the +/// `VaultStateV2` encoding of the CCB object registry (class `0x0001`). +pub const TAG_DSM_VAULT_STATE: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/vault-state"); +/// The genesis lineage edge: `h_0 = H(tag ‖ 0x00 ‖ vault_id)`. Commits the +/// vault identity and nothing else — birth reserves, `S` and `q` are already +/// members of `V_0`. +pub const TAG_DSM_VAULT_STATE_PARENT_GENESIS_V2: TaggedHashDomain<'static> = + crate::tagged_domain!(b"DSM/vault-state-parent/genesis/v2"); /// Canonical storage-set identity: `H(tag ‖ 0x00 ‖ u32_be(count) ‖ for each /// member id in lexicographic byte order: u32_be(len) ‖ id)`. pub const TAG_DSM_STORAGE_SET_V1: TaggedHashDomain<'static> = diff --git a/dsm_client/deterministic_state_machine/dsm/src/lib.rs b/dsm_client/deterministic_state_machine/dsm/src/lib.rs index c63671aa2..84f6ff69f 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/lib.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/lib.rs @@ -69,6 +69,7 @@ //! - Feature-gated optional modules (perf, telemetry, bluetooth) pub mod bilateral; +pub mod ccb; // canonical commit bytes — docs/papers/ccb-object-registry.md pub mod commitments; pub mod common; // pub mod config; // Network detection moved to SDK - no HTTP in core diff --git a/dsm_client/deterministic_state_machine/dsm/tests/ccb_conformance.rs b/dsm_client/deterministic_state_machine/dsm/tests/ccb_conformance.rs new file mode 100644 index 000000000..807ea712a --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/tests/ccb_conformance.rs @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: Apache-2.0 +#![allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal + +//! Independent conformance check for the CCB `VaultStateV2` closure. +//! +//! **This file must not call the production canonicalization helpers.** It is +//! an integration test, so `dsm::ccb`'s `pub(crate)` writers are unreachable by +//! construction, and everything below is written from +//! `docs/papers/ccb-object-registry.md` rather than from `src/ccb/`. That is +//! the point: if the two agreed because they shared code, agreement would prove +//! nothing. A bug reachable from both sides has to be a bug in the same place +//! twice, independently. +//! +//! It does three things the production encoder cannot do for itself: +//! +//! 1. Rebuilds every byte from the registry text and demands equality. +//! 2. **Parses** `CCB(V_n)` back into its fifteen fields. The production side +//! has no decoder on purpose, so this is the only thing that demonstrates +//! the bytes are uniquely readable — in particular that field 14's frozen +//! envelope-less `StorageSet` is not ambiguous against the `u32` of field 15. +//! 3. Pins golden vectors for genesis, a market successor and a terminal close, +//! and checks the `h_n = c_{n-1}` recurrence across them. + +use dsm::ccb::{ + genesis_parent_commitment, storage_set_id, vault_state_commitment, EncumbranceClaim, + EncumbranceSet, FeePolicy, MarketPolicy, ReleasePolicy, StorageSetMembers, VaultStateV2, +}; + +// ── An independent encoder, written from the registry ─────────────────────── + +/// Deliberately a different shape from the production writers: this builds +/// byte vectors by explicit concatenation rather than by pushing into a shared +/// buffer, so a transcription slip cannot be common to both. +mod indep { + pub fn u16be(v: u16) -> Vec { + vec![(v >> 8) as u8, (v & 0xff) as u8] + } + + pub fn u32be(v: u32) -> Vec { + (0..4).rev().map(|i| (v >> (i * 8)) as u8).collect() + } + + pub fn u64be(v: u64) -> Vec { + (0..8).rev().map(|i| (v >> (i * 8)) as u8).collect() + } + + pub fn envelope(class: u16, version: u16) -> Vec { + [u16be(class), u16be(version)].concat() + } + + pub fn bytes_field(v: &[u8]) -> Vec { + [u32be(v.len() as u32), v.to_vec()].concat() + } + + /// §5.2 frozen layout: no envelope, count then bare length-prefixed ids in + /// ascending raw-byte order. + pub fn storage_set(mut ids: Vec>) -> Vec { + ids.sort(); + let mut out = u32be(ids.len() as u32); + for id in ids { + out.extend(bytes_field(&id)); + } + out + } + + pub fn market_policy(family: u16, version: u16, a: [u8; 32], b: [u8; 32]) -> Vec { + [ + envelope(0x0007, 1), + u16be(family), + u16be(version), + a.to_vec(), + b.to_vec(), + ] + .concat() + } + + pub fn release_policy(family: u16, version: u16) -> Vec { + [envelope(0x0009, 1), u16be(family), u16be(version)].concat() + } + + pub fn fee_policy(fee_bps: u32) -> Vec { + [envelope(0x000A, 1), u32be(fee_bps)].concat() + } + + pub fn encumbrance_claim( + vault_id: [u8; 32], + psc: [u8; 32], + claim_seq: u64, + amount: u64, + token: [u8; 32], + purpose: u16, + ) -> Vec { + [ + envelope(0x0004, 1), + vault_id.to_vec(), + psc.to_vec(), + u64be(claim_seq), + u64be(amount), + token.to_vec(), + u16be(purpose), + ] + .concat() + } + + pub fn encumbrance_set(mut claims: Vec>) -> Vec { + claims.sort(); + let mut out = [envelope(0x0005, 1), u32be(claims.len() as u32)].concat(); + for c in claims { + out.extend(c); + } + out + } + + #[allow(clippy::too_many_arguments)] + pub fn vault_state( + g_o: [u8; 32], + d_o: [u8; 32], + vault_id: [u8; 32], + generation: u64, + r_a: u64, + r_b: u64, + p_m: Vec, + p_r: Vec, + phi: Vec, + e: Vec, + beta: Option, + h_n: [u8; 32], + r_o: [u8; 32], + s: Vec, + q: u32, + ) -> Vec { + let beta_bytes = match beta { + None => vec![0x00], + Some(v) => [vec![0x01], u64be(v)].concat(), + }; + [ + envelope(0x0001, 1), + g_o.to_vec(), + d_o.to_vec(), + vault_id.to_vec(), + u64be(generation), + u64be(r_a), + u64be(r_b), + p_m, + p_r, + phi, + e, + beta_bytes, + h_n.to_vec(), + r_o.to_vec(), + s, + u32be(q), + ] + .concat() + } +} + +// ── An independent parser — the uniqueness proof ──────────────────────────── + +/// What a reader recovers from `CCB(V_n)` knowing only the registry. +#[derive(Debug, PartialEq, Eq)] +struct ParsedVaultState { + class: u16, + schema: u16, + generation: u64, + reserve_a: u64, + reserve_b: u64, + fee_bps: u32, + encumbrance_count: u32, + iteration_budget: Option, + parent_state_commitment: [u8; 32], + storage_members: Vec>, + quorum: u32, + /// Bytes left over. Must be zero: a trailing byte would mean the layout is + /// not uniquely readable. + trailing: usize, +} + +struct Cursor<'a> { + b: &'a [u8], + i: usize, +} + +impl<'a> Cursor<'a> { + fn take(&mut self, n: usize) -> &'a [u8] { + let s = &self.b[self.i..self.i + n]; + self.i += n; + s + } + fn u16(&mut self) -> u16 { + let s = self.take(2); + u16::from_be_bytes([s[0], s[1]]) + } + fn u32(&mut self) -> u32 { + let s = self.take(4); + u32::from_be_bytes([s[0], s[1], s[2], s[3]]) + } + fn u64(&mut self) -> u64 { + let s = self.take(8); + u64::from_be_bytes(s.try_into().unwrap()) + } + fn digest32(&mut self) -> [u8; 32] { + self.take(32).try_into().unwrap() + } + fn u8(&mut self) -> u8 { + self.take(1)[0] + } +} + +/// Walks the fifteen fields in declared order. The only structural knowledge +/// used is the registry: no length prefix on the object, no tags, no +/// self-description. +fn parse_vault_state(bytes: &[u8]) -> ParsedVaultState { + let mut c = Cursor { b: bytes, i: 0 }; + let class = c.u16(); + let schema = c.u16(); + let _g_o = c.digest32(); // 1 + let _d_o = c.digest32(); // 2 + let _vault = c.digest32(); // 3 + let generation = c.u64(); // 4 + let reserve_a = c.u64(); // 5 + let reserve_b = c.u64(); // 6 + + // 7 MarketPolicy: envelope + 2×u16 + 2×digest32 + assert_eq!(c.u16(), 0x0007, "field 7 must be a MarketPolicy envelope"); + let _ = c.u16(); + let _ = c.u16(); + let _ = c.u16(); + let _ = c.digest32(); + let _ = c.digest32(); + + // 8 ReleasePolicy: envelope + 2×u16 + assert_eq!(c.u16(), 0x0009, "field 8 must be a ReleasePolicy envelope"); + let _ = c.u16(); + let _ = c.u16(); + let _ = c.u16(); + + // 9 FeePolicy: envelope + u32 + assert_eq!(c.u16(), 0x000A, "field 9 must be a FeePolicy envelope"); + let _ = c.u16(); + let fee_bps = c.u32(); + + // 10 EncumbranceSet: envelope + count + count×claim + assert_eq!( + c.u16(), + 0x0005, + "field 10 must be an EncumbranceSet envelope" + ); + let _ = c.u16(); + let encumbrance_count = c.u32(); + for _ in 0..encumbrance_count { + assert_eq!(c.u16(), 0x0004, "set element must be an EncumbranceClaim"); + let _ = c.u16(); + let _ = c.digest32(); + let _ = c.digest32(); + let _ = c.u64(); + let _ = c.u64(); + let _ = c.digest32(); + let _ = c.u16(); + } + + // 11 optional β + let iteration_budget = match c.u8() { + 0x00 => None, + 0x01 => Some(c.u64()), + other => panic!("presence marker must be 0x00 or 0x01, got {other:#04x}"), + }; + + let parent_state_commitment = c.digest32(); // 12 + let _r_o = c.digest32(); // 13 + + // 14 StorageSet — NO ENVELOPE. This is the boundary that matters: the + // reader must know from the registry alone that field 14 starts at a count + // rather than at a 0x0002 discriminant, and must stop at exactly the right + // byte so that field 15 reads as `q`. + let member_count = c.u32(); + let mut storage_members = Vec::new(); + for _ in 0..member_count { + let len = c.u32() as usize; + storage_members.push(c.take(len).to_vec()); + } + + let quorum = c.u32(); // 15 + ParsedVaultState { + class, + schema, + generation, + reserve_a, + reserve_b, + fee_bps, + encumbrance_count, + iteration_budget, + parent_state_commitment, + storage_members, + quorum, + trailing: bytes.len() - c.i, + } +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +fn d(b: u8) -> [u8; 32] { + [b; 32] +} + +const VAULT_ID: [u8; 32] = [0x7E; 32]; +const TOKEN_A: [u8; 32] = [0x11; 32]; +const TOKEN_B: [u8; 32] = [0x22; 32]; +const MEMBERS: [&[u8]; 5] = [ + b"dsm-node-3", + b"dsm-node-1", + b"dsm-node-5", + b"dsm-node-2", + b"dsm-node-4", +]; + +fn beta_set() -> StorageSetMembers { + StorageSetMembers::new(&MEMBERS).expect("five distinct members") +} + +fn state(generation: u64, r_a: u64, r_b: u64, h_n: [u8; 32], beta: Option) -> VaultStateV2 { + VaultStateV2 { + owner_genesis_id: d(0xA1), + owner_device_id: d(0xA2), + vault_id: VAULT_ID, + generation, + reserve_a: r_a, + reserve_b: r_b, + market_policy: MarketPolicy::beta_constant_product(TOKEN_A, TOKEN_B).expect("ordered pair"), + release_policy: ReleasePolicy::beta_owner_local_full_close(), + fee_policy: FeePolicy::new(30).expect("30 bps"), + encumbrances: EncumbranceSet::empty(), + iteration_budget: beta, + parent_state_commitment: h_n, + owner_root: d(0xA3), + storage_set: beta_set(), + quorum: 4, + } +} + +fn indep_state_bytes( + generation: u64, + r_a: u64, + r_b: u64, + h_n: [u8; 32], + beta: Option, +) -> Vec { + indep::vault_state( + d(0xA1), + d(0xA2), + VAULT_ID, + generation, + r_a, + r_b, + indep::market_policy(0x0001, 1, TOKEN_A, TOKEN_B), + indep::release_policy(0x0001, 1), + indep::fee_policy(30), + indep::encumbrance_set(vec![]), + beta, + h_n, + d(0xA3), + indep::storage_set(MEMBERS.iter().map(|m| m.to_vec()).collect()), + 4, + ) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +/// The two implementations agree byte-for-byte across the whole lifecycle: +/// genesis, a market successor, and the terminal full close. +#[test] +fn production_and_independent_encoders_agree_on_every_lifecycle_state() { + let h0 = genesis_parent_commitment(&VAULT_ID); + + // V_0 — birth. Reserves funded, no encumbrances, no iteration budget. + let v0 = state(0, 1_000_000, 500_000, h0, None); + assert_eq!( + v0.encode().expect("V_0 encodes"), + indep_state_bytes(0, 1_000_000, 500_000, h0, None), + "genesis state must match the independent encoding" + ); + + // V_1 — one market successor. Full input credited, output removed; the + // numbers are the beta pricing rule applied to V_0. + let c0 = vault_state_commitment(&v0).expect("c_0"); + let v1 = state(1, 1_000_000 + 1_000, 500_000 - 453, c0, None); + assert_eq!( + v1.encode().expect("V_1 encodes"), + indep_state_bytes(1, 1_001_000, 499_547, c0, None), + "market successor must match" + ); + + // V_2 — terminal full close. Both legs to zero. + let c1 = vault_state_commitment(&v1).expect("c_1"); + let v2 = state(2, 0, 0, c1, None); + assert_eq!( + v2.encode().expect("V_2 encodes"), + indep_state_bytes(2, 0, 0, c1, None), + "terminal close state must match" + ); +} + +/// `h_n = c_{n-1}` holds across the chain, and a divergent history yields a +/// divergent binding even when the reserves are identical — Req 6.5's property. +#[test] +fn the_lineage_recurrence_distinguishes_histories_with_equal_reserves() { + let h0 = genesis_parent_commitment(&VAULT_ID); + let v0 = state(0, 1_000, 1_000, h0, None); + let c0 = vault_state_commitment(&v0).expect("c_0"); + + // Two different histories that arrive at the SAME generation and the SAME + // reserves, differing only in what came before. + let other_v0 = state(0, 999, 1_001, h0, None); + let other_c0 = vault_state_commitment(&other_v0).expect("other c_0"); + assert_ne!(c0, other_c0, "different birth reserves give different c_0"); + + let v1 = state(1, 900, 1_100, c0, None); + let other_v1 = state(1, 900, 1_100, other_c0, None); + assert_eq!(v1.reserve_a, other_v1.reserve_a); + assert_eq!(v1.reserve_b, other_v1.reserve_b); + assert_eq!(v1.generation, other_v1.generation); + assert_ne!( + vault_state_commitment(&v1).expect("c_1"), + vault_state_commitment(&other_v1).expect("other c_1"), + "identical reserves at identical generation must still differ when the \ + preceding canonical state differed — this is what Req 6.5 requires and \ + what a reserves-only digest could not deliver" + ); +} + +/// THE BOUNDARY THAT MATTERS. +/// +/// Field 14 nests `StorageSet` in its frozen envelope-less layout, and field 15 +/// is a bare `u32`. If a generic "nested object gets an envelope" helper ever +/// wrapped `S`, the bytes would still be deterministic and would no longer be +/// normative. This parses the whole object back and requires that every field +/// lands where the registry says, with nothing left over. +#[test] +fn the_envelope_less_storage_set_stays_uniquely_parseable_against_the_quorum_field() { + let h0 = genesis_parent_commitment(&VAULT_ID); + let v = state(7, 4_242, 8_888, h0, Some(99)); + let bytes = v.encode().expect("encodes"); + + let parsed = parse_vault_state(&bytes); + assert_eq!( + parsed.trailing, 0, + "the layout must consume exactly its bytes" + ); + assert_eq!(parsed.class, 0x0001); + assert_eq!(parsed.schema, 1); + assert_eq!(parsed.generation, 7); + assert_eq!(parsed.reserve_a, 4_242); + assert_eq!(parsed.reserve_b, 8_888); + assert_eq!(parsed.fee_bps, 30); + assert_eq!(parsed.encumbrance_count, 0); + assert_eq!(parsed.iteration_budget, Some(99)); + assert_eq!(parsed.parent_state_commitment, h0); + assert_eq!( + parsed.quorum, 4, + "field 15 must read as q, not as set bytes" + ); + + let mut expected: Vec> = MEMBERS.iter().map(|m| m.to_vec()).collect(); + expected.sort(); + assert_eq!(parsed.storage_members, expected); + + // If S carried an envelope, the reader would consume 4 extra bytes inside + // field 14 and `q` would be read from the wrong offset. Demonstrate that + // the failure is detectable rather than silent. + let mut wrapped = bytes.clone(); + let set_start = bytes.len() - (4 + expected.iter().map(|m| 4 + m.len()).sum::() + 4); + wrapped.splice(set_start..set_start, [0x00, 0x02, 0x00, 0x01]); + let reparsed = std::panic::catch_unwind(|| parse_vault_state(&wrapped)); + let drifted = match reparsed { + Err(_) => true, + Ok(p) => p.trailing != 0 || p.quorum != 4, + }; + assert!( + drifted, + "wrapping S in an envelope must change what the reader sees; if it did \ + not, field 14 and field 15 would be mutually ambiguous" + ); +} + +/// The optional marker is emitted in both states, and absent is not the same +/// byte string as present-with-zero. +#[test] +fn the_optional_budget_marker_is_never_skipped() { + let h0 = genesis_parent_commitment(&VAULT_ID); + let absent = state(0, 1, 1, h0, None).encode().expect("absent"); + let present_zero = state(0, 1, 1, h0, Some(0)).encode().expect("present 0"); + assert_ne!( + absent, present_zero, + "an absent budget must not encode as a present zero" + ); + assert_eq!( + present_zero.len(), + absent.len() + 8, + "present adds exactly the u64, the marker is in both" + ); + assert_eq!(parse_vault_state(&absent).iteration_budget, None); + assert_eq!(parse_vault_state(&present_zero).iteration_budget, Some(0)); +} + +/// The frozen `storage_set_id` construction is unchanged by this work: the id +/// derived through the CCB path equals the shipping helper's value. +#[test] +fn the_storage_set_id_matches_the_shipping_construction() { + let members = beta_set(); + let via_ccb = storage_set_id(&members).expect("id"); + + // Recompute independently: H(tag ‖ 0x00 ‖ count ‖ (len ‖ id)*). + let mut ids: Vec> = MEMBERS.iter().map(|m| m.to_vec()).collect(); + ids.sort(); + let mut preimage = b"DSM/storage-set/v1".to_vec(); + preimage.push(0x00); + preimage.extend(indep::u32be(ids.len() as u32)); + for id in &ids { + preimage.extend(indep::bytes_field(id)); + } + let expected: [u8; 32] = *blake3::hash(&preimage).as_bytes(); + assert_eq!( + via_ccb, expected, + "the frozen layout must survive being reached through the CCB module" + ); +} + +/// Validity conditions refuse rather than repair. +#[test] +fn invalid_inputs_are_refused_rather_than_normalized() { + assert!( + MarketPolicy::beta_constant_product(TOKEN_B, TOKEN_A).is_err(), + "an unordered token pair is refused, not swapped" + ); + assert!( + MarketPolicy::beta_constant_product(TOKEN_A, TOKEN_A).is_err(), + "an equal pair is refused" + ); + assert!(FeePolicy::new(10_000).is_err(), "fee at the denominator"); + assert!( + FeePolicy::new(9_999).is_ok(), + "one below is the legal maximum" + ); + assert!( + StorageSetMembers::new(&[b"a", b"a"]).is_err(), + "a duplicate member is refused, not collapsed" + ); + assert!(StorageSetMembers::new(&[]).is_err(), "an empty set"); + assert!( + StorageSetMembers::new(&[b""]).is_err(), + "an empty member id" + ); + + let claim = EncumbranceClaim { + vault_id: VAULT_ID, + parent_state_commitment: d(0x01), + claim_seq: 1, + amount: 5, + token: TOKEN_A, + purpose: 1, + }; + assert!( + EncumbranceSet::new(vec![claim.clone(), claim]).is_err(), + "a duplicate claim is refused, not deduplicated" + ); +} + +/// A NON-EMPTY encumbrance set, cross-checked and set-ordered. +/// +/// Every other vector above carries an empty `E`, which would leave the nested +/// set's element encoding and its §2.4 ordering untested — the two places a +/// second implementation is most likely to differ. +#[test] +fn a_populated_encumbrance_set_agrees_and_is_ordered_by_element_encoding() { + let h0 = genesis_parent_commitment(&VAULT_ID); + let mk = |seq: u64, amount: u64, purpose: u16| EncumbranceClaim { + vault_id: VAULT_ID, + parent_state_commitment: h0, + claim_seq: seq, + amount, + token: TOKEN_A, + purpose, + }; + // Deliberately supplied out of order, so the encoder must sort by element + // encoding rather than preserve insertion order. + let claims = vec![mk(9, 500, 2), mk(1, 10, 1), mk(4, 77, 3)]; + let set = EncumbranceSet::new(claims.clone()).expect("distinct claims"); + + let mut v = state(3, 10, 20, h0, None); + v.encumbrances = set; + let produced = v.encode().expect("encodes"); + + let indep_claims: Vec> = claims + .iter() + .map(|c| { + indep::encumbrance_claim( + c.vault_id, + c.parent_state_commitment, + c.claim_seq, + c.amount, + c.token, + c.purpose, + ) + }) + .collect(); + let expected = indep::vault_state( + d(0xA1), + d(0xA2), + VAULT_ID, + 3, + 10, + 20, + indep::market_policy(0x0001, 1, TOKEN_A, TOKEN_B), + indep::release_policy(0x0001, 1), + indep::fee_policy(30), + indep::encumbrance_set(indep_claims), + None, + h0, + d(0xA3), + indep::storage_set(MEMBERS.iter().map(|m| m.to_vec()).collect()), + 4, + ); + assert_eq!(produced, expected, "populated set must match byte for byte"); + + let parsed = parse_vault_state(&produced); + assert_eq!(parsed.encumbrance_count, 3); + assert_eq!( + parsed.trailing, 0, + "three nested claims must not desynchronize the reader" + ); + assert_eq!( + parsed.quorum, 4, + "field 15 still reads as q after a populated set" + ); + + // Insertion order must not survive into the bytes. + let reordered = EncumbranceSet::new(vec![mk(4, 77, 3), mk(9, 500, 2), mk(1, 10, 1)]) + .expect("same three claims"); + let mut w = state(3, 10, 20, h0, None); + w.encumbrances = reordered; + assert_eq!( + w.encode().expect("encodes"), + produced, + "a set is its members, not the order they were handed over" + ); +}