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