From 50065a7db7a7a70d22473e74e938b3d01774ded8 Mon Sep 17 00:00:00 2001 From: ristik Date: Thu, 25 Jun 2026 13:44:01 +0300 Subject: [PATCH 1/3] token-split-2 --- Cargo.lock | 1 - Cargo.toml | 5 +- README.md | 26 +- examples/split.rs | 14 +- src/lib.rs | 6 +- src/payment/asset.rs | 201 +++++++---- src/payment/commitment.rs | 74 ++++ src/payment/justification.rs | 42 ++- src/payment/manifest.rs | 131 +++++++ src/payment/mod.rs | 27 +- src/payment/proof.rs | 68 ---- src/payment/split.rs | 164 +++++---- src/payment/tests.rs | 260 ++++++++++---- src/payment/verifier.rs | 205 +++++------ src/rsmst/build.rs | 337 ++++++++++++++++++ src/rsmst/mod.rs | 201 +++++++++++ src/rsmst/proof.rs | 213 +++++++++++ src/smt/bigint.rs | 94 ----- src/smt/mod.rs | 93 ----- src/smt/plain.rs | 569 ----------------------------- src/smt/sum.rs | 670 ----------------------------------- src/verify/error.rs | 88 ++--- 22 files changed, 1575 insertions(+), 1914 deletions(-) create mode 100644 src/payment/commitment.rs create mode 100644 src/payment/manifest.rs delete mode 100644 src/payment/proof.rs create mode 100644 src/rsmst/build.rs create mode 100644 src/rsmst/mod.rs create mode 100644 src/rsmst/proof.rs delete mode 100644 src/smt/bigint.rs delete mode 100644 src/smt/mod.rs delete mode 100644 src/smt/plain.rs delete mode 100644 src/smt/sum.rs diff --git a/Cargo.lock b/Cargo.lock index a1e1530..63e6135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,6 @@ dependencies = [ "hex-literal", "k256", "num-bigint", - "num-traits", "serde_json", "sha2", "ureq", diff --git a/Cargo.toml b/Cargo.toml index 1e75bb1..7524bc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,9 @@ http = ["std", "client", "dep:ureq", "dep:url", "dep:zeroize"] sha2 = { version = "0.10", default-features = false } k256 = { version = "0.13", default-features = false, features = ["ecdsa", "alloc"] } hex = { version = "0.4", default-features = false, features = ["alloc"] } -# Arbitrary-precision unsigned integers for sparse-Merkle-tree path routing -# (the plain/sum trees used by token split). no_std + alloc, no host deps. +# Arbitrary-precision unsigned integers for 256-bit asset amounts and the radix +# sparse Merkle sum tree sums used by token split. no_std + alloc, no host deps. num-bigint = { version = "0.4", default-features = false } -num-traits = { version = "0.2", default-features = false } getrandom = { version = "0.2", optional = true } serde_json = { version = "1", optional = true } ureq = { version = "2", optional = true } diff --git a/README.md b/README.md index 770f7f6..8d18a93 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,15 @@ carries either is rejected unless you opt in to a verifier for it. ## Payment, assets & token split -A token can carry a fungible **payment payload** — a `PaymentAssetCollection` -(asset id → amount) — in its mint `data`, and a token can be **split** into -several new tokens whose assets sum to the original. The source token is burned -to an aggregation-tree root, and each output's genesis carries a -`SplitMintJustification` (CBOR tag 39044) proving its share. +A token can carry a fungible **payment payload** — a `PaymentAssetCollection`, a +non-empty canonical set of (asset id → amount) entries — in its mint `data`, and +a token can be **split** into several new tokens whose per-asset allocations sum +to the original. The split builds one **radix sparse Merkle sum tree** (RSMST) +per asset, keyed by output token id; the per-asset root hashes form a +`SplitManifest` (CBOR tag 39046) that the source token is burned to (the burn +predicate's reason is `SHA-256` of the manifest, stored as the burn transfer's +auxiliary data). Each output's genesis carries a `SplitMintJustification` +(CBOR tag 39044) with one RSMST allocation proof per asset, proving its share. Verifying payment tokens is **fail-closed and policy-gated**: cryptographic certification alone never authorizes an asset issuer, so you must register the @@ -67,11 +71,13 @@ let assets = verify_payment_token( Constructing a split (mint → split → mint outputs) is a `client`-feature operation via `payment::TokenSplit::split`. The verifier independently re-checks -value conservation, so client-side construction is untrusted. The split proofs -ride on bigint-routed sparse Merkle trees in the `smt` module; their decoding is -bounded by the `MAX_SMT_*` limits and the cumulative `VerificationPolicy` / -`VerificationLimits` (embedded-token depth and byte budgets) to stay safe on -attacker-controlled input. See `src/payment/tests.rs` for an end-to-end example. +value conservation — every output's RSMST proof must reconstruct a root sum +equal to the authenticated source amount — so client-side construction is +untrusted. The split proofs ride on the radix sparse Merkle sum trees in the +`rsmst` module; their decoding is bounded by the per-proof limits and the +cumulative `VerificationPolicy` / `VerificationLimits` (embedded-token depth and +byte budgets) to stay safe on attacker-controlled input. See +`src/payment/tests.rs` for an end-to-end example. ## Feature Flags diff --git a/examples/split.rs b/examples/split.rs index db8d232..390baf2 100644 --- a/examples/split.rs +++ b/examples/split.rs @@ -3,9 +3,10 @@ //! //! Mints a coin token carrying a fungible payment payload (300 EUR + 500 USD), //! splits it into three new coins (150 EUR, 150 EUR, 500 USD), burns the -//! original, mints each output with a `SplitMintJustification`, and verifies the -//! outputs **fail-closed** via [`payment::verify_payment_token`]. Each minted -//! output's CBOR is printed as hex, like the model example. +//! original to its split-manifest burn predicate, mints each output with a +//! `SplitMintJustification`, and verifies the outputs **fail-closed** via +//! [`payment::verify_payment_token`]. Each minted output's CBOR is printed as +//! hex, like the model example. //! //! Connection parameters come from `e2e/.env` (see `e2e/.env.example`). //! @@ -211,8 +212,9 @@ fn main() { ) .expect("build split"); - // Burn the source coin to the split's aggregation-root burn predicate, using - // the same state mask so the certified burn matches the split proofs. + // Burn the source coin to the split's manifest burn predicate, storing the + // exact manifest bytes as the burn transfer's auxiliary data and reusing the + // same state mask so the certified burn matches the split proofs. let burnt = client::transfer( &aggregator, &trust_base, @@ -220,7 +222,7 @@ fn main() { &split.burn.owner_predicate, &alice, StateMask::from_bytes(BURN_STATE_MASK), - None, + Some(split.burn.manifest.clone()), ) .expect("burn source coin"); diff --git a/src/lib.rs b/src/lib.rs index 8abd2a7..67cce5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,8 +22,8 @@ //! [`payment`] module adds the fungible-asset payload and token-split subsystem: //! verify such tokens fail-closed (policy-gated) with //! [`payment::verify_payment_token`], and construct splits (under the `client` -//! feature) with `payment::TokenSplit`. The split inclusion proofs use the -//! bigint-routed sparse Merkle trees in [`smt`]. +//! feature) with `payment::TokenSplit`. The split inclusion proofs use the radix +//! sparse Merkle sum trees in [`rsmst`]. //! //! [`Token::verify`]: crate::transaction::Token::verify //! [`RootTrustBase`]: crate::api::bft::RootTrustBase @@ -42,7 +42,7 @@ pub mod crypto; pub mod error; pub mod payment; pub mod predicate; -pub mod smt; +pub mod rsmst; pub mod transaction; pub mod verify; diff --git a/src/payment/asset.rs b/src/payment/asset.rs index cf7a946..f8fda33 100644 --- a/src/payment/asset.rs +++ b/src/payment/asset.rs @@ -1,24 +1,40 @@ -//! Assets: a fungible value tagged with an [`AssetId`], and the payment -//! collection a token carries. Mirrors `payment/asset/*` in the reference SDKs. +//! The fungible payload a token carries: an [`Asset`] is an amount tagged with +//! an [`AssetId`], and a [`PaymentAssetCollection`] is the canonical set of them. +//! +//! This module implements the yellowpaper `Assets(ty, auxd')` function for the +//! default payload form (the mint `data` is exactly the encoded collection): the +//! decoded collection is non-empty, holds at most 256 distinct assets in strict +//! canonical asset-id order, with every amount in `1..2^256`. A payload with a +//! zero amount, a duplicate or out-of-order id, or an empty id is **rejected**, +//! never normalized — split verification associates proofs with assets purely by +//! this canonical order. -use alloc::collections::BTreeSet; use alloc::vec::Vec; use num_bigint::BigUint; use crate::cbor::{encode_array, encode_byte_string, Decoder}; -use crate::error::Error; -use crate::smt::bigint::{bytes_to_path, key_to_path, path_to_bytes}; +use crate::error::{CborError, Error}; +use crate::rsmst::{decode_positive_amount, encode_amount, AMOUNT_MAX_BYTES}; /// Maximum encoded asset identifier length accepted by the payment protocol. pub const MAX_ASSET_ID_BYTES: usize = 128; /// Maximum encoded asset amount length (256 bits). -pub const MAX_ASSET_VALUE_BYTES: usize = 32; +pub const MAX_ASSET_VALUE_BYTES: usize = AMOUNT_MAX_BYTES; /// Maximum number of distinct assets carried by one token. pub const MAX_PAYMENT_ASSETS: usize = 256; -/// Identifier of an asset class. Arbitrary-length opaque bytes; routes a sum -/// tree in the aggregation tree of a split. +/// Unsigned lexicographic comparison with a shorter byte string ordered first +/// when it is a prefix of another. This is the canonical asset-id ordering. +fn canonical_cmp(a: &[u8], b: &[u8]) -> core::cmp::Ordering { + let common = a.len().min(b.len()); + match a[..common].cmp(&b[..common]) { + core::cmp::Ordering::Equal => a.len().cmp(&b.len()), + other => other, + } +} + +/// Identifier of an asset class: 1..=128 opaque bytes. #[derive(Clone, PartialEq, Eq, Hash)] pub struct AssetId(Vec); @@ -33,19 +49,17 @@ impl AssetId { &self.0 } - /// The sparse-Merkle routing path of this id (`0x01 ‖ bytes` big-endian). - pub fn to_path(&self) -> BigUint { - key_to_path(&self.0) - } - /// CBOR byte string. pub fn to_cbor(&self) -> Vec { encode_byte_string(&self.0) } - /// Decode from a CBOR byte string. + /// Decode from a CBOR byte string, enforcing the non-empty / length bounds. pub fn from_cbor(d: Decoder<'_>) -> Result { let bytes = d.bytes_value()?; + if bytes.is_empty() { + return Err(Error::OutOfRange("asset id must be non-empty")); + } if bytes.len() > MAX_ASSET_ID_BYTES { return Err(Error::OutOfRange("asset id exceeds protocol limit")); } @@ -59,7 +73,7 @@ impl core::fmt::Debug for AssetId { } } -/// An [`AssetId`] paired with a non-negative value. +/// An [`AssetId`] paired with a strictly positive amount (`1 <= v < 2^256`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Asset { id: AssetId, @@ -77,62 +91,66 @@ impl Asset { &self.id } - /// The value held. + /// The amount held. pub fn value(&self) -> &BigUint { &self.value } - /// Decode from CBOR: `[bstr(id), bstr(value)]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { + /// Decode from CBOR: `[bstr(id), bstr(value)]` with a positive minimal value. + fn from_cbor(d: Decoder<'_>) -> Result { let items = d.array(Some(2))?; let id = AssetId::from_cbor(items[0])?; - let value_bytes = items[1].bytes_value()?; - if value_bytes.len() > MAX_ASSET_VALUE_BYTES { - return Err(Error::OutOfRange("asset value exceeds 256 bits")); - } - let value = bytes_to_path(value_bytes)?; + let value = decode_positive_amount(items[1].bytes_value()?)?; Ok(Asset { id, value }) } /// Encode to CBOR: `[bstr(id), bstr(value)]`. - pub fn to_cbor(&self) -> Vec { - encode_array(&[ - &self.id.to_cbor(), - &encode_byte_string(&path_to_bytes(&self.value)), - ]) + fn to_cbor(&self) -> Vec { + encode_array(&[&self.id.to_cbor(), &encode_byte_string(&encode_amount(&self.value))]) } } -/// An id-keyed collection of [`Asset`]s, preserving insertion order. Duplicate -/// asset ids are rejected at construction. -#[derive(Clone, Debug, PartialEq, Eq, Default)] +/// A canonical, id-ordered collection of [`Asset`]s — the result of the +/// `Assets(ty, auxd')` function. Always non-empty, at most 256 assets, distinct +/// ids in strict canonical order, every amount positive. +#[derive(Clone, Debug, PartialEq, Eq)] pub struct PaymentAssetCollection { assets: Vec, } impl PaymentAssetCollection { - /// Build a collection, rejecting duplicate asset ids. + /// Build a collection from arbitrary-order assets, validating every field + /// and canonicalizing the order. Rejects an empty input, a duplicate id, an + /// over-long id, or a non-positive / over-256-bit amount. pub fn create(assets: impl IntoIterator) -> Result { - let mut out: Vec = Vec::new(); - let mut ids = BTreeSet::new(); - for asset in assets { - if out.len() == MAX_PAYMENT_ASSETS { - return Err(Error::OutOfRange( - "payment asset count exceeds protocol limit", - )); + let mut out: Vec = assets.into_iter().collect(); + if out.is_empty() { + return Err(Error::OutOfRange("payment collection must be non-empty")); + } + if out.len() > MAX_PAYMENT_ASSETS { + return Err(Error::OutOfRange( + "payment asset count exceeds protocol limit", + )); + } + for asset in &out { + if asset.id.0.is_empty() { + return Err(Error::OutOfRange("asset id must be non-empty")); } - if asset.id.bytes().len() > MAX_ASSET_ID_BYTES { + if asset.id.0.len() > MAX_ASSET_ID_BYTES { return Err(Error::OutOfRange("asset id exceeds protocol limit")); } - if asset.value.to_bytes_be().len() > MAX_ASSET_VALUE_BYTES { - return Err(Error::OutOfRange("asset value exceeds 256 bits")); + if asset.value == BigUint::ZERO { + return Err(Error::OutOfRange("asset amount must be positive")); } - if !ids.insert(asset.id.bytes().to_vec()) { - return Err(Error::UnexpectedValue( - "duplicate asset id in payment collection", - )); + if asset.value.bits() > 256 { + return Err(Error::OutOfRange("asset amount exceeds 256 bits")); } - out.push(asset); + } + out.sort_by(|a, b| canonical_cmp(a.id.bytes(), b.id.bytes())); + if out.windows(2).any(|w| w[0].id == w[1].id) { + return Err(Error::UnexpectedValue( + "duplicate asset id in payment collection", + )); } Ok(PaymentAssetCollection { assets: out }) } @@ -147,38 +165,57 @@ impl PaymentAssetCollection { self.assets.len() } - /// Whether the collection contains no assets. + /// Always `false` (a collection is never empty); present for lint parity. pub fn is_empty(&self) -> bool { self.assets.is_empty() } - /// The assets in insertion order. + /// The assets in canonical id order. pub fn as_slice(&self) -> &[Asset] { &self.assets } - /// Decode from CBOR: `[asset...]`. + /// Decode the `Assets` function from CBOR: `[asset...]` in strict canonical + /// order. Rejects an empty array, more than 256 assets, a non-canonical or + /// duplicate id ordering, an empty id, or a non-positive amount. pub fn from_cbor(d: Decoder<'_>) -> Result { let items = d.array(None)?; + if items.is_empty() { + return Err(Error::OutOfRange("payment collection must be non-empty")); + } if items.len() > MAX_PAYMENT_ASSETS { return Err(Error::OutOfRange( "payment asset count exceeds protocol limit", )); } - let mut assets = Vec::new(); - for asset in items { - assets.push(Asset::from_cbor(asset)?); + let mut assets: Vec = Vec::with_capacity(items.len()); + for item in items { + let asset = Asset::from_cbor(item)?; + if let Some(previous) = assets.last() { + match canonical_cmp(previous.id.bytes(), asset.id.bytes()) { + core::cmp::Ordering::Less => {} + core::cmp::Ordering::Equal => { + return Err(Error::UnexpectedValue("duplicate asset id")) + } + core::cmp::Ordering::Greater => { + return Err(CborError::NonCanonicalEncoding.into()) + } + } + } + assets.push(asset); } - PaymentAssetCollection::create(assets) + Ok(PaymentAssetCollection { assets }) } - /// Decode directly from CBOR bytes (the default payment-data form, where a - /// token's mint `data` is exactly the encoded collection). + /// Decode directly from CBOR bytes (the default payload form, where a token's + /// mint `data` is exactly the encoded collection). pub fn from_cbor_bytes(bytes: &[u8]) -> Result { - Self::from_cbor(Decoder::new(bytes)) + let d = Decoder::new(bytes); + d.finish()?; + Self::from_cbor(d) } - /// Encode to CBOR: `[asset...]`. + /// Encode to CBOR: `[asset...]` in canonical order. pub fn to_cbor(&self) -> Vec { let parts: Vec> = self.assets.iter().map(Asset::to_cbor).collect(); let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect(); @@ -203,9 +240,45 @@ mod tests { } #[test] - fn payment_collection_enforces_semantic_limits() { + fn payment_decoder_rejects_zero_amount() { + let asset = encode_array(&[&encode_byte_string(b"USD"), &encode_byte_string(&[])]); + let payment = encode_array(&[&asset]); + assert!(PaymentAssetCollection::from_cbor_bytes(&payment).is_err()); + } + + #[test] + fn payment_decoder_rejects_non_canonical_order() { + let a = encode_array(&[&encode_byte_string(b"USD"), &encode_byte_string(&[1])]); + let b = encode_array(&[&encode_byte_string(b"EUR"), &encode_byte_string(&[1])]); + // USD before EUR is not canonical. + let payment = encode_array(&[&a, &b]); + assert_eq!( + PaymentAssetCollection::from_cbor_bytes(&payment), + Err(Error::Cbor(CborError::NonCanonicalEncoding)) + ); + } + + #[test] + fn create_canonicalizes_order_and_roundtrips() { + let collection = PaymentAssetCollection::create([ + Asset::new(AssetId::new(b"USD".to_vec()), BigUint::from(2u32)), + Asset::new(AssetId::new(b"EUR".to_vec()), BigUint::from(1u32)), + ]) + .unwrap(); + assert_eq!(collection.as_slice()[0].id().bytes(), b"EUR"); + let bytes = collection.to_cbor(); + assert_eq!( + PaymentAssetCollection::from_cbor_bytes(&bytes).unwrap(), + collection + ); + } + + #[test] + fn create_enforces_semantic_limits() { let oversized_id = AssetId::new(alloc::vec![0; MAX_ASSET_ID_BYTES + 1]); - assert!(PaymentAssetCollection::create([Asset::new(oversized_id, BigUint::ZERO)]).is_err()); + assert!( + PaymentAssetCollection::create([Asset::new(oversized_id, BigUint::from(1u8))]).is_err() + ); let oversized_value = BigUint::from(1u8) << (MAX_ASSET_VALUE_BYTES * 8); assert!(PaymentAssetCollection::create([Asset::new( @@ -213,5 +286,13 @@ mod tests { oversized_value, )]) .is_err()); + + assert!(PaymentAssetCollection::create([Asset::new( + AssetId::new(b"USD".to_vec()), + BigUint::ZERO, + )]) + .is_err()); + + assert!(PaymentAssetCollection::create([]).is_err()); } } diff --git a/src/payment/commitment.rs b/src/payment/commitment.rs new file mode 100644 index 0000000..c286538 --- /dev/null +++ b/src/payment/commitment.rs @@ -0,0 +1,74 @@ +//! The split output commitment `d_j` (yellowpaper "Output Commitment"). +//! +//! `d_j` binds a split allocation leaf to the complete output mint transaction +//! *except* its mint reason (excluded to avoid a circular commitment). Including +//! the output token type, recipient predicate, salt, resulting token id and the +//! exact auxiliary payload means a proof prepared for one recipient or token type +//! cannot authorize a mint to another, even though the universal minting key is +//! public. +//! +//! ```text +//! d_j = SHA-256(CBOR( +//! SPLIT_OUTPUT, id_src, α, encpred(pred'_j), salt_j, id_j, ty_j, auxd'_j )) +//! ``` +//! +//! Every term is a CBOR byte string except `α`, which is an unsigned integer. + +use crate::api::network_id::NetworkId; +use crate::cbor::{encode_array, encode_byte_string, encode_uint}; +use crate::crypto::hash::sha256; +use crate::error::Error; +use crate::predicate::EncodedPredicate; +use crate::transaction::ids::{TokenId, TokenSalt, TokenType}; +use crate::transaction::{MintTransaction, Transaction}; + +/// The `SPLIT_OUTPUT` domain tag: the ASCII bytes `UNICITY_SPLIT_OUTPUT`. +pub const SPLIT_OUTPUT: &[u8] = b"UNICITY_SPLIT_OUTPUT"; + +/// Compute the split output commitment `d_j` from its constituent fields. +/// +/// `aux_data` is the exact auxiliary-payload byte string stored in the output +/// mint transaction (not a decoded or re-encoded object). +#[allow(clippy::too_many_arguments)] +pub fn split_output_commitment( + source_id: &TokenId, + network_id: NetworkId, + recipient: &EncodedPredicate, + salt: &TokenSalt, + output_id: &TokenId, + token_type: &TokenType, + aux_data: &[u8], +) -> [u8; 32] { + let preimage = encode_array(&[ + &encode_byte_string(SPLIT_OUTPUT), + &encode_byte_string(source_id.bytes()), + &encode_uint(network_id.id() as u64), + &encode_byte_string(&recipient.to_cbor()), + &encode_byte_string(salt.bytes()), + &encode_byte_string(output_id.bytes()), + &encode_byte_string(token_type.bytes()), + &encode_byte_string(aux_data), + ]); + let mut out = [0u8; 32]; + out.copy_from_slice(sha256(&preimage).data()); + out +} + +/// Compute `d_j` for an output mint transaction, given the source token id. +/// +/// Errors if the output carries no auxiliary payload (a split output must always +/// declare the non-empty canonical asset collection it receives). +pub fn commitment_for_mint(source_id: &TokenId, mint: &MintTransaction) -> Result<[u8; 32], Error> { + let aux_data = mint + .data() + .ok_or(Error::UnexpectedValue("split output has no auxiliary payload"))?; + Ok(split_output_commitment( + source_id, + mint.network_id(), + mint.recipient(), + mint.salt(), + mint.token_id(), + mint.token_type(), + aux_data, + )) +} diff --git a/src/payment/justification.rs b/src/payment/justification.rs index 92acd24..0b20433 100644 --- a/src/payment/justification.rs +++ b/src/payment/justification.rs @@ -1,30 +1,37 @@ -//! [`SplitMintJustification`] (CBOR tag 39044): the mint justification proving a +//! [`SplitMintJustification`] (CBOR tag 39044): the split *mint reason* proving a //! token was minted as an output of splitting a (now burned) source token. +//! +//! It carries the complete certified burned source token `L_burn` — including +//! its manifest-bearing burn transfer — and, in canonical output-asset order, +//! one [`RsmstInclusionProof`] per asset the new token receives. The proofs hold +//! only sibling entries; the asset id, output id, output commitment, leaf amount +//! and root hash are all derived by the verifier from the output payload, mint +//! transaction and manifest, so they never appear on the wire here. use alloc::vec::Vec; use super::asset::MAX_PAYMENT_ASSETS; -use super::proof::SplitAssetProof; use crate::cbor::{encode_array, encode_tag, DecodeLimits, Decoder}; use crate::error::Error; +use crate::rsmst::RsmstInclusionProof; use crate::transaction::Token; /// CBOR tag for [`SplitMintJustification`]. pub const SPLIT_MINT_JUSTIFICATION_TAG: u64 = 39044; -/// Justification carried by a split-minted token's genesis: the burned source -/// token plus one [`SplitAssetProof`] per asset the new token receives. +/// The split mint reason: the burned source token plus one RSMST allocation +/// proof per asset the new token receives (canonical output-asset order). #[derive(Debug, Clone, PartialEq, Eq)] pub struct SplitMintJustification { token: Token, - proofs: Vec, + proofs: Vec, encoded_token_len: usize, } impl SplitMintJustification { - /// Construct from the burned source token and its asset proofs. `proofs` - /// must be non-empty. - pub fn create(token: Token, proofs: Vec) -> Result { + /// Construct from the burned source token and its allocation proofs. + /// `proofs` must be a non-empty, at-most-256 vector. + pub fn create(token: Token, proofs: Vec) -> Result { if proofs.is_empty() { return Err(Error::UnexpectedValue( "split mint justification needs at least one proof", @@ -48,8 +55,8 @@ impl SplitMintJustification { &self.token } - /// The per-asset proofs. - pub fn proofs(&self) -> &[SplitAssetProof] { + /// The per-asset allocation proofs, in canonical output-asset order. + pub fn proofs(&self) -> &[RsmstInclusionProof] { &self.proofs } @@ -68,9 +75,15 @@ impl SplitMintJustification { let items = inner.array(Some(2))?; let encoded_token_len = items[0].bytes().len(); let token = Token::from_cbor_with_limits(items[0].bytes(), limits)?; - let mut proofs = Vec::new(); - for proof in items[1].array(None)? { - proofs.push(SplitAssetProof::from_cbor(proof)?); + let encoded_proofs = items[1].array(None)?; + if encoded_proofs.len() > MAX_PAYMENT_ASSETS { + return Err(Error::OutOfRange( + "split proof count exceeds protocol limit", + )); + } + let mut proofs = Vec::with_capacity(encoded_proofs.len()); + for proof in encoded_proofs { + proofs.push(RsmstInclusionProof::from_cbor(proof)?); } let mut justification = SplitMintJustification::create(token, proofs)?; justification.encoded_token_len = encoded_token_len; @@ -79,7 +92,8 @@ impl SplitMintJustification { /// Encode to CBOR (tag 39044): `[token, [proofs...]]`. pub fn to_cbor(&self) -> Vec { - let proof_bytes: Vec> = self.proofs.iter().map(SplitAssetProof::to_cbor).collect(); + let proof_bytes: Vec> = + self.proofs.iter().map(RsmstInclusionProof::to_cbor).collect(); let proof_refs: Vec<&[u8]> = proof_bytes.iter().map(Vec::as_slice).collect(); encode_tag( SPLIT_MINT_JUSTIFICATION_TAG, diff --git a/src/payment/manifest.rs b/src/payment/manifest.rs new file mode 100644 index 0000000..420a8d4 --- /dev/null +++ b/src/payment/manifest.rs @@ -0,0 +1,131 @@ +//! The split manifest (CBOR tag 39046): the ordered vector of per-asset RSMST +//! root hashes committed by the source token's burn. +//! +//! The certified burn transfer of the source token stores the manifest's exact +//! canonical encoding `b_M` as its auxiliary data, and the burn predicate's +//! reason is `SHA-256(b_M)`. The roots are positionally aligned with the source +//! token's canonical asset collection, so the manifest itself repeats no asset +//! identifier, amount or source id — all are already authenticated by the burned +//! token (yellowpaper "Split Manifest"). + +use alloc::vec::Vec; + +use crate::cbor::{encode_array, encode_byte_string, encode_tag, DecodeLimits, Decoder}; +use crate::crypto::hash::sha256; +use crate::error::Error; + +use super::asset::MAX_PAYMENT_ASSETS; + +/// CBOR tag for a [`SplitManifest`]. +pub const SPLIT_MANIFEST_TAG: u64 = 39046; + +/// An ordered vector of `1..=256` raw 32-byte RSMST root digests. +#[derive(Clone, PartialEq, Eq)] +pub struct SplitManifest { + roots: Vec<[u8; 32]>, +} + +impl SplitManifest { + /// Build a manifest from per-asset root hashes (one per source asset, in + /// canonical asset order). Rejects an empty or over-256 vector. + pub fn create(roots: Vec<[u8; 32]>) -> Result { + if roots.is_empty() { + return Err(Error::OutOfRange("split manifest must be non-empty")); + } + if roots.len() > MAX_PAYMENT_ASSETS { + return Err(Error::OutOfRange("split manifest exceeds protocol limit")); + } + Ok(SplitManifest { roots }) + } + + /// The root hashes, in canonical source-asset order. + pub fn roots(&self) -> &[[u8; 32]] { + &self.roots + } + + /// The number of roots (equals the source asset count). + pub fn len(&self) -> usize { + self.roots.len() + } + + /// Always `false` (a manifest is never empty); present for lint parity. + pub fn is_empty(&self) -> bool { + self.roots.is_empty() + } + + /// Encode to CBOR (tag 39046): `[bstr(r_1), ..., bstr(r_m)]`. + pub fn to_cbor(&self) -> Vec { + let parts: Vec> = self.roots.iter().map(|r| encode_byte_string(r)).collect(); + let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect(); + encode_tag(SPLIT_MANIFEST_TAG, &encode_array(&refs)) + } + + /// `SHA-256(b_M)`: the burn predicate reason that commits to this manifest. + pub fn reason_hash(&self) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(sha256(&self.to_cbor()).data()); + out + } + + /// Decode from the complete tagged CBOR encoding, enforcing no trailing + /// bytes, the 39046 tag, `1..=256` roots, and a 32-byte length per root. + pub fn from_cbor_bytes(bytes: &[u8], limits: DecodeLimits) -> Result { + let d = Decoder::with_limits(bytes, limits); + d.finish()?; + let inner = d.expect_tag(SPLIT_MANIFEST_TAG)?; + let items = inner.array(None)?; + if items.is_empty() { + return Err(Error::OutOfRange("split manifest must be non-empty")); + } + if items.len() > MAX_PAYMENT_ASSETS { + return Err(Error::OutOfRange("split manifest exceeds protocol limit")); + } + let mut roots = Vec::with_capacity(items.len()); + for item in items { + let root: [u8; 32] = + item.bytes_value()? + .try_into() + .map_err(|_| Error::InvalidLength { + what: "split manifest root", + expected: 32, + actual: 0, + })?; + roots.push(root); + } + Ok(SplitManifest { roots }) + } +} + +impl core::fmt::Debug for SplitManifest { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SplitManifest") + .field("roots", &self.roots.len()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_and_reason_hash() { + let manifest = SplitManifest::create(alloc::vec![[0x11; 32], [0x22; 32]]).unwrap(); + let bytes = manifest.to_cbor(); + let decoded = SplitManifest::from_cbor_bytes(&bytes, DecodeLimits::DEFAULT).unwrap(); + assert_eq!(decoded, manifest); + assert_eq!(manifest.reason_hash(), { + let mut h = [0u8; 32]; + h.copy_from_slice(crate::crypto::hash::sha256(&bytes).data()); + h + }); + } + + #[test] + fn rejects_trailing_bytes_and_wrong_tag() { + let manifest = SplitManifest::create(alloc::vec![[0x11; 32]]).unwrap(); + let mut bytes = manifest.to_cbor(); + bytes.push(0xff); + assert!(SplitManifest::from_cbor_bytes(&bytes, DecodeLimits::DEFAULT).is_err()); + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 14c1b5c..9028b54 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -1,18 +1,21 @@ //! Payment / asset / token-split subsystem. //! -//! A token can carry a [`PaymentAssetCollection`] in its mint `data`: a set of -//! fungible [`Asset`]s. `TokenSplit` (client only) burns -//! such a token and produces several new ones whose combined assets equal the -//! original, each accompanied by [`SplitAssetProof`]s. A split-minted token's -//! genesis carries a [`SplitMintJustification`] (tag 39044), which -//! [`SplitMintJustificationVerifier`] checks during -//! [`Token::verify_with`](crate::transaction::Token::verify_with). +//! A token can carry a [`PaymentAssetCollection`] in its mint `data`: a non-empty +//! canonical set of fungible [`Asset`]s (the yellowpaper `Assets(ty, auxd')` +//! function). `TokenSplit` (client only) burns such a token and produces several +//! new ones whose per-asset allocations sum to the original, each accompanied by +//! [`RsmstInclusionProof`]s. The burn commits to a [`SplitManifest`] (tag 39046) +//! stored as its auxiliary data; each split-minted token's genesis carries a +//! [`SplitMintJustification`] (tag 39044), which [`SplitMintJustificationVerifier`] +//! re-checks during [`Token::verify_with`](crate::transaction::Token::verify_with). //! -//! The proofs ride on the bigint-routed [`smt`](crate::smt) plain and sum trees. +//! The allocation proofs ride on the radix sparse Merkle sum trees in +//! [`rsmst`](crate::rsmst): one tree per asset, keyed by output token id. pub mod asset; +pub mod commitment; pub mod justification; -pub mod proof; +pub mod manifest; pub mod verifier; #[cfg(any(feature = "client", test))] @@ -22,12 +25,16 @@ pub mod split; mod tests; pub use asset::{Asset, AssetId, PaymentAssetCollection}; +pub use commitment::{split_output_commitment, SPLIT_OUTPUT}; pub use justification::{SplitMintJustification, SPLIT_MINT_JUSTIFICATION_TAG}; -pub use proof::SplitAssetProof; +pub use manifest::{SplitManifest, SPLIT_MANIFEST_TAG}; pub use verifier::{ verify_payment_token, PaymentDataDecoder, PaymentDataVerifier, PaymentIssuancePolicy, SplitMintJustificationVerifier, }; +/// Re-export of the allocation proof type carried by a split mint reason. +pub use crate::rsmst::RsmstInclusionProof; + #[cfg(any(feature = "client", test))] pub use split::{Split, SplitBurn, SplitToken, SplitTokenRequest, TokenSplit}; diff --git a/src/payment/proof.rs b/src/payment/proof.rs deleted file mode 100644 index 9c14153..0000000 --- a/src/payment/proof.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! [`SplitAssetProof`]: proves one asset's contribution to a split, by an -//! aggregation-tree path (asset id → asset-tree root) and an asset-tree path -//! (new token id → amount). - -use alloc::vec::Vec; - -use super::asset::AssetId; -use crate::cbor::{encode_array, Decoder}; -use crate::error::Error; -use crate::smt::plain::SparseMerkleTreePath; -use crate::smt::sum::SparseMerkleSumTreePath; - -/// Inclusion proof for a single asset within a split payment. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SplitAssetProof { - asset_id: AssetId, - aggregation_path: SparseMerkleTreePath, - asset_tree_path: SparseMerkleSumTreePath, -} - -impl SplitAssetProof { - /// Construct from its parts. - pub fn new( - asset_id: AssetId, - aggregation_path: SparseMerkleTreePath, - asset_tree_path: SparseMerkleSumTreePath, - ) -> Self { - SplitAssetProof { - asset_id, - aggregation_path, - asset_tree_path, - } - } - - /// The asset id this proof is for. - pub fn asset_id(&self) -> &AssetId { - &self.asset_id - } - - /// Path through the aggregation tree (asset id → asset-tree root). - pub fn aggregation_path(&self) -> &SparseMerkleTreePath { - &self.aggregation_path - } - - /// Path through this asset's sum tree (new token id → amount). - pub fn asset_tree_path(&self) -> &SparseMerkleSumTreePath { - &self.asset_tree_path - } - - /// Decode from CBOR: `[bstr(assetId), aggregationPath, assetTreePath]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(3))?; - Ok(SplitAssetProof { - asset_id: AssetId::from_cbor(items[0])?, - aggregation_path: SparseMerkleTreePath::from_cbor(items[1])?, - asset_tree_path: SparseMerkleSumTreePath::from_cbor(items[2])?, - }) - } - - /// Encode to CBOR: `[bstr(assetId), aggregationPath, assetTreePath]`. - pub fn to_cbor(&self) -> Vec { - encode_array(&[ - &self.asset_id.to_cbor(), - &self.aggregation_path.to_cbor(), - &self.asset_tree_path.to_cbor(), - ]) - } -} diff --git a/src/payment/split.rs b/src/payment/split.rs index a81b990..64e602c 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -1,25 +1,27 @@ //! Client-side token splitting (`client` feature). //! -//! [`TokenSplit::split`] burns a payment-carrying token and produces the data -//! needed to mint several new tokens whose assets sum to the original. It builds -//! a sum tree per asset (token id → amount) and a plain aggregation tree over -//! the asset-tree roots; the aggregation root becomes the burn predicate's -//! reason, and each output carries [`SplitAssetProof`]s extracted from those -//! trees. The result is exactly what [`SplitMintJustificationVerifier`] checks. +//! [`TokenSplit::split`] burns a payment-carrying token and produces everything +//! needed to mint several new tokens whose per-asset allocations sum to the +//! original. For every source asset it builds one radix sparse Merkle sum tree +//! ([`Rsmst`]) keyed by output token id; the per-asset root hashes form the +//! *split manifest*, whose hash becomes the burn predicate's reason and whose +//! exact bytes are stored as the burn transfer's auxiliary data. Each output +//! carries one [`RsmstInclusionProof`] per asset it receives. The result is +//! exactly what [`SplitMintJustificationVerifier`] re-checks. //! //! [`SplitMintJustificationVerifier`]: super::verifier::SplitMintJustificationVerifier use alloc::vec::Vec; -use super::asset::{AssetId, PaymentAssetCollection}; -use super::proof::SplitAssetProof; +use super::asset::PaymentAssetCollection; +use super::commitment::split_output_commitment; +use super::manifest::SplitManifest; use crate::api::network_id::NetworkId; use crate::error::Error; use crate::predicate::builtin::BurnPredicate; use crate::predicate::EncodedPredicate; -use crate::smt::bigint::key_to_path; -use crate::smt::plain::SparseMerkleTree; -use crate::smt::sum::SparseMerkleSumTree; +use crate::rsmst::build::Rsmst; +use crate::rsmst::RsmstInclusionProof; use crate::transaction::ids::{TokenId, TokenSalt, TokenType}; use crate::transaction::{Token, TransferTransaction}; @@ -34,7 +36,8 @@ pub struct SplitTokenRequest { impl SplitTokenRequest { /// Create a request: `recipient` locks the new token, which receives - /// `assets` and is identified by `token_type` and `salt`. + /// `assets` and is identified by `token_type` and `salt`. `token_type` must + /// equal the source token type (splitting is not a type-conversion). pub fn create( recipient: EncodedPredicate, assets: PaymentAssetCollection, @@ -61,19 +64,21 @@ pub struct SplitToken { pub token_type: TokenType, /// Salt of the new token. pub salt: TokenSalt, - /// Assets the new token receives. + /// Assets the new token receives (canonical order). pub assets: PaymentAssetCollection, - /// Inclusion proofs binding these assets to the burned source token. - pub proofs: Vec, + /// One allocation proof per asset, in canonical output-asset order. + pub proofs: Vec, } /// The burn side of a split: the predicate and transfer that destroy the source. #[derive(Debug, Clone)] pub struct SplitBurn { - /// The burn predicate (reason = aggregation-tree root imprint). + /// The burn predicate (reason = `SHA-256(b_M)`). pub owner_predicate: BurnPredicate, - /// The transfer that burns the source token. + /// The transfer that burns the source token (auxiliary data = `b_M`). pub transaction: TransferTransaction, + /// The exact canonical manifest encoding `b_M` stored by the burn transfer. + pub manifest: Vec, } /// The result of [`TokenSplit::split`]. @@ -103,71 +108,74 @@ impl TokenSplit { burn_state_mask: Option<[u8; 32]>, ) -> Result { let network_id = token.genesis().transaction().network_id(); + let source_token_type = token.token_type().clone(); - // Derive each output's token id and reject duplicates. - let mut entries: Vec<(SplitTokenRequest, TokenId, num_bigint::BigUint)> = Vec::new(); - for request in requests { - let token_id = TokenId::derive(network_id, &request.salt); - let token_id_path = key_to_path(token_id.bytes()); - if entries.iter().any(|(_, _, p)| p == &token_id_path) { - return Err(Error::UnexpectedValue("duplicate split token id")); - } - entries.push((request, token_id, token_id_path)); - } - - // One sum tree per asset, mapping each output token id to its amount. - let mut trees: Vec<(AssetId, SparseMerkleSumTree)> = Vec::new(); - for (request, _, token_id_path) in &entries { - for asset in request.assets.as_slice() { - let tree = match trees.iter_mut().find(|(id, _)| id == asset.id()) { - Some((_, tree)) => tree, - None => { - trees.push((asset.id().clone(), SparseMerkleSumTree::new())); - &mut trees.last_mut().expect("just pushed").1 - } - }; - tree.add_leaf( - token_id_path.clone(), - asset.id().bytes().to_vec(), - asset.value().clone(), - )?; - } - } - - // The source token's declared payment must match the requested assets. - let payment_bytes = token + // The source token's declared canonical asset collection (A). + let source_bytes = token .genesis() .transaction() .data() .ok_or(Error::UnexpectedValue("source token has no payment data"))?; - let assets = decode_payment_data(payment_bytes)?; - if trees.len() != assets.len() { - return Err(Error::UnexpectedValue( - "asset count does not match source payment", - )); + let assets = decode_payment_data(source_bytes)?; + + // Validate each request and derive its token id and output commitment. + let mut entries: Vec<(SplitTokenRequest, TokenId, [u8; 32])> = Vec::new(); + for request in requests { + if request.token_type != source_token_type { + return Err(Error::UnexpectedValue( + "split output token type must equal source token type", + )); + } + for asset in request.assets.as_slice() { + if assets.get(asset.id()).is_none() { + return Err(Error::UnexpectedValue( + "split output asset is absent from source token", + )); + } + } + let token_id = TokenId::derive(network_id, &request.salt); + if entries.iter().any(|(_, id, _)| id == &token_id) { + return Err(Error::UnexpectedValue("duplicate split token id")); + } + let commitment = split_output_commitment( + token.id(), + network_id, + &request.recipient, + &request.salt, + &token_id, + &request.token_type, + &request.assets.to_cbor(), + ); + entries.push((request, token_id, commitment)); } - // Finalize each asset tree, checking value conservation, and aggregate - // the roots into a plain tree keyed by asset id. - let mut aggregation_tree = SparseMerkleTree::new(); - let mut asset_tree_roots: Vec<(AssetId, crate::smt::sum::SparseMerkleSumTreeRootNode)> = + // One RSMST per source asset, in canonical asset order, checking value + // conservation. The per-asset root hashes form the manifest. + let mut roots: Vec<[u8; 32]> = Vec::new(); + let mut built_trees: Vec<(super::asset::AssetId, crate::rsmst::build::BuiltRsmst)> = Vec::new(); - for (asset_id, tree) in trees { - let token_asset = assets - .get(&asset_id) - .ok_or(Error::UnexpectedValue("source payment missing an asset"))?; - let root = tree.calculate_root(); - if root.value() != token_asset.value() { + for source_asset in assets.as_slice() { + let mut tree = Rsmst::new(); + for (request, token_id, commitment) in &entries { + if let Some(output_asset) = request.assets.get(source_asset.id()) { + tree.insert(*token_id.bytes(), *commitment, output_asset.value().clone())?; + } + } + let built = tree.build().map_err(|_| { + Error::UnexpectedValue("source asset is not allocated to any output") + })?; + if built.root_sum() != source_asset.value() { return Err(Error::UnexpectedValue( "split asset total does not match source amount", )); } - aggregation_tree.add_leaf(key_to_path(asset_id.bytes()), root.hash().imprint())?; - asset_tree_roots.push((asset_id, root)); + roots.push(built.root_hash()); + built_trees.push((source_asset.id().clone(), built)); } - let aggregation_root = aggregation_tree.calculate_root(); - let burn_predicate = BurnPredicate::new(aggregation_root.hash().imprint()); + let manifest = SplitManifest::create(roots)?; + let manifest_bytes = manifest.to_cbor(); + let burn_predicate = BurnPredicate::new(manifest.reason_hash().to_vec()); let mask = match burn_state_mask { Some(m) => m, @@ -179,23 +187,22 @@ impl TokenSplit { lock_script, burn_predicate.to_encoded(), mask.to_vec(), - None, + Some(manifest_bytes.clone()), ); - // Build each output with its per-asset proofs. + // Build each output with its per-asset proofs (canonical output order). let mut tokens = Vec::new(); - for (request, _token_id, token_id_path) in entries { + for (request, token_id, _commitment) in entries { let mut proofs = Vec::new(); for asset in request.assets.as_slice() { - let (_, asset_root) = asset_tree_roots + let (_, built) = built_trees .iter() .find(|(id, _)| id == asset.id()) - .expect("asset tree built for every requested asset"); - proofs.push(SplitAssetProof::new( - asset.id().clone(), - aggregation_root.get_path(&key_to_path(asset.id().bytes())), - asset_root.get_path(&token_id_path), - )); + .expect("a tree is built for every source asset"); + let proof = built + .proof(token_id.bytes()) + .ok_or(Error::UnexpectedValue("missing allocation proof for output"))?; + proofs.push(proof); } tokens.push(SplitToken { network_id, @@ -211,6 +218,7 @@ impl TokenSplit { burn: SplitBurn { owner_predicate: burn_predicate, transaction: burn_transaction, + manifest: manifest_bytes, }, tokens, }) diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 8721981..e52d91f 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -1,7 +1,7 @@ -//! End-to-end token-split tests: build a payment-carrying token, split it, -//! mint each output with a [`SplitMintJustification`], and verify the outputs -//! against the trust base through the registered split verifier. Negative cases -//! confirm each split rule rejects its forgery. +//! End-to-end token-split tests: build a payment-carrying token, split it into +//! per-asset RSMST allocations, mint each output with a [`SplitMintJustification`], +//! and verify the outputs against the trust base through the registered split +//! verifier. Negative cases confirm each split rule rejects its forgery. use alloc::boxed::Box; use alloc::string::{String, ToString}; @@ -11,9 +11,9 @@ use alloc::vec::Vec; use num_bigint::BigUint; use super::{ - verify_payment_token, Asset, AssetId, PaymentAssetCollection, PaymentDataVerifier, - SplitAssetProof, SplitMintJustification, SplitMintJustificationVerifier, SplitTokenRequest, - TokenSplit, + split_output_commitment, verify_payment_token, Asset, AssetId, PaymentAssetCollection, + PaymentDataVerifier, SplitManifest, SplitMintJustification, SplitMintJustificationVerifier, + SplitTokenRequest, TokenSplit, }; use crate::api::bft::{ InputRecord, RootTrustBase, RootTrustBaseNodeInfo, ShardId, ShardTreeCertificate, @@ -26,10 +26,8 @@ use crate::crypto::signer::{Secp256k1Signer, Signer}; use crate::predicate::builtin::{BurnPredicate, SignaturePredicate}; use crate::predicate::unlock::sign_signature_unlock; use crate::predicate::EncodedPredicate; -use crate::smt::bigint::key_to_path; -use crate::smt::plain::SparseMerkleTree; -use crate::smt::sum::SparseMerkleSumTree; -use crate::transaction::ids::{TokenSalt, TokenType}; +use crate::rsmst::build::Rsmst; +use crate::transaction::ids::{TokenId, TokenSalt, TokenType}; use crate::transaction::{ CertifiedMintTransaction, CertifiedTransferTransaction, MintTransaction, Minter, Token, Transaction, TransferTransaction, @@ -145,6 +143,12 @@ fn valid_proof( // --- scenario builders ------------------------------------------------------ +/// Source/output token type. Splitting is never a type conversion, so the source +/// and every output share one coin token type. +fn coin_type() -> TokenType { + TokenType::new(vec![0xC0; 32]) +} + fn asset_a() -> AssetId { AssetId::new(vec![0xAA; 32]) } @@ -162,7 +166,7 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { let mint = MintTransaction::create( NetworkId::LOCAL, sig_pred(owner), - TokenType::new(vec![0xC0; 32]), + coin_type(), TokenSalt::from_bytes([0x01; 32]), Some(payment.to_cbor()), None, @@ -211,17 +215,15 @@ fn mint_output( Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()) } -fn registry(_tb: &RootTrustBase) -> MintJustificationRegistry { +fn registry() -> MintJustificationRegistry { let mut r = MintJustificationRegistry::new(); r.register(Box::new(SplitMintJustificationVerifier::new())) .unwrap(); - for byte in [0xC0, 0xC1, 0xC2] { - r.register_token_data(Box::new(PaymentDataVerifier::new( - TokenType::new(vec![byte; 32]), - authorize_test_payment, - ))) - .unwrap(); - } + r.register_token_data(Box::new(PaymentDataVerifier::new( + coin_type(), + authorize_test_payment, + ))) + .unwrap(); r } @@ -255,13 +257,13 @@ fn scenario() -> Scenario { Asset::new(asset_b(), BigUint::from(50u32)), ]) .unwrap(), - TokenType::new(vec![0xC1; 32]), + coin_type(), TokenSalt::from_bytes([0x10; 32]), ); let req2 = SplitTokenRequest::create( sig_pred(&carol), PaymentAssetCollection::create([Asset::new(asset_a(), BigUint::from(40u32))]).unwrap(), - TokenType::new(vec![0xC2; 32]), + coin_type(), TokenSalt::from_bytes([0x20; 32]), ); @@ -274,48 +276,58 @@ fn scenario() -> Scenario { } } +/// Forge a single-asset output by hand-building an RSMST allocation that claims +/// `amount` of `asset_id`, then minting against it. The forged manifest carries +/// one root per source asset (the second is a dummy) so it passes the structural +/// length check and the relevant per-asset rule is reached. fn forged_single_asset_output(s: &Scenario, asset_id: AssetId, amount: u32) -> Token { - let recipient = signer(0x55); + forged_output_with_type(s, asset_id, amount, coin_type()) +} + +fn forged_output_with_type( + s: &Scenario, + asset_id: AssetId, + amount: u32, + token_type: TokenType, +) -> Token { + let recipient = sig_pred(&signer(0x55)); let salt = TokenSalt::from_bytes([0x31; 32]); - let token_type = TokenType::new(vec![0xC1; 32]); - let token_id = crate::transaction::ids::TokenId::derive(NetworkId::LOCAL, &salt); - let token_id_path = key_to_path(token_id.bytes()); - - let mut asset_tree = SparseMerkleSumTree::new(); - asset_tree - .add_leaf( - token_id_path.clone(), - asset_id.bytes().to_vec(), - BigUint::from(amount), - ) - .unwrap(); - let asset_root = asset_tree.calculate_root(); - let mut aggregation_tree = SparseMerkleTree::new(); - aggregation_tree - .add_leaf(asset_id.to_path(), asset_root.hash().imprint()) + let token_id = TokenId::derive(NetworkId::LOCAL, &salt); + let assets = + PaymentAssetCollection::create([Asset::new(asset_id, BigUint::from(amount))]).unwrap(); + let commitment = split_output_commitment( + s.source.id(), + NetworkId::LOCAL, + &recipient, + &salt, + &token_id, + &token_type, + &assets.to_cbor(), + ); + + let mut tree = Rsmst::new(); + tree.insert(*token_id.bytes(), commitment, BigUint::from(amount)) .unwrap(); - let aggregation_root = aggregation_tree.calculate_root(); + let built = tree.build().unwrap(); + let proof = built.proof(token_id.bytes()).unwrap(); + // Source has two assets (asset_a, asset_b in canonical order); align the + // forged root at index 0 and pad index 1 with a dummy root. + let manifest = SplitManifest::create(vec![built.root_hash(), [0u8; 32]]).unwrap(); + let burn_predicate = BurnPredicate::new(manifest.reason_hash().to_vec()); let (source_state_hash, lock_script) = s.source.latest_state(); let burn = TransferTransaction::new( source_state_hash, lock_script, - BurnPredicate::new(aggregation_root.hash().imprint()).to_encoded(), + burn_predicate.to_encoded(), vec![9u8; 32], - None, + Some(manifest.to_cbor()), ); let burned = burned_token(&s.source, burn, &s.alice, &s.node); - let proof = SplitAssetProof::new( - asset_id.clone(), - aggregation_root.get_path(&asset_id.to_path()), - asset_root.get_path(&token_id_path), - ); let justification = SplitMintJustification::create(burned, vec![proof]).unwrap(); - let assets = - PaymentAssetCollection::create([Asset::new(asset_id, BigUint::from(amount))]).unwrap(); mint_output( NetworkId::LOCAL, - sig_pred(&recipient), + recipient, token_type, salt, &assets, @@ -330,7 +342,7 @@ fn forged_single_asset_output(s: &Scenario, asset_id: AssetId, amount: u32) -> T fn split_outputs_verify_end_to_end() { let s = scenario(); assert_eq!(s.source.verify(&s.tb), Ok(()), "source token should verify"); - let registry = registry(&s.tb); + let registry = registry(); assert_eq!( verify_payment_token( &s.source, @@ -411,10 +423,7 @@ fn payment_verification_enforces_issuance_policy() { let s = scenario(); let mut registry = MintJustificationRegistry::new(); registry - .register_token_data(Box::new(PaymentDataVerifier::new( - TokenType::new(vec![0xC0; 32]), - reject, - ))) + .register_token_data(Box::new(PaymentDataVerifier::new(coin_type(), reject))) .unwrap(); assert_eq!( verify_payment_token( @@ -458,7 +467,7 @@ fn recursive_split_verification_honors_shared_depth_limit() { }; assert_eq!( - verify_token_with_policy(&token, &s.tb, ®istry(&s.tb), policy), + verify_token_with_policy(&token, &s.tb, ®istry(), policy), Err(VerificationError::BurnTokenVerificationFailed(Box::new( VerificationError::VerificationLimitExceeded("embedded token depth") ))) @@ -466,13 +475,13 @@ fn recursive_split_verification_honors_shared_depth_limit() { } #[test] -fn rejects_inflated_sum_tree_built_without_split_builder() { +fn rejects_inflated_allocation_built_without_split_builder() { let s = scenario(); // Forge a tree claiming 1,000 units although the source owns only 100. let token = forged_single_asset_output(&s, asset_a(), 1_000); assert_eq!( - token.verify_with(&s.tb, ®istry(&s.tb)), + token.verify_with(&s.tb, ®istry()), Err(VerificationError::SplitSourceAmountMismatch) ); } @@ -483,7 +492,7 @@ fn rejects_asset_absent_from_burned_source() { let token = forged_single_asset_output(&s, AssetId::new(vec![0xCC; 32]), 10); assert_eq!( - token.verify_with(&s.tb, ®istry(&s.tb)), + token.verify_with(&s.tb, ®istry()), Err(VerificationError::SplitSourceAssetMissing) ); } @@ -499,11 +508,11 @@ fn rejects_tampered_output_amount() { ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); - let registry = registry(&s.tb); + let registry = registry(); let out = &split.tokens[0]; let just = SplitMintJustification::create(burned, out.proofs.clone()).unwrap(); - // Declare more of asset A than the proofs certify. + // Declare a different amount of asset A than the proof (and commitment) cover. let inflated = PaymentAssetCollection::create([ Asset::new(asset_a(), BigUint::from(61u32)), Asset::new(asset_b(), BigUint::from(50u32)), @@ -520,7 +529,7 @@ fn rejects_tampered_output_amount() { ); assert_eq!( token.verify_with(&s.tb, ®istry), - Err(VerificationError::SplitAssetAmountMismatch) + Err(VerificationError::SplitAllocationProofInvalid) ); } @@ -535,7 +544,7 @@ fn rejects_dropped_proof() { ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); - let registry = registry(&s.tb); + let registry = registry(); let out = &split.tokens[0]; // has two assets / two proofs let mut proofs = out.proofs.clone(); @@ -552,7 +561,7 @@ fn rejects_dropped_proof() { ); assert_eq!( token.verify_with(&s.tb, ®istry), - Err(VerificationError::SplitAssetCountMismatch) + Err(VerificationError::SplitProofCountMismatch) ); } @@ -566,16 +575,17 @@ fn rejects_wrong_burn_predicate() { Some([7u8; 32]), ) .unwrap(); - let registry = registry(&s.tb); + let registry = registry(); - // Burn the source to an unrelated burn predicate (not the aggregation root). + // Burn the source carrying the real manifest, but locked to an unrelated burn + // predicate (not SHA-256 of the manifest). let (source_state_hash, lock_script) = s.source.latest_state(); let wrong_burn = TransferTransaction::new( source_state_hash, lock_script, - BurnPredicate::new(b"not-the-aggregation-root".to_vec()).to_encoded(), + BurnPredicate::new(b"not-the-manifest-hash".to_vec()).to_encoded(), vec![7u8; 32], - None, + Some(split.burn.manifest.clone()), ); let burned = burned_token(&s.source, wrong_burn, &s.alice, &s.node); assert_eq!( @@ -601,6 +611,118 @@ fn rejects_wrong_burn_predicate() { ); } +#[test] +fn rejects_output_token_type_mismatch_at_verify() { + let s = scenario(); + // A self-consistent forged output whose token type differs from the source: + // construction-time checks are bypassed, but the verifier still rejects it. + let token = forged_output_with_type(&s, asset_a(), 100, TokenType::new(vec![0xC9; 32])); + assert_eq!( + token.verify_with(&s.tb, ®istry()), + Err(VerificationError::SplitTokenTypeMismatch) + ); +} + +#[test] +fn rejects_missing_manifest() { + let s = scenario(); + let split = TokenSplit::split( + &s.source, + PaymentAssetCollection::from_cbor_bytes, + s.requests, + Some([7u8; 32]), + ) + .unwrap(); + // Burn with no auxiliary manifest data at all. + let (source_state_hash, lock_script) = s.source.latest_state(); + let burn = TransferTransaction::new( + source_state_hash, + lock_script, + BurnPredicate::new(b"x".to_vec()).to_encoded(), + vec![3u8; 32], + None, + ); + let burned = burned_token(&s.source, burn, &s.alice, &s.node); + let out = &split.tokens[0]; + let just = SplitMintJustification::create(burned, out.proofs.clone()).unwrap(); + let token = mint_output( + out.network_id, + out.recipient.clone(), + out.token_type.clone(), + out.salt.clone(), + &out.assets, + &just, + &s.node, + ); + assert_eq!( + token.verify_with(&s.tb, ®istry()), + Err(VerificationError::SplitManifestMissing) + ); +} + +#[test] +fn rejects_manifest_length_mismatch() { + let s = scenario(); + let split = TokenSplit::split( + &s.source, + PaymentAssetCollection::from_cbor_bytes, + s.requests, + Some([7u8; 32]), + ) + .unwrap(); + // A self-consistent burn whose manifest has one root, although the source + // carries two assets. + let short = SplitManifest::create(vec![[0u8; 32]]).unwrap(); + let (source_state_hash, lock_script) = s.source.latest_state(); + let burn = TransferTransaction::new( + source_state_hash, + lock_script, + BurnPredicate::new(short.reason_hash().to_vec()).to_encoded(), + vec![4u8; 32], + Some(short.to_cbor()), + ); + let burned = burned_token(&s.source, burn, &s.alice, &s.node); + let out = &split.tokens[0]; + let just = SplitMintJustification::create(burned, out.proofs.clone()).unwrap(); + let token = mint_output( + out.network_id, + out.recipient.clone(), + out.token_type.clone(), + out.salt.clone(), + &out.assets, + &just, + &s.node, + ); + assert_eq!( + token.verify_with(&s.tb, ®istry()), + Err(VerificationError::SplitManifestLengthMismatch) + ); +} + +#[test] +fn rejects_wrong_output_token_type() { + let s = scenario(); + // A split request whose output type differs from the source is rejected at + // construction (splitting is not a token-type conversion). + let bad = vec![SplitTokenRequest::create( + sig_pred(&signer(0x33)), + PaymentAssetCollection::create([ + Asset::new(asset_a(), BigUint::from(100u32)), + Asset::new(asset_b(), BigUint::from(50u32)), + ]) + .unwrap(), + TokenType::new(vec![0xC9; 32]), + TokenSalt::from_bytes([0x10; 32]), + )]; + assert!(TokenSplit::split( + &s.source, + PaymentAssetCollection::from_cbor_bytes, + bad, + Some([7u8; 32]), + ) + .is_err()); +} + #[test] fn rejects_unbalanced_split_at_build_time() { let s = scenario(); @@ -613,7 +735,7 @@ fn rejects_unbalanced_split_at_build_time() { Asset::new(asset_b(), BigUint::from(50u32)), ]) .unwrap(), - TokenType::new(vec![0xC1; 32]), + coin_type(), TokenSalt::from_bytes([0x10; 32]), )]; assert!(TokenSplit::split( diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 7406627..e4957cc 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1,31 +1,37 @@ //! [`SplitMintJustificationVerifier`]: the security-critical check that a //! split-minted token is a legitimate output of burning a real source token. //! -//! Faithful port of the reference `SplitMintJustificationVerifier`. The chain of -//! evidence it enforces: +//! The chain of evidence it enforces (yellowpaper "Split Mint-Reason +//! Verification"): //! -//! 1. the burned **source token fully verifies** against the trust base (so its -//! value was real and is now provably destroyed); -//! 2. the source token's final state is a **burn predicate whose reason is the -//! aggregation-tree root** — binding the burn to *exactly* this set of -//! outputs and no other; -//! 3. for every asset, an **aggregation path** (asset id → asset-tree root) and -//! an **asset-tree path** (this minted token's id → amount) both verify, all -//! proofs share one aggregation root, and the certified amounts match the -//! minted token's declared payment data. +//! 1. the output declares a **non-empty canonical asset collection** as its +//! payload; +//! 2. the burned **source token fully verifies** against the trust base +//! (recursively, so a split of a split is checked end to end), on the same +//! network as the output; +//! 3. the source ends in a certified **burn transfer** whose auxiliary data is +//! the exact split manifest `b_M` and whose recipient is +//! `burn(SHA-256(b_M))` — binding the burn to *exactly* this ordered vector +//! of per-asset allocation roots; +//! 4. the output **token type equals the source token type**, byte for byte; +//! 5. the source payload is canonical and has exactly one manifest root per +//! asset; and +//! 6. for every output asset, in canonical order, its **RSMST allocation proof** +//! verifies against the matching manifest root using the recomputed output +//! commitment `d_j`, and the proof's reconstructed **root sum equals the +//! source amount** for that asset (the verifier-side value-conservation rule). //! //! Any deviation rejects the mint, so a forged or over-issued split cannot pass. use alloc::boxed::Box; -use alloc::collections::BTreeSet; -use alloc::vec::Vec; use super::asset::PaymentAssetCollection; +use super::commitment::commitment_for_mint; use super::justification::{SplitMintJustification, SPLIT_MINT_JUSTIFICATION_TAG}; +use super::manifest::SplitManifest; use crate::error::Error; use crate::predicate::builtin::BurnPredicate; use crate::predicate::EncodedPredicate; -use crate::smt::bigint::key_to_path; use crate::transaction::ids::TokenType; use crate::transaction::{CertifiedMintTransaction, Token}; use crate::verify::{ @@ -34,7 +40,8 @@ use crate::verify::{ }; use crate::VerificationError; -/// Decoder from a token's mint `data` bytes to its [`PaymentAssetCollection`]. +/// Decoder from a token's mint `data` bytes to its [`PaymentAssetCollection`] +/// (the `Assets(ty, auxd')` function). /// /// The default ([`PaymentAssetCollection::from_cbor_bytes`]) treats the mint /// `data` as exactly the encoded collection; supply a custom decoder if the @@ -158,138 +165,108 @@ impl MintJustificationVerifier for SplitMintJustificationVerifier { context: &mut VerificationContext<'_>, ) -> Result<(), VerificationError> { let mint = genesis.transaction(); + let limits = context.policy().limits.decode; let justification_bytes = mint .justification() .ok_or(VerificationError::MalformedMintJustification)?; - let justification = SplitMintJustification::from_cbor_with_limits( - justification_bytes, - context.policy().limits.decode, - ) - .map_err(|_| VerificationError::MalformedMintJustification)?; + let justification = + SplitMintJustification::from_cbor_with_limits(justification_bytes, limits) + .map_err(|_| VerificationError::MalformedMintJustification)?; - // The minted token must declare the payment (assets) it received. - let payment_bytes = mint.data().ok_or(VerificationError::PaymentDataMissing)?; - if payment_bytes.len() > context.policy().limits.decode.max_input_bytes { + // (1) The minted token must declare a non-empty canonical asset payload. + let output_payment_bytes = mint.data().ok_or(VerificationError::PaymentDataMissing)?; + if output_payment_bytes.len() > limits.max_input_bytes { return Err(VerificationError::VerificationLimitExceeded( "payment data bytes", )); } - let assets = (self.decode_payment_data)(payment_bytes) + let output_assets = (self.decode_payment_data)(output_payment_bytes) .map_err(|_| VerificationError::MalformedTokenData)?; + let burned = justification.token(); + // Mint and source token must live on the same network. - if mint.network_id() != justification.token().genesis().transaction().network_id() { + if mint.network_id() != burned.genesis().transaction().network_id() { return Err(VerificationError::SplitNetworkMismatch); } - // The burned source token must itself fully verify (recursively, so a - // split of a split is checked end to end). + // (2) The burned source token must itself fully verify (recursively, so + // a split of a split is checked end to end) under the same context. context - .verify_embedded_token(justification.token(), justification.encoded_token_len()) + .verify_embedded_token(burned, justification.encoded_token_len()) .map_err(|e| VerificationError::BurnTokenVerificationFailed(Box::new(e)))?; - let source_payment_bytes = justification - .token() - .genesis() + // (3) The source must end in a certified burn transfer carrying the exact + // manifest, locked to burn(SHA-256(b_M)). + let burn_transfer = burned + .transactions() + .last() + .ok_or(VerificationError::SplitBurnTransferMissing)?; + let manifest_bytes = burn_transfer .transaction() + .data() + .ok_or(VerificationError::SplitManifestMissing)?; + if manifest_bytes.len() > limits.max_input_bytes { + return Err(VerificationError::VerificationLimitExceeded( + "split manifest bytes", + )); + } + let manifest = SplitManifest::from_cbor_bytes(manifest_bytes, limits) + .map_err(|_| VerificationError::SplitManifestMalformed)?; + let expected_burn = EncodedPredicate::from_predicate(&BurnPredicate::new( + manifest.reason_hash().to_vec(), + )); + if burn_transfer.recipient() != &expected_burn { + return Err(VerificationError::SplitBurnPredicateMismatch); + } + + // (4) Output token type must be byte-identical to the source token type. + let source = burned.genesis().transaction(); + if mint.token_type() != source.token_type() { + return Err(VerificationError::SplitTokenTypeMismatch); + } + + // (5) The source payload must be canonical with one manifest root each. + let source_payment_bytes = source .data() .ok_or(VerificationError::SplitSourcePaymentDataMissing)?; - if source_payment_bytes.len() > context.policy().limits.decode.max_input_bytes { + if source_payment_bytes.len() > limits.max_input_bytes { return Err(VerificationError::VerificationLimitExceeded( "source payment data bytes", )); } let source_assets = (self.decode_payment_data)(source_payment_bytes) .map_err(|_| VerificationError::SplitSourcePaymentDataMissing)?; - - if assets.len() != justification.proofs().len() { - return Err(VerificationError::SplitAssetCountMismatch); + if source_assets.len() != manifest.len() { + return Err(VerificationError::SplitManifestLengthMismatch); } - let token_id_path = key_to_path(mint.token_id().bytes()); - let aggregation_root = justification - .proofs() - .first() - .map(|p| p.aggregation_path().root()); - let last_recipient = justification - .token() - .transactions() - .last() - .map(|t| t.recipient()); - - let mut validated: BTreeSet> = BTreeSet::new(); - for proof in justification.proofs() { - let asset_key = proof.asset_id().bytes().to_vec(); - if validated.contains(&asset_key) { - return Err(VerificationError::DuplicateSplitProof); - } - - // Asset id is committed in the aggregation tree. - if !proof - .aggregation_path() - .verify(&proof.asset_id().to_path()) - .is_successful() - { - return Err(VerificationError::SplitAggregationPathInvalid); - } - - // This minted token's id is committed in the asset's sum tree. - let asset_path_result = proof.asset_tree_path().verify(&token_id_path); - if !asset_path_result.is_successful() { - return Err(VerificationError::SplitAssetTreePathInvalid); - } - - // All proofs must share one aggregation tree. - if Some(proof.aggregation_path().root()) != aggregation_root { - return Err(VerificationError::SplitProofRootMismatch); - } - - // The asset-tree root must be the leaf committed in the aggregation - // path (binding the two layers together). - if Some(proof.asset_tree_path().root().imprint().as_slice()) - != proof - .aggregation_path() - .steps() - .first() - .and_then(|s| s.data()) - { - return Err(VerificationError::SplitAssetTreeRootMismatch); - } - - // The certified amount must match the declared payment amount. - let amount = assets - .get(proof.asset_id()) - .map(|a| a.value()) - .ok_or(VerificationError::SplitAssetNotInPayment)?; - if proof.asset_tree_path().steps().first().map(|s| s.value()) != Some(amount) { - return Err(VerificationError::SplitAssetAmountMismatch); - } + // The number of proofs must equal the number of output assets; proofs are + // associated with assets purely by canonical order. + if justification.proofs().len() != output_assets.len() { + return Err(VerificationError::SplitProofCountMismatch); + } - // The sum committed by this asset tree must equal the amount that - // existed in the burned source. This is the verifier-side value - // conservation check; client-side split construction is untrusted. - let source_amount = source_assets - .get(proof.asset_id()) - .map(|asset| asset.value()) + // (6) Verify each allocation proof against its manifest root, and require + // the reconstructed root sum to equal the authenticated source amount. + let output_id = mint.token_id(); + let commitment = commitment_for_mint(burned.id(), mint) + .map_err(|_| VerificationError::PaymentDataMissing)?; + + for (asset, proof) in output_assets.as_slice().iter().zip(justification.proofs()) { + let index = source_assets + .as_slice() + .iter() + .position(|source_asset| source_asset.id() == asset.id()) .ok_or(VerificationError::SplitSourceAssetMissing)?; - if asset_path_result.root_sum() != Some(source_amount) { + let root = &manifest.roots()[index]; + let root_sum = proof + .verify(output_id.bytes(), &commitment, asset.value(), root) + .ok_or(VerificationError::SplitAllocationProofInvalid)?; + if &root_sum != source_assets.as_slice()[index].value() { return Err(VerificationError::SplitSourceAmountMismatch); } - - // The source token must have been burned to this aggregation root. - let expected_recipient = EncodedPredicate::from_predicate(&BurnPredicate::new( - proof.aggregation_path().root().imprint(), - )); - if last_recipient != Some(&expected_recipient) { - return Err(VerificationError::SplitBurnPredicateMismatch); - } - - validated.insert(asset_key); - } - - if validated.len() != assets.len() { - return Err(VerificationError::SplitProofsIncomplete); } Ok(()) diff --git a/src/rsmst/build.rs b/src/rsmst/build.rs new file mode 100644 index 0000000..4ce845a --- /dev/null +++ b/src/rsmst/build.rs @@ -0,0 +1,337 @@ +//! Mutable RSMST builder (`client` feature). +//! +//! Collects positive-valued leaves keyed by 256-bit token ids, then builds the +//! canonical path-compressed radix tree, from which the root `(hash, sum)` and +//! per-key leaf-to-root inclusion proofs are extracted. The bifurcation depth of +//! every internal node is the lowest key-bit position at which its descendant +//! keys disagree, so the structure — and therefore the root hash — is uniquely +//! determined by the key set, independent of insertion order. + +use alloc::boxed::Box; +use alloc::vec::Vec; + +use num_bigint::BigUint; + +use super::{is_valid_amount, key_bit, leaf_hash, node_hash, RsmstInclusionProof, RsmstProofStep}; +use crate::error::Error; + +struct Leaf { + key: [u8; 32], + value: BigUint, + hash: [u8; 32], +} + +enum Node { + Leaf(Leaf), + Branch { + depth: u8, + left: Box, + right: Box, + hash: [u8; 32], + sum: BigUint, + }, +} + +impl Node { + fn hash(&self) -> &[u8; 32] { + match self { + Node::Leaf(l) => &l.hash, + Node::Branch { hash, .. } => hash, + } + } + + fn sum(&self) -> &BigUint { + match self { + Node::Leaf(l) => &l.value, + Node::Branch { sum, .. } => sum, + } + } +} + +/// A mutable radix sparse Merkle sum tree under construction. +#[derive(Default)] +pub struct Rsmst { + leaves: Vec, +} + +impl core::fmt::Debug for Rsmst { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Rsmst") + .field("leaves", &self.leaves.len()) + .finish() + } +} + +impl Rsmst { + /// Create an empty tree. + pub fn new() -> Self { + Self::default() + } + + /// Insert a leaf `(key, data, value)`. Rejects a duplicate key, a zero or + /// over-256-bit `value`, and (defensively) any value that does not hash. + pub fn insert(&mut self, key: [u8; 32], data: [u8; 32], value: BigUint) -> Result<(), Error> { + if !is_valid_amount(&value) { + return Err(Error::OutOfRange("RSMST leaf amount must be in 1..2^256")); + } + if self.leaves.iter().any(|l| l.key == key) { + return Err(Error::UnexpectedValue("duplicate RSMST leaf key")); + } + let hash = leaf_hash(&key, &data, &value).ok_or(Error::OutOfRange("RSMST leaf amount"))?; + self.leaves.push(Leaf { key, value, hash }); + Ok(()) + } + + /// The number of leaves inserted so far. + pub fn len(&self) -> usize { + self.leaves.len() + } + + /// Whether no leaves have been inserted. + pub fn is_empty(&self) -> bool { + self.leaves.is_empty() + } + + /// Finalize the tree. Errors if empty or if any internal sum overflows. + pub fn build(self) -> Result { + if self.leaves.is_empty() { + return Err(Error::UnexpectedValue("RSMST must have at least one leaf")); + } + let root = build_subtree(self.leaves, 0)?; + Ok(BuiltRsmst { root }) + } +} + +/// Build the canonical subtree for a set of distinct leaves whose keys agree on +/// every bit below `start_bit`. +fn build_subtree(mut leaves: Vec, start_bit: u16) -> Result { + if leaves.len() == 1 { + return Ok(Node::Leaf(leaves.pop().expect("len == 1"))); + } + + // Lowest bit position at or above start_bit where the keys disagree. + let mut depth = start_bit; + let bifurcation = loop { + if depth > 255 { + // Distinct keys must disagree on some bit; reaching here means two + // leaves share all 256 bits (a duplicate that slipped through). + return Err(Error::UnexpectedValue("duplicate RSMST leaf key")); + } + let position = depth as u8; + let first = key_bit(&leaves[0].key, position); + if leaves.iter().any(|l| key_bit(&l.key, position) != first) { + break position; + } + depth += 1; + }; + + let mut left = Vec::new(); + let mut right = Vec::new(); + for leaf in leaves { + if key_bit(&leaf.key, bifurcation) { + right.push(leaf); + } else { + left.push(leaf); + } + } + + let next_bit = u16::from(bifurcation) + 1; + let left = build_subtree(left, next_bit)?; + let right = build_subtree(right, next_bit)?; + let (hash, sum) = node_hash( + bifurcation, + (left.hash(), left.sum()), + (right.hash(), right.sum()), + ) + .ok_or(Error::OutOfRange("RSMST internal sum exceeds 256 bits"))?; + Ok(Node::Branch { + depth: bifurcation, + left: Box::new(left), + right: Box::new(right), + hash, + sum, + }) +} + +/// A finalized RSMST: its root commitment and a proof extractor. +pub struct BuiltRsmst { + root: Node, +} + +impl core::fmt::Debug for BuiltRsmst { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("BuiltRsmst") + .field("root_hash", &hex::encode(self.root.hash())) + .field("root_sum", self.root.sum()) + .finish() + } +} + +impl BuiltRsmst { + /// The root hash (raw 32-byte SHA-256 digest). + pub fn root_hash(&self) -> [u8; 32] { + *self.root.hash() + } + + /// The root sum (total of all leaf amounts). + pub fn root_sum(&self) -> &BigUint { + self.root.sum() + } + + /// Extract the leaf-to-root inclusion proof for `key`, or `None` if `key` is + /// not a leaf of this tree. + pub fn proof(&self, key: &[u8; 32]) -> Option { + let mut steps = Vec::new(); + let mut node = &self.root; + loop { + match node { + Node::Leaf(leaf) => { + return (leaf.key == *key).then(|| { + steps.reverse(); + RsmstInclusionProof::new(steps) + }); + } + Node::Branch { + depth, + left, + right, + .. + } => { + let (sibling, next): (&Node, &Node) = if key_bit(key, *depth) { + (left, right) + } else { + (right, left) + }; + steps.push(RsmstProofStep::new(*depth, *sibling.hash(), sibling.sum().clone())); + node = next; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(bytes: &[(usize, u8)]) -> [u8; 32] { + let mut k = [0u8; 32]; + for &(i, b) in bytes { + k[i] = b; + } + k + } + + #[test] + fn single_leaf_root_is_leaf_hash_and_empty_proof() { + let mut tree = Rsmst::new(); + let k = key(&[(31, 1)]); + let d = [0xAB; 32]; + tree.insert(k, d, BigUint::from(42u32)).unwrap(); + let built = tree.build().unwrap(); + assert_eq!(*built.root_sum(), BigUint::from(42u32)); + assert_eq!(built.root_hash(), leaf_hash(&k, &d, &BigUint::from(42u32)).unwrap()); + let proof = built.proof(&k).unwrap(); + assert!(proof.steps().is_empty()); + assert_eq!( + proof.verify(&k, &d, &BigUint::from(42u32), &built.root_hash()), + Some(BigUint::from(42u32)) + ); + } + + #[test] + fn multi_leaf_proofs_verify_and_sum() { + let leaves: [([u8; 32], [u8; 32], u32); 5] = [ + (key(&[(31, 1)]), [0x01; 32], 10), + (key(&[(31, 2)]), [0x02; 32], 20), + (key(&[(31, 3)]), [0x03; 32], 5), + (key(&[(0, 0x80)]), [0x04; 32], 7), + (key(&[(15, 0x40)]), [0x05; 32], 3), + ]; + let mut tree = Rsmst::new(); + for (k, d, v) in &leaves { + tree.insert(*k, *d, BigUint::from(*v)).unwrap(); + } + let built = tree.build().unwrap(); + assert_eq!(*built.root_sum(), BigUint::from(45u32)); + let root = built.root_hash(); + + for (k, d, v) in &leaves { + let proof = built.proof(k).unwrap(); + // Strictly decreasing depths. + for w in proof.steps().windows(2) { + assert!(w[0].depth() > w[1].depth()); + } + assert_eq!( + proof.verify(k, d, &BigUint::from(*v), &root), + Some(BigUint::from(45u32)), + "leaf {k:?} should verify and reconstruct the total" + ); + // Wrong amount must not verify. + assert_eq!(proof.verify(k, d, &BigUint::from(*v + 1), &root), None); + } + } + + #[test] + fn rejects_duplicate_key_and_zero_value() { + let mut tree = Rsmst::new(); + let k = key(&[(31, 1)]); + tree.insert(k, [0; 32], BigUint::from(1u32)).unwrap(); + assert!(tree.insert(k, [1; 32], BigUint::from(2u32)).is_err()); + assert!(tree.insert(key(&[(31, 2)]), [0; 32], BigUint::ZERO).is_err()); + } + + /// Golden cross-implementation vector. The digests below were computed + /// independently from the yellowpaper hash definitions + /// (`SHA-256(0x10 || k || d || u256(v))` for leaves, + /// `SHA-256(0x11 || depth || hL || u256(vL) || hR || u256(vR))` for nodes). + /// Two keys `...01` and `...00` differ at bit 0, so the root bifurcates at + /// depth 0 with `...00` (bit 0 = 0) on the left. + #[test] + fn golden_two_leaf_root() { + use hex_literal::hex; + let key_a = key(&[(31, 0x01)]); + let key_b = key(&[]); // all zero + let data_a = [0xAA; 32]; + let data_b = [0xBB; 32]; + + assert_eq!( + leaf_hash(&key_a, &data_a, &BigUint::from(10u32)).unwrap(), + hex!("d2eeb3078ac5565fadc748863de7ea50affc0c13bf94b079c7abe971ec2a6213") + ); + assert_eq!( + leaf_hash(&key_b, &data_b, &BigUint::from(20u32)).unwrap(), + hex!("51f25c331b15d0051422df960c80bdc5456be7422c2046b7a73a541cea81e6aa") + ); + + let mut tree = Rsmst::new(); + tree.insert(key_a, data_a, BigUint::from(10u32)).unwrap(); + tree.insert(key_b, data_b, BigUint::from(20u32)).unwrap(); + let built = tree.build().unwrap(); + assert_eq!( + built.root_hash(), + hex!("5aa8ce35ddea4792808b631b70b95e1406bf1a137f5c512f4adf1e36c1999c51") + ); + assert_eq!(*built.root_sum(), BigUint::from(30u32)); + + // The single-step proof for key_a folds in sibling key_b at depth 0. + let proof = built.proof(&key_a).unwrap(); + assert_eq!(proof.steps().len(), 1); + assert_eq!(proof.steps()[0].depth(), 0); + assert_eq!( + proof.verify(&key_a, &data_a, &BigUint::from(10u32), &built.root_hash()), + Some(BigUint::from(30u32)) + ); + } + + #[test] + fn proof_for_absent_key_is_none() { + let mut tree = Rsmst::new(); + tree.insert(key(&[(31, 1)]), [0; 32], BigUint::from(1u32)) + .unwrap(); + tree.insert(key(&[(31, 2)]), [0; 32], BigUint::from(1u32)) + .unwrap(); + let built = tree.build().unwrap(); + assert!(built.proof(&key(&[(31, 9)])).is_none()); + } +} diff --git a/src/rsmst/mod.rs b/src/rsmst/mod.rs new file mode 100644 index 0000000..e54a62e --- /dev/null +++ b/src/rsmst/mod.rs @@ -0,0 +1,201 @@ +//! Radix Sparse Merkle Sum Tree (RSMST). +//! +//! An RSMST is the radix sparse Merkle tree (a leaf-anchored, path-compressed +//! binary trie over a 256-bit key space, LSB-first) decorated with a positive +//! amount at every leaf and an accumulated sum at every internal node. It backs +//! the per-asset split-allocation roots of the token-splitting protocol +//! (yellowpaper Appendix "Radix Sparse Merkle Sum Trees"): one tree per asset +//! maps each output token id to the amount that output receives, and a single +//! leaf-to-root inclusion proof simultaneously proves a leaf's amount *and* that +//! the committed total equals the burned source amount. +//! +//! The structure deliberately mirrors the plain radix SMT used for transaction +//! inclusion ([`InclusionCertificate`](crate::api::InclusionCertificate)) — same +//! key space, same LSB-first bit order, same canonical bifurcation depths — but +//! is domain-separated from it (`0x10`/`0x11` vs `0x00`/`0x01`) and additionally +//! commits the per-subtree sum next to every child hash. +//! +//! Hash inputs are fixed binary concatenations, **not** CBOR tuples: +//! +//! ```text +//! leaf: SHA-256( 0x10 || key[32] || data[32] || u256(value) ) +//! node: SHA-256( 0x11 || depth[1] || hL[32] || u256(sumL) || hR[32] || u256(sumR) ) +//! ``` +//! +//! where `u256(x)` is the 32-byte big-endian encoding of `x` and `depth` is the +//! absolute bifurcation bit position. A node sum is `sumL + sumR` with checked +//! 256-bit addition: any overflow makes the node (and hence the proof) invalid. +//! +//! Proof *verification* ([`RsmstInclusionProof::verify`]) is part of the `no_std` +//! core; the mutable [`build::Rsmst`] builder is gated behind the `client` +//! feature. + +mod proof; + +#[cfg(any(feature = "client", test))] +pub mod build; + +pub use proof::{RsmstInclusionProof, RsmstProofStep}; + +use alloc::vec::Vec; + +use num_bigint::BigUint; + +use crate::crypto::hash::sha256; +use crate::error::{CborError, Error}; + +/// Domain-separation prefix for an RSMST leaf hash. +pub const RSMST_LEAF: u8 = 0x10; +/// Domain-separation prefix for an RSMST internal-node hash. +pub const RSMST_NODE: u8 = 0x11; + +/// Maximum byte length of a 256-bit amount or subtree sum (`v < 2^256`). +pub const AMOUNT_MAX_BYTES: usize = 32; + +/// Maximum number of sibling entries in one inclusion proof (one per key bit). +pub const MAX_PROOF_STEPS: usize = 256; + +/// Encode a non-negative amount as a minimal big-endian byte string. +/// +/// Zero encodes to the empty string; a positive value to its big-endian bytes +/// with no leading `0x00`. This is the wire form of split amounts and sibling +/// sums (the latter are always positive). +pub fn encode_amount(value: &BigUint) -> Vec { + if *value == BigUint::ZERO { + Vec::new() + } else { + value.to_bytes_be() + } +} + +/// Decode a strictly positive, minimally encoded big-endian amount. +/// +/// Rejects the empty string (zero is not a valid amount or sibling sum), a +/// leading `0x00` (non-minimal), and anything wider than 256 bits. +pub fn decode_positive_amount(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(Error::OutOfRange("amount must be positive")); + } + if bytes.len() > AMOUNT_MAX_BYTES { + return Err(Error::OutOfRange("amount exceeds 256 bits")); + } + if bytes[0] == 0 { + return Err(CborError::NonCanonicalEncoding.into()); + } + Ok(BigUint::from_bytes_be(bytes)) +} + +/// `true` when `value` fits the protocol amount domain `1 <= v < 2^256`. +pub fn is_valid_amount(value: &BigUint) -> bool { + *value != BigUint::ZERO && value.bits() <= 256 +} + +/// The fixed 32-byte big-endian encoding of `value`, or `None` if `value` does +/// not fit in 256 bits. +fn u256_be(value: &BigUint) -> Option<[u8; 32]> { + let bytes = value.to_bytes_be(); + if bytes.len() > AMOUNT_MAX_BYTES { + return None; + } + let mut out = [0u8; 32]; + out[AMOUNT_MAX_BYTES - bytes.len()..].copy_from_slice(&bytes); + Some(out) +} + +/// SHA-256 of the concatenation of `parts`, returned as the raw 32-byte digest. +fn sha256_raw(parts: &[&[u8]]) -> [u8; 32] { + let len: usize = parts.iter().map(|p| p.len()).sum(); + let mut buf = Vec::with_capacity(len); + for part in parts { + buf.extend_from_slice(part); + } + let mut out = [0u8; 32]; + out.copy_from_slice(sha256(&buf).data()); + out +} + +/// `rsmst_leaf_hash(k, d, v)` — `SHA-256(0x10 || k || d || u256(v))`. +/// +/// Returns `None` if `v` does not fit in 256 bits (callers reject the leaf). +pub fn leaf_hash(key: &[u8; 32], data: &[u8; 32], value: &BigUint) -> Option<[u8; 32]> { + let value = u256_be(value)?; + Some(sha256_raw(&[&[RSMST_LEAF], key, data, &value])) +} + +/// `rsmst_node_hash(δ, (hL, vL), (hR, vR))` with checked 256-bit sum. +/// +/// Returns `(hash, sum)` where `sum = vL + vR`, or `None` if that sum overflows +/// 256 bits (in which case the node, and any proof using it, is invalid). +pub fn node_hash( + depth: u8, + left: (&[u8; 32], &BigUint), + right: (&[u8; 32], &BigUint), +) -> Option<([u8; 32], BigUint)> { + let sum = left.1 + right.1; + if sum.bits() > 256 { + return None; + } + let left_sum = u256_be(left.1)?; + let right_sum = u256_be(right.1)?; + let hash = sha256_raw(&[ + &[RSMST_NODE], + &[depth], + left.0, + &left_sum, + right.0, + &right_sum, + ]); + Some((hash, sum)) +} + +/// Bit `index` of a 256-bit key in LSB-first order: bit 0 is the least +/// significant bit of the whole key (the low bit of the last byte). +fn key_bit(key: &[u8; 32], index: u8) -> bool { + let byte = 31 - (index / 8) as usize; + (key[byte] >> (index % 8)) & 1 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_bit_is_lsb_first() { + let mut key = [0u8; 32]; + key[31] = 0b0000_0101; // bits 0 and 2 set + key[30] = 0b0000_0010; // bit 9 set + key[0] = 0b1000_0000; // bit 255 set + assert!(key_bit(&key, 0)); + assert!(!key_bit(&key, 1)); + assert!(key_bit(&key, 2)); + assert!(key_bit(&key, 9)); + assert!(key_bit(&key, 255)); + assert!(!key_bit(&key, 254)); + } + + #[test] + fn u256_is_left_padded_big_endian() { + assert_eq!(u256_be(&BigUint::ZERO), Some([0u8; 32])); + let mut expected = [0u8; 32]; + expected[31] = 1; + assert_eq!(u256_be(&BigUint::from(1u8)), Some(expected)); + // 2^256 does not fit. + assert_eq!(u256_be(&(BigUint::from(1u8) << 256)), None); + } + + #[test] + fn node_hash_rejects_sum_overflow() { + let max = (BigUint::from(1u8) << 256) - BigUint::from(1u8); + let h = [0u8; 32]; + assert!(node_hash(0, (&h, &max), (&h, &BigUint::from(1u8))).is_none()); + assert!(node_hash(0, (&h, &max), (&h, &BigUint::ZERO)).is_some()); + } + + #[test] + fn decode_positive_amount_enforces_minimality() { + assert!(decode_positive_amount(&[]).is_err()); + assert!(decode_positive_amount(&[0x00, 0x01]).is_err()); + assert!(decode_positive_amount(&[0u8; 33]).is_err()); + assert_eq!(decode_positive_amount(&[0x01]).unwrap(), BigUint::from(1u8)); + } +} diff --git a/src/rsmst/proof.rs b/src/rsmst/proof.rs new file mode 100644 index 0000000..3b5ec14 --- /dev/null +++ b/src/rsmst/proof.rs @@ -0,0 +1,213 @@ +//! Explicit-depth RSMST inclusion proof: the wire type and its verifier. +//! +//! A proof is a leaf-to-root sequence of sibling entries `(δ, s, w)` — the +//! sibling's parent bifurcation depth, the sibling subtree hash, and the sibling +//! subtree sum. Unlike the plain RSMT inclusion certificate, no bitmap is +//! carried: the explicit depth in each entry fully identifies the branch step, +//! and depths are strictly decreasing from the leaf toward the root. +//! +//! The leaf key, leaf data, leaf amount and root hash are **not** part of the +//! encoded proof; the verifier supplies them from the output token id, the +//! output commitment, the output payload and the split manifest respectively +//! (yellowpaper "Split Allocation Inclusion Proof"). + +use alloc::vec::Vec; + +use num_bigint::BigUint; + +use super::{ + decode_positive_amount, encode_amount, is_valid_amount, key_bit, leaf_hash, node_hash, + MAX_PROOF_STEPS, +}; +use crate::cbor::{encode_array, encode_byte_string, encode_uint, Decoder}; +use crate::error::Error; + +/// One sibling entry on the leaf-to-root path: the sibling's parent bifurcation +/// `depth`, the sibling subtree `hash`, and the positive sibling subtree `sum`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RsmstProofStep { + depth: u8, + hash: [u8; 32], + sum: BigUint, +} + +impl RsmstProofStep { + /// Construct a step. `sum` must be a positive 256-bit amount. + pub fn new(depth: u8, hash: [u8; 32], sum: BigUint) -> Self { + RsmstProofStep { depth, hash, sum } + } + + /// The sibling's parent bifurcation depth (`0..=255`). + pub fn depth(&self) -> u8 { + self.depth + } + + /// The sibling subtree hash. + pub fn hash(&self) -> &[u8; 32] { + &self.hash + } + + /// The sibling subtree sum. + pub fn sum(&self) -> &BigUint { + &self.sum + } + + /// Decode from CBOR: `[uint(depth), bstr(hash[32]), bstr(sum)]`. + fn from_cbor(d: Decoder<'_>) -> Result { + let items = d.array(Some(3))?; + let depth = u8::try_from(items[0].uint()?) + .map_err(|_| Error::OutOfRange("RSMST proof depth exceeds 255"))?; + let hash: [u8; 32] = items[1] + .bytes_value()? + .try_into() + .map_err(|_| Error::InvalidLength { + what: "RSMST sibling hash", + expected: 32, + actual: 0, + })?; + let sum = decode_positive_amount(items[2].bytes_value()?)?; + Ok(RsmstProofStep { depth, hash, sum }) + } + + /// Encode to CBOR: `[uint(depth), bstr(hash[32]), bstr(sum)]`. + fn to_cbor(&self) -> Vec { + encode_array(&[ + &encode_uint(self.depth as u64), + &encode_byte_string(&self.hash), + &encode_byte_string(&encode_amount(&self.sum)), + ]) + } +} + +/// A complete leaf-to-root RSMST inclusion proof (a vector of sibling entries). +/// +/// An empty proof is valid only for a single-leaf tree, where the leaf hash is +/// the root hash and the leaf amount is the root sum. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RsmstInclusionProof { + steps: Vec, +} + +impl RsmstInclusionProof { + /// Construct from leaf-to-root sibling entries. + pub fn new(steps: Vec) -> Self { + RsmstInclusionProof { steps } + } + + /// The sibling entries, leaf first. + pub fn steps(&self) -> &[RsmstProofStep] { + &self.steps + } + + /// Decode from CBOR: an array of `0..=256` sibling entries. + /// + /// Rejects more than 256 entries, depths outside `0..=255`, non-decreasing + /// depths, sibling hashes that are not 32 bytes, and zero / non-minimal / + /// overlong sibling sums. + pub fn from_cbor(d: Decoder<'_>) -> Result { + let encoded = d.array(None)?; + if encoded.len() > MAX_PROOF_STEPS { + return Err(Error::OutOfRange("RSMST proof has too many steps")); + } + let mut steps = Vec::with_capacity(encoded.len()); + let mut prev_depth: Option = None; + for entry in encoded { + let step = RsmstProofStep::from_cbor(entry)?; + if let Some(previous) = prev_depth { + if step.depth >= previous { + return Err(Error::UnexpectedValue( + "RSMST proof depths are not strictly decreasing", + )); + } + } + prev_depth = Some(step.depth); + steps.push(step); + } + Ok(RsmstInclusionProof { steps }) + } + + /// Encode to CBOR: an array of sibling entries. + pub fn to_cbor(&self) -> Vec { + let parts: Vec> = self.steps.iter().map(RsmstProofStep::to_cbor).collect(); + let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect(); + encode_array(&refs) + } + + /// `rsmst_verify_inclusion(k, d, v, C, r)`. + /// + /// Reconstructs the root hash from the leaf `(key, data, value)` upward, + /// folding in each sibling at its committed depth, and returns the + /// reconstructed root **sum** iff the reconstruction equals `root`. The + /// caller compares that sum to the authenticated source amount for value + /// conservation — the root hash alone is not sufficient, because sibling + /// sums are committed by every internal node hash. + /// + /// Returns `None` on any failure: an out-of-domain amount, an over-long + /// proof, non-decreasing or out-of-range depths, a non-positive sibling sum, + /// a 256-bit sum overflow, or a root-hash mismatch. + pub fn verify( + &self, + key: &[u8; 32], + data: &[u8; 32], + value: &BigUint, + root: &[u8; 32], + ) -> Option { + if !is_valid_amount(value) || self.steps.len() > MAX_PROOF_STEPS { + return None; + } + let mut hash = leaf_hash(key, data, value)?; + let mut sum = value.clone(); + // Sentinel above the maximum depth so the first step (any depth 0..=255) + // satisfies the strictly-decreasing requirement. + let mut prev_depth: u16 = 256; + + for step in &self.steps { + if u16::from(step.depth) >= prev_depth || !is_valid_amount(&step.sum) { + return None; + } + let (next_hash, next_sum) = if key_bit(key, step.depth) { + // The leaf side is the right child at this depth. + node_hash(step.depth, (&step.hash, &step.sum), (&hash, &sum))? + } else { + node_hash(step.depth, (&hash, &sum), (&step.hash, &step.sum))? + }; + hash = next_hash; + sum = next_sum; + prev_depth = u16::from(step.depth); + } + + (hash == *root).then_some(sum) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cbor::Decoder; + + fn step(depth: u8, sum: u32) -> RsmstProofStep { + RsmstProofStep::new(depth, [depth; 32], BigUint::from(sum)) + } + + #[test] + fn cbor_roundtrip() { + let proof = RsmstInclusionProof::new(alloc::vec![step(9, 7), step(3, 11), step(1, 5)]); + let bytes = proof.to_cbor(); + let decoded = RsmstInclusionProof::from_cbor(Decoder::new(&bytes)).unwrap(); + assert_eq!(decoded, proof); + assert_eq!(decoded.to_cbor(), bytes); + } + + #[test] + fn decode_rejects_non_decreasing_depths() { + let proof = RsmstInclusionProof::new(alloc::vec![step(3, 1), step(3, 1)]); + let bytes = proof.to_cbor(); + assert!(RsmstInclusionProof::from_cbor(Decoder::new(&bytes)).is_err()); + } + + #[test] + fn decode_rejects_zero_sibling_sum() { + let bytes = RsmstInclusionProof::new(alloc::vec![step(3, 0)]).to_cbor(); + assert!(RsmstInclusionProof::from_cbor(Decoder::new(&bytes)).is_err()); + } +} diff --git a/src/smt/bigint.rs b/src/smt/bigint.rs deleted file mode 100644 index 9359f6d..0000000 --- a/src/smt/bigint.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Big-integer helpers for sparse-Merkle-tree path routing. -//! -//! The plain and sum trees route by interpreting keys and step paths as -//! arbitrary-precision unsigned integers, exactly mirroring the reference SDKs' -//! `BitString` and `BigintConverter`. Two conventions are load-bearing for -//! binary compatibility and must be reproduced precisely: -//! -//! * **`BigintConverter`** is big-endian and *minimal*: the integer `0` encodes -//! to the **empty** byte string, not a single `0x00`. [`bytes_to_path`] / -//! [`path_to_bytes`] implement this. -//! * **`BitString`** prepends a sentinel `0x01` byte before interpreting the key -//! bytes big-endian, so leading zero bits survive the round-trip. The routing -//! path of a key is therefore `0x01 ‖ key` read big-endian ([`key_to_path`]). - -use alloc::vec::Vec; - -use num_bigint::BigUint; -use num_traits::{One, Zero}; - -use crate::error::{CborError, Error}; - -/// Decode a `BigintConverter`-encoded big-endian byte string into a [`BigUint`]. -/// The empty string decodes to zero. -pub fn bytes_to_path(bytes: &[u8]) -> Result { - if bytes.first() == Some(&0) { - return Err(CborError::NonCanonicalEncoding.into()); - } - Ok(BigUint::from_bytes_be(bytes)) -} - -/// Encode a [`BigUint`] as a `BigintConverter` big-endian byte string. Zero -/// encodes to the empty string (matching the reference, which strips leading -/// zero bytes entirely). -pub fn path_to_bytes(value: &BigUint) -> Vec { - if value.is_zero() { - Vec::new() - } else { - value.to_bytes_be() - } -} - -/// The sparse-Merkle routing path of a key: `0x01 ‖ key` interpreted big-endian -/// (the reference `BitString.fromBytes(key).toBigInt()`). -pub fn key_to_path(key: &[u8]) -> BigUint { - let mut buf = Vec::with_capacity(key.len() + 1); - buf.push(0x01); - buf.extend_from_slice(key); - BigUint::from_bytes_be(&buf) -} - -/// The number of significant bits in `value` (`0` for zero), matching noble's -/// `bitLen`. -pub fn bit_len(value: &BigUint) -> u64 { - value.bits() -} - -/// The longest common prefix path of two routing paths, returned as -/// `(length, path)` exactly like the reference `calculateCommonPath`. -/// -/// Walks bit positions from the least-significant end while the two paths agree -/// and neither has been fully consumed; `path` accumulates the shared bits with -/// the same sentinel-bit convention used throughout tree routing. -pub fn calculate_common_path(path1: &BigUint, path2: &BigUint) -> (BigUint, BigUint) { - let one = BigUint::one(); - let mut path = one.clone(); - let mut mask = one.clone(); - let mut length = BigUint::zero(); - - // (path1 & mask) == (path2 & mask) && path < path1 && path < path2 - while (path1 & &mask) == (path2 & &mask) && path < *path1 && path < *path2 { - mask <<= 1u32; - length += &one; - // path = mask | ((mask - 1) & path1) - path = &mask | (&(&mask - &one) & path1); - } - - (length, path) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::{CborError, Error}; - - #[test] - fn bigint_encoding_is_minimal() { - assert_eq!(bytes_to_path(&[]).unwrap(), BigUint::ZERO); - assert_eq!(bytes_to_path(&[1]).unwrap(), BigUint::one()); - assert_eq!( - bytes_to_path(&[0, 1]), - Err(Error::Cbor(CborError::NonCanonicalEncoding)) - ); - } -} diff --git a/src/smt/mod.rs b/src/smt/mod.rs deleted file mode 100644 index df8fb6a..0000000 --- a/src/smt/mod.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Sparse Merkle trees used by the token-split payment subsystem. -//! -//! These are the **bigint-routed** plain and sum trees from the reference SDKs -//! (`smt/plain`, `smt/sum`), distinct from the radix SMT in -//! [`InclusionCertificate`](crate::api::InclusionCertificate) that proves a -//! transaction's inclusion under a block root. A split commits its outputs in -//! two layers: -//! -//! * a **sum tree** per asset, mapping each new token's id to the amount it -//! receives (the node sums let a verifier check value conservation), and -//! * a **plain aggregation tree** over all asset trees, mapping each asset id to -//! its sum-tree root. -//! -//! The aggregation root is the burn predicate's reason, binding the burned -//! source token to exactly this set of outputs. -//! -//! Path *verification* ([`plain::SparseMerkleTreePath::verify`], -//! [`sum::SparseMerkleSumTreePath::verify`]) is part of the `no_std` core; the -//! mutable tree *builders* are gated behind the `client` feature. - -pub mod bigint; -pub mod plain; -pub mod sum; - -/// Maximum routing-path width accepted by the payment SMT protocol. -pub const MAX_SMT_PATH_BYTES: usize = 129; -/// Maximum number of compressed nodes in one payment SMT proof. -pub const MAX_SMT_PATH_STEPS: usize = 1025; -/// Maximum opaque leaf/sibling data length accepted in a payment SMT proof. -pub const MAX_SMT_DATA_BYTES: usize = 128; - -/// Outcome of verifying a sparse-Merkle-tree path. -/// -/// A path is only trustworthy when it is both well-formed (recomputes the -/// committed root) *and* proves inclusion of the queried key. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PathVerificationResult { - /// The path recomputed the committed root hash. - pub is_path_valid: bool, - /// The path routes to the queried key. - pub is_path_included: bool, -} - -/// Outcome of verifying a sparse-Merkle sum-tree path. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SumPathVerificationResult { - path: PathVerificationResult, - root_sum: Option, -} - -impl SumPathVerificationResult { - pub(crate) fn invalid() -> Self { - Self { - path: PathVerificationResult::new(false, false), - root_sum: None, - } - } - - pub(crate) fn new( - is_path_valid: bool, - is_path_included: bool, - root_sum: num_bigint::BigUint, - ) -> Self { - let path = PathVerificationResult::new(is_path_valid, is_path_included); - let root_sum = path.is_successful().then_some(root_sum); - Self { path, root_sum } - } - - /// `true` only when the path recomputes the root and proves inclusion. - pub fn is_successful(&self) -> bool { - self.path.is_successful() - } - - /// Sum committed by the verified root, available only for a successful path. - pub fn root_sum(&self) -> Option<&num_bigint::BigUint> { - self.root_sum.as_ref() - } -} - -impl PathVerificationResult { - /// Construct a result; [`is_successful`](Self::is_successful) is their `&&`. - pub fn new(is_path_valid: bool, is_path_included: bool) -> Self { - PathVerificationResult { - is_path_valid, - is_path_included, - } - } - - /// `true` only when the path is both valid and proves inclusion. - pub fn is_successful(&self) -> bool { - self.is_path_valid && self.is_path_included - } -} diff --git a/src/smt/plain.rs b/src/smt/plain.rs deleted file mode 100644 index 70c8f1e..0000000 --- a/src/smt/plain.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Plain sparse Merkle tree: path type + verification (core) and the mutable -//! builder (`client`). Used as the split *aggregation tree* over asset roots. - -use alloc::vec::Vec; - -use num_bigint::BigUint; -use num_traits::One; - -use super::bigint::{bit_len, bytes_to_path, path_to_bytes}; -use super::{PathVerificationResult, MAX_SMT_DATA_BYTES, MAX_SMT_PATH_BYTES, MAX_SMT_PATH_STEPS}; -use crate::cbor::{encode_array, encode_byte_string, encode_nullable, Decoder}; -use crate::crypto::hash::{DataHash, DataHasher, HashAlgorithm}; -use crate::error::Error; - -/// A single step along a plain SMT path: the routing `path` of the node and the -/// sibling/leaf `data` hashed at that level (`null` for an empty child). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SparseMerkleTreePathStep { - path: BigUint, - data: Option>, -} - -impl SparseMerkleTreePathStep { - /// Construct a step from its routing path and optional data. - pub fn new(path: BigUint, data: Option>) -> Self { - SparseMerkleTreePathStep { path, data } - } - - /// The routing path of this step. - pub fn path(&self) -> &BigUint { - &self.path - } - - /// The data hashed at this level, if any. - pub fn data(&self) -> Option<&[u8]> { - self.data.as_deref() - } - - /// Decode from CBOR: `[bstr(path), nullable bstr(data)]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(2))?; - let path_bytes = items[0].bytes_value()?; - if path_bytes.len() > MAX_SMT_PATH_BYTES { - return Err(Error::OutOfRange("SMT path exceeds protocol limit")); - } - let path = bytes_to_path(path_bytes)?; - let data = - items[1].nullable(|d| d.bytes_value().map(<[u8]>::to_vec).map_err(Into::into))?; - if data - .as_ref() - .is_some_and(|value| value.len() > MAX_SMT_DATA_BYTES) - { - return Err(Error::OutOfRange("SMT data exceeds protocol limit")); - } - Ok(SparseMerkleTreePathStep { path, data }) - } - - /// Encode to CBOR: `[bstr(path), nullable bstr(data)]`. - pub fn to_cbor(&self) -> Vec { - encode_array(&[ - &encode_byte_string(&path_to_bytes(&self.path)), - &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - ]) - } -} - -/// A leaf-to-root path through a plain sparse Merkle tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SparseMerkleTreePath { - root: DataHash, - steps: Vec, -} - -impl SparseMerkleTreePath { - /// Construct from a committed root and its steps. - pub fn new(root: DataHash, steps: Vec) -> Self { - SparseMerkleTreePath { root, steps } - } - - /// The committed root hash. - pub fn root(&self) -> &DataHash { - &self.root - } - - /// The path steps, leaf first. - pub fn steps(&self) -> &[SparseMerkleTreePathStep] { - &self.steps - } - - /// Decode from CBOR: `[bstr(root.imprint), [steps...]]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(2))?; - let root = DataHash::from_imprint(items[0].bytes_value()?)?; - let encoded_steps = items[1].array(None)?; - if encoded_steps.len() > MAX_SMT_PATH_STEPS { - return Err(Error::OutOfRange("SMT proof has too many steps")); - } - let mut steps = Vec::new(); - for step in encoded_steps { - steps.push(SparseMerkleTreePathStep::from_cbor(step)?); - } - Ok(SparseMerkleTreePath { root, steps }) - } - - /// Encode to CBOR: `[bstr(root.imprint), [steps...]]`. - pub fn to_cbor(&self) -> Vec { - let step_bytes: Vec> = self.steps.iter().map(|s| s.to_cbor()).collect(); - let step_refs: Vec<&[u8]> = step_bytes.iter().map(Vec::as_slice).collect(); - encode_array(&[ - &encode_byte_string(&self.root.imprint()), - &encode_array(&step_refs), - ]) - } - - /// Verify the path against its committed root and the routing path of the - /// queried key. Returns whether the path recomputes the root and whether it - /// routes to `key_path`. - pub fn verify(&self, key_path: &BigUint) -> PathVerificationResult { - if self.steps.len() > MAX_SMT_PATH_STEPS - || key_path.to_bytes_be().len() > MAX_SMT_PATH_BYTES - || self.steps.iter().any(|step| { - step.path.to_bytes_be().len() > MAX_SMT_PATH_BYTES - || step - .data - .as_ref() - .is_some_and(|value| value.len() > MAX_SMT_DATA_BYTES) - }) - { - return PathVerificationResult::new(false, false); - } - let Some(first) = self.steps.first() else { - return PathVerificationResult::new(false, false); - }; - let algorithm = self.root.algorithm(); - let one = BigUint::one(); - - let mut current_path = first.path.clone(); - let mut current_data: Option>; - if first.path > one { - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&first.path)), - &encode_nullable(first.data.as_ref(), |v| encode_byte_string(v)), - ]); - current_data = match hash(algorithm, &preimage) { - Some(d) => Some(d), - None => return PathVerificationResult::new(false, false), - }; - } else { - current_path = one.clone(); - current_data = first.data.clone(); - } - - for i in 1..self.steps.len() { - let step = &self.steps[i]; - let is_right = self.steps[i - 1].path.bit(0); - let (left, right) = if is_right { - (step.data.as_deref(), current_data.as_deref()) - } else { - (current_data.as_deref(), step.data.as_deref()) - }; - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&step.path)), - &encode_nullable(left, encode_byte_string), - &encode_nullable(right, encode_byte_string), - ]); - current_data = match hash(algorithm, &preimage) { - Some(d) => Some(d), - None => return PathVerificationResult::new(false, false), - }; - - let bits = bit_len(&step.path); - if bits == 0 { - return PathVerificationResult::new(false, false); - } - let length = bits - 1; - // current_path = (current_path << length) | (step.path & ((1 << length) - 1)) - current_path = (current_path << length) | (&step.path & ((&one << length) - &one)); - } - - let path_valid = current_data - .as_deref() - .is_some_and(|d| d == self.root.data()); - let path_included = key_path == ¤t_path; - PathVerificationResult::new(path_valid, path_included) - } -} - -/// Hash a CBOR preimage with `algorithm`, returning the raw digest bytes, or -/// `None` if the algorithm has no streaming hasher (e.g. RIPEMD-160). -fn hash(algorithm: HashAlgorithm, preimage: &[u8]) -> Option> { - Some( - DataHasher::new(algorithm) - .ok()? - .update(preimage) - .finalize() - .data() - .to_vec(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cbor::Decoder; - use crate::smt::bigint::key_to_path; - - #[test] - fn build_get_path_verify_roundtrip() { - // Fixed-length (32-byte) keys, as in real usage (asset/token ids). - let keys: [[u8; 32]; 4] = [key32(1), key32(2), key32(0xff), key32(0x80)]; - let mut tree = SparseMerkleTree::new(); - for (i, k) in keys.iter().enumerate() { - tree.add_leaf(key_to_path(k), alloc::vec![i as u8; 8]) - .unwrap(); - } - let root = tree.calculate_root(); - - for k in keys { - let path = root.get_path(&key_to_path(&k)); - let result = path.verify(&key_to_path(&k)); - assert!(result.is_successful(), "key {k:?} should verify"); - - // CBOR round-trips byte-for-byte. - let bytes = path.to_cbor(); - let decoded = SparseMerkleTreePath::from_cbor(Decoder::new(&bytes)).unwrap(); - assert_eq!(decoded, path); - assert_eq!(decoded.to_cbor(), bytes); - - // A path proves inclusion only for its own key. - assert!(!path.verify(&key_to_path(&key32(0x55))).is_path_included); - } - } - - fn key32(last: u8) -> [u8; 32] { - let mut k = [0u8; 32]; - k[31] = last; - k - } -} - -#[cfg(any(feature = "client", test))] -pub use build::{SparseMerkleTree, SparseMerkleTreeRootNode}; - -#[cfg(any(feature = "client", test))] -mod build { - //! Mutable plain SMT builder (insert leaves, finalize, extract paths). - - use alloc::boxed::Box; - use alloc::vec; - use alloc::vec::Vec; - - use num_bigint::BigUint; - use num_traits::One; - - use super::{SparseMerkleTreePath, SparseMerkleTreePathStep}; - use crate::cbor::{encode_array, encode_byte_string, encode_nullable}; - use crate::crypto::hash::{DataHash, DataHasher, HashAlgorithm}; - use crate::error::Error; - use crate::smt::bigint::{calculate_common_path, path_to_bytes}; - - enum Pending { - Leaf { - path: BigUint, - data: Vec, - }, - Node { - path: BigUint, - left: Box, - right: Box, - }, - } - - enum Finalized { - Leaf { - path: BigUint, - data: Vec, - hash: DataHash, - }, - Node { - path: BigUint, - left: Box, - right: Box, - hash: DataHash, - }, - } - - impl Finalized { - fn hash(&self) -> &DataHash { - match self { - Finalized::Leaf { hash, .. } | Finalized::Node { hash, .. } => hash, - } - } - } - - fn digest(algorithm: HashAlgorithm, preimage: &[u8]) -> DataHash { - DataHasher::new(algorithm) - .expect("split SMT uses SHA-256") - .update(preimage) - .finalize() - } - - impl Pending { - fn path(&self) -> &BigUint { - match self { - Pending::Leaf { path, .. } | Pending::Node { path, .. } => path, - } - } - - fn finalize(self, algorithm: HashAlgorithm) -> Finalized { - match self { - Pending::Leaf { path, data } => { - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&path)), - &encode_byte_string(&data), - ]); - let hash = digest(algorithm, &preimage); - Finalized::Leaf { path, data, hash } - } - Pending::Node { path, left, right } => { - let left = left.finalize(algorithm); - let right = right.finalize(algorithm); - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&path)), - &encode_byte_string(left.hash().data()), - &encode_byte_string(right.hash().data()), - ]); - let hash = digest(algorithm, &preimage); - Finalized::Node { - path, - left: Box::new(left), - right: Box::new(right), - hash, - } - } - } - } - } - - /// A mutable plain sparse Merkle tree. Insert leaves with - /// [`add_leaf`](Self::add_leaf), then [`calculate_root`](Self::calculate_root). - #[derive(Default)] - pub struct SparseMerkleTree { - left: Option, - right: Option, - } - - impl core::fmt::Debug for SparseMerkleTree { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("SparseMerkleTree").finish_non_exhaustive() - } - } - - impl SparseMerkleTree { - /// Create an empty tree. - pub fn new() -> Self { - Self::default() - } - - /// Insert a leaf at routing `path` (which must be `>= 1`). - pub fn add_leaf(&mut self, path: BigUint, data: Vec) -> Result<(), Error> { - if path < BigUint::one() { - return Err(Error::OutOfRange("SMT leaf path must be >= 1")); - } - let is_right = path.bit(0); - let slot = if is_right { - &mut self.right - } else { - &mut self.left - }; - let new = match slot.take() { - Some(branch) => build_tree(branch, path, data)?, - None => Pending::Leaf { path, data }, - }; - *slot = Some(new); - Ok(()) - } - - /// Finalize all hashes and return the root node. - pub fn calculate_root(self) -> SparseMerkleTreeRootNode { - let algorithm = HashAlgorithm::Sha256; - let left = self.left.map(|b| b.finalize(algorithm)); - let right = self.right.map(|b| b.finalize(algorithm)); - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&BigUint::one())), - &encode_nullable(left.as_ref(), |b| encode_byte_string(b.hash().data())), - &encode_nullable(right.as_ref(), |b| encode_byte_string(b.hash().data())), - ]); - let hash = digest(algorithm, &preimage); - SparseMerkleTreeRootNode { left, right, hash } - } - } - - fn build_tree( - branch: Pending, - remaining_path: BigUint, - data: Vec, - ) -> Result { - let (length, common) = calculate_common_path(&remaining_path, branch.path()); - let shifted = &remaining_path >> length_to_u64(&length); - let is_right = shifted.bit(0); - - if common == remaining_path { - return Err(Error::UnexpectedValue( - "SMT leaf is inside an existing branch", - )); - } - - match branch { - Pending::Leaf { - path: bpath, - data: bdata, - } => { - if common == bpath { - return Err(Error::UnexpectedValue("SMT leaf out of bounds")); - } - let old = Pending::Leaf { - path: &bpath >> length_to_u64(&length), - data: bdata, - }; - let new = Pending::Leaf { - path: shifted, - data, - }; - Ok(node(common, is_right, old, new)) - } - Pending::Node { - path: bpath, - left, - right, - } => { - if common < bpath { - let new = Pending::Leaf { - path: shifted, - data, - }; - let old = Pending::Node { - path: &bpath >> length_to_u64(&length), - left, - right, - }; - return Ok(node(common, is_right, old, new)); - } - if is_right { - Ok(Pending::Node { - path: bpath, - left, - right: Box::new(build_tree(*right, shifted, data)?), - }) - } else { - Ok(Pending::Node { - path: bpath, - left: Box::new(build_tree(*left, shifted, data)?), - right, - }) - } - } - } - } - - /// Place `old`/`new` under a fresh node at `path`, ordering by `is_right`. - fn node(path: BigUint, is_right: bool, old: Pending, new: Pending) -> Pending { - let (left, right) = if is_right { (old, new) } else { (new, old) }; - Pending::Node { - path, - left: Box::new(left), - right: Box::new(right), - } - } - - fn length_to_u64(length: &BigUint) -> u64 { - length.try_into().expect("common-path length fits in u64") - } - - /// The finalized root of a plain sparse Merkle tree. - pub struct SparseMerkleTreeRootNode { - left: Option, - right: Option, - hash: DataHash, - } - - impl core::fmt::Debug for SparseMerkleTreeRootNode { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("SparseMerkleTreeRootNode") - .field("hash", &self.hash) - .finish_non_exhaustive() - } - } - - impl SparseMerkleTreeRootNode { - /// The root hash. - pub fn hash(&self) -> &DataHash { - &self.hash - } - - /// Extract the inclusion path for routing `path`. - pub fn get_path(&self, path: &BigUint) -> SparseMerkleTreePath { - let steps = generate_path_root(path, self); - SparseMerkleTreePath::new(self.hash.clone(), steps) - } - } - - fn data_of(branch: Option<&Finalized>) -> Option> { - branch.map(|b| b.hash().data().to_vec()) - } - - fn generate_path_root( - path: &BigUint, - root: &SparseMerkleTreeRootNode, - ) -> Vec { - generate_path_inner( - path, - &BigUint::one(), - root.left.as_ref(), - root.right.as_ref(), - ) - } - - fn generate_path_branch(path: &BigUint, parent: &Finalized) -> Vec { - match parent { - Finalized::Leaf { path: p, data, .. } => { - vec![SparseMerkleTreePathStep::new(p.clone(), Some(data.clone()))] - } - Finalized::Node { - path: p, - left, - right, - .. - } => generate_path_inner(path, p, Some(left), Some(right)), - } - } - - fn generate_path_inner( - path: &BigUint, - parent_path: &BigUint, - left: Option<&Finalized>, - right: Option<&Finalized>, - ) -> Vec { - let one = BigUint::one(); - let (length, common) = calculate_common_path(path, parent_path); - let remaining = path >> length_to_u64(&length); - - if &common != parent_path || remaining == one { - return vec![ - SparseMerkleTreePathStep::new(BigUint::ZERO, data_of(left)), - SparseMerkleTreePathStep::new(parent_path.clone(), data_of(right)), - ]; - } - - let is_right = remaining.bit(0); - let (branch, sibling) = if is_right { - (right, left) - } else { - (left, right) - }; - let step = SparseMerkleTreePathStep::new(parent_path.clone(), data_of(sibling)); - - match branch { - None => { - let bit = if is_right { one } else { BigUint::ZERO }; - vec![SparseMerkleTreePathStep::new(bit, None), step] - } - Some(b) => { - let mut steps = generate_path_branch(&remaining, b); - steps.push(step); - steps - } - } - } -} diff --git a/src/smt/sum.rs b/src/smt/sum.rs deleted file mode 100644 index a1fa5d2..0000000 --- a/src/smt/sum.rs +++ /dev/null @@ -1,670 +0,0 @@ -//! Sparse Merkle *sum* tree: path type + verification (core) and the mutable -//! builder (`client`). Each node carries the sum of its subtree's leaf values, -//! so a single path proves both inclusion and the committed amount. Used as the -//! per-asset tree mapping a new token's id to the amount it receives in a split. - -use alloc::vec::Vec; - -use num_bigint::BigUint; -use num_traits::One; - -use super::bigint::{bit_len, bytes_to_path, path_to_bytes}; -use super::{ - SumPathVerificationResult, MAX_SMT_DATA_BYTES, MAX_SMT_PATH_BYTES, MAX_SMT_PATH_STEPS, -}; -use crate::cbor::{encode_array, encode_byte_string, encode_nullable, Decoder}; -use crate::crypto::hash::{DataHash, DataHasher, HashAlgorithm}; -use crate::error::Error; - -const MAX_SMT_VALUE_BYTES: usize = 32; - -/// A single step along a sum-tree path: routing `path`, sibling/leaf `data`, and -/// the subtree `value` hashed at that level. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SparseMerkleSumTreePathStep { - path: BigUint, - data: Option>, - value: BigUint, -} - -impl SparseMerkleSumTreePathStep { - /// Construct a step from its routing path, optional data, and value. - pub fn new(path: BigUint, data: Option>, value: BigUint) -> Self { - SparseMerkleSumTreePathStep { path, data, value } - } - - /// The routing path of this step. - pub fn path(&self) -> &BigUint { - &self.path - } - - /// The data hashed at this level, if any. - pub fn data(&self) -> Option<&[u8]> { - self.data.as_deref() - } - - /// The subtree value at this level. - pub fn value(&self) -> &BigUint { - &self.value - } - - /// Decode from CBOR: `[bstr(path), nullable bstr(data), bstr(value)]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(3))?; - let path_bytes = items[0].bytes_value()?; - if path_bytes.len() > MAX_SMT_PATH_BYTES { - return Err(Error::OutOfRange("SMT path exceeds protocol limit")); - } - let path = bytes_to_path(path_bytes)?; - let data = - items[1].nullable(|d| d.bytes_value().map(<[u8]>::to_vec).map_err(Into::into))?; - if data - .as_ref() - .is_some_and(|value| value.len() > MAX_SMT_DATA_BYTES) - { - return Err(Error::OutOfRange("SMT data exceeds protocol limit")); - } - let value_bytes = items[2].bytes_value()?; - if value_bytes.len() > MAX_SMT_VALUE_BYTES { - return Err(Error::OutOfRange("SMT value exceeds 256 bits")); - } - let value = bytes_to_path(value_bytes)?; - Ok(SparseMerkleSumTreePathStep { path, data, value }) - } - - /// Encode to CBOR: `[bstr(path), nullable bstr(data), bstr(value)]`. - pub fn to_cbor(&self) -> Vec { - encode_array(&[ - &encode_byte_string(&path_to_bytes(&self.path)), - &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - &encode_byte_string(&path_to_bytes(&self.value)), - ]) - } -} - -/// A leaf-to-root path through a sparse Merkle sum tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SparseMerkleSumTreePath { - root: DataHash, - steps: Vec, -} - -impl SparseMerkleSumTreePath { - /// Construct from a committed root and its steps. - pub fn new(root: DataHash, steps: Vec) -> Self { - SparseMerkleSumTreePath { root, steps } - } - - /// The committed root hash. - pub fn root(&self) -> &DataHash { - &self.root - } - - /// The path steps, leaf first. - pub fn steps(&self) -> &[SparseMerkleSumTreePathStep] { - &self.steps - } - - /// Decode from CBOR: `[bstr(root.imprint), [steps...]]`. - pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(2))?; - let root = DataHash::from_imprint(items[0].bytes_value()?)?; - let encoded_steps = items[1].array(None)?; - if encoded_steps.len() > MAX_SMT_PATH_STEPS { - return Err(Error::OutOfRange("SMT proof has too many steps")); - } - let mut steps = Vec::new(); - for step in encoded_steps { - steps.push(SparseMerkleSumTreePathStep::from_cbor(step)?); - } - Ok(SparseMerkleSumTreePath { root, steps }) - } - - /// Encode to CBOR: `[bstr(root.imprint), [steps...]]`. - pub fn to_cbor(&self) -> Vec { - let step_bytes: Vec> = self.steps.iter().map(|s| s.to_cbor()).collect(); - let step_refs: Vec<&[u8]> = step_bytes.iter().map(Vec::as_slice).collect(); - encode_array(&[ - &encode_byte_string(&self.root.imprint()), - &encode_array(&step_refs), - ]) - } - - /// Verify the path against its committed root and the routing path of the - /// queried key. - pub fn verify(&self, key_path: &BigUint) -> SumPathVerificationResult { - if self.steps.len() > MAX_SMT_PATH_STEPS - || key_path.to_bytes_be().len() > MAX_SMT_PATH_BYTES - || self.steps.iter().any(|step| { - step.path.to_bytes_be().len() > MAX_SMT_PATH_BYTES - || step.value.to_bytes_be().len() > MAX_SMT_VALUE_BYTES - || step - .data - .as_ref() - .is_some_and(|value| value.len() > MAX_SMT_DATA_BYTES) - }) - { - return SumPathVerificationResult::invalid(); - } - let Some(first) = self.steps.first() else { - return SumPathVerificationResult::invalid(); - }; - let algorithm = self.root.algorithm(); - let one = BigUint::one(); - let zero = BigUint::ZERO; - - let mut current_path = first.path.clone(); - let mut current_sum = first.value.clone(); - let mut current_data: Option>; - if first.path > zero { - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&first.path)), - &encode_nullable(first.data.as_ref(), |v| encode_byte_string(v)), - &encode_byte_string(&path_to_bytes(&first.value)), - ]); - current_data = match hash(algorithm, &preimage) { - Some(d) => Some(d), - None => return SumPathVerificationResult::invalid(), - }; - } else { - current_path = one.clone(); - current_data = first.data.clone(); - } - - for i in 1..self.steps.len() { - let step = &self.steps[i]; - let is_right = self.steps[i - 1].path.bit(0); - let (left_data, left_value, right_data, right_value) = if is_right { - ( - step.data.as_deref(), - &step.value, - current_data.as_deref(), - ¤t_sum, - ) - } else { - ( - current_data.as_deref(), - ¤t_sum, - step.data.as_deref(), - &step.value, - ) - }; - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&step.path)), - &encode_nullable(left_data, encode_byte_string), - &encode_byte_string(&path_to_bytes(left_value)), - &encode_nullable(right_data, encode_byte_string), - &encode_byte_string(&path_to_bytes(right_value)), - ]); - current_data = match hash(algorithm, &preimage) { - Some(d) => Some(d), - None => return SumPathVerificationResult::invalid(), - }; - - let bits = bit_len(&step.path); - if bits == 0 { - return SumPathVerificationResult::invalid(); - } - let length = bits - 1; - current_path = (current_path << length) | (&step.path & ((&one << length) - &one)); - current_sum += &step.value; - } - - let path_valid = current_data - .as_deref() - .is_some_and(|d| d == self.root.data()); - let path_included = key_path == ¤t_path; - SumPathVerificationResult::new(path_valid, path_included, current_sum) - } -} - -fn hash(algorithm: HashAlgorithm, preimage: &[u8]) -> Option> { - Some( - DataHasher::new(algorithm) - .ok()? - .update(preimage) - .finalize() - .data() - .to_vec(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cbor::Decoder; - use crate::smt::bigint::key_to_path; - - #[test] - fn build_get_path_verify_roundtrip_and_sum() { - let leaves: [([u8; 32], u32); 4] = [ - (key32(1), 10), - (key32(2), 20), - (key32(0xff), 5), - (key32(0x80), 7), - ]; - let mut tree = SparseMerkleSumTree::new(); - for (k, v) in leaves { - tree.add_leaf(key_to_path(&k), k.to_vec(), BigUint::from(v)) - .unwrap(); - } - let root = tree.calculate_root(); - assert_eq!(*root.value(), BigUint::from(42u32)); - - for (k, v) in leaves { - let path = root.get_path(&key_to_path(&k)); - let result = path.verify(&key_to_path(&k)); - assert!(result.is_successful(), "key {k:?} should verify"); - assert_eq!(result.root_sum(), Some(&BigUint::from(42u32))); - // The leaf step carries the committed amount. - assert_eq!(path.steps().first().unwrap().value(), &BigUint::from(v)); - - let bytes = path.to_cbor(); - let decoded = SparseMerkleSumTreePath::from_cbor(Decoder::new(&bytes)).unwrap(); - assert_eq!(decoded, path); - assert_eq!(decoded.to_cbor(), bytes); - } - } - - fn key32(last: u8) -> [u8; 32] { - let mut k = [0u8; 32]; - k[31] = last; - k - } -} - -#[cfg(any(feature = "client", test))] -pub use build::{SparseMerkleSumTree, SparseMerkleSumTreeRootNode}; - -#[cfg(any(feature = "client", test))] -mod build { - //! Mutable sum SMT builder. - - use alloc::boxed::Box; - use alloc::vec; - use alloc::vec::Vec; - - use num_bigint::BigUint; - use num_traits::One; - - use super::{SparseMerkleSumTreePath, SparseMerkleSumTreePathStep}; - use crate::cbor::{encode_array, encode_byte_string, encode_nullable}; - use crate::crypto::hash::{DataHash, DataHasher, HashAlgorithm}; - use crate::error::Error; - use crate::smt::bigint::{calculate_common_path, path_to_bytes}; - - enum Pending { - Leaf { - path: BigUint, - data: Vec, - value: BigUint, - }, - Node { - path: BigUint, - left: Box, - right: Box, - }, - } - - enum Finalized { - Leaf { - path: BigUint, - data: Vec, - value: BigUint, - hash: DataHash, - }, - Node { - path: BigUint, - left: Box, - right: Box, - value: BigUint, - hash: DataHash, - }, - } - - impl Finalized { - fn hash(&self) -> &DataHash { - match self { - Finalized::Leaf { hash, .. } | Finalized::Node { hash, .. } => hash, - } - } - fn value(&self) -> &BigUint { - match self { - Finalized::Leaf { value, .. } | Finalized::Node { value, .. } => value, - } - } - } - - fn digest(algorithm: HashAlgorithm, preimage: &[u8]) -> DataHash { - DataHasher::new(algorithm) - .expect("split SMT uses SHA-256") - .update(preimage) - .finalize() - } - - impl Pending { - fn path(&self) -> &BigUint { - match self { - Pending::Leaf { path, .. } | Pending::Node { path, .. } => path, - } - } - - fn finalize(self, algorithm: HashAlgorithm) -> Finalized { - match self { - Pending::Leaf { path, data, value } => { - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&path)), - &encode_byte_string(&data), - &encode_byte_string(&path_to_bytes(&value)), - ]); - let hash = digest(algorithm, &preimage); - Finalized::Leaf { - path, - data, - value, - hash, - } - } - Pending::Node { path, left, right } => { - let left = left.finalize(algorithm); - let right = right.finalize(algorithm); - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&path)), - &encode_byte_string(left.hash().data()), - &encode_byte_string(&path_to_bytes(left.value())), - &encode_byte_string(right.hash().data()), - &encode_byte_string(&path_to_bytes(right.value())), - ]); - let hash = digest(algorithm, &preimage); - let value = left.value() + right.value(); - Finalized::Node { - path, - left: Box::new(left), - right: Box::new(right), - value, - hash, - } - } - } - } - } - - /// A mutable sparse Merkle sum tree. - #[derive(Default)] - pub struct SparseMerkleSumTree { - left: Option, - right: Option, - } - - impl core::fmt::Debug for SparseMerkleSumTree { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("SparseMerkleSumTree") - .finish_non_exhaustive() - } - } - - impl SparseMerkleSumTree { - /// Create an empty tree. - pub fn new() -> Self { - Self::default() - } - - /// Insert a leaf at routing `path` (`>= 1`) with `value`. - pub fn add_leaf( - &mut self, - path: BigUint, - data: Vec, - value: BigUint, - ) -> Result<(), Error> { - if path < BigUint::one() { - return Err(Error::OutOfRange("SMT leaf path must be >= 1")); - } - let is_right = path.bit(0); - let slot = if is_right { - &mut self.right - } else { - &mut self.left - }; - let new = match slot.take() { - Some(branch) => build_tree(branch, path, data, value)?, - None => Pending::Leaf { path, data, value }, - }; - *slot = Some(new); - Ok(()) - } - - /// Finalize all hashes and return the root node. - pub fn calculate_root(self) -> SparseMerkleSumTreeRootNode { - let algorithm = HashAlgorithm::Sha256; - let zero = BigUint::ZERO; - let left = self.left.map(|b| b.finalize(algorithm)); - let right = self.right.map(|b| b.finalize(algorithm)); - let left_value = left.as_ref().map_or(zero.clone(), |b| b.value().clone()); - let right_value = right.as_ref().map_or(zero.clone(), |b| b.value().clone()); - let preimage = encode_array(&[ - &encode_byte_string(&path_to_bytes(&BigUint::one())), - &encode_nullable(left.as_ref(), |b| encode_byte_string(b.hash().data())), - &encode_byte_string(&path_to_bytes(&left_value)), - &encode_nullable(right.as_ref(), |b| encode_byte_string(b.hash().data())), - &encode_byte_string(&path_to_bytes(&right_value)), - ]); - let hash = digest(algorithm, &preimage); - let value = left_value + right_value; - SparseMerkleSumTreeRootNode { - left, - right, - value, - hash, - } - } - } - - fn build_tree( - branch: Pending, - remaining_path: BigUint, - data: Vec, - value: BigUint, - ) -> Result { - let (length, common) = calculate_common_path(&remaining_path, branch.path()); - let len = length_to_u64(&length); - let shifted = &remaining_path >> len; - let is_right = shifted.bit(0); - - if common == remaining_path { - return Err(Error::UnexpectedValue( - "SMT leaf is inside an existing branch", - )); - } - - match branch { - Pending::Leaf { - path: bpath, - data: bdata, - value: bvalue, - } => { - if common == bpath { - return Err(Error::UnexpectedValue("SMT leaf out of bounds")); - } - let old = Pending::Leaf { - path: &bpath >> len, - data: bdata, - value: bvalue, - }; - let new = Pending::Leaf { - path: shifted, - data, - value, - }; - Ok(node(common, is_right, old, new)) - } - Pending::Node { - path: bpath, - left, - right, - } => { - if common < bpath { - let new = Pending::Leaf { - path: shifted, - data, - value, - }; - let old = Pending::Node { - path: &bpath >> len, - left, - right, - }; - return Ok(node(common, is_right, old, new)); - } - if is_right { - Ok(Pending::Node { - path: bpath, - left, - right: Box::new(build_tree(*right, shifted, data, value)?), - }) - } else { - Ok(Pending::Node { - path: bpath, - left: Box::new(build_tree(*left, shifted, data, value)?), - right, - }) - } - } - } - } - - fn node(path: BigUint, is_right: bool, old: Pending, new: Pending) -> Pending { - let (left, right) = if is_right { (old, new) } else { (new, old) }; - Pending::Node { - path, - left: Box::new(left), - right: Box::new(right), - } - } - - fn length_to_u64(length: &BigUint) -> u64 { - length.try_into().expect("common-path length fits in u64") - } - - /// The finalized root of a sparse Merkle sum tree. - pub struct SparseMerkleSumTreeRootNode { - left: Option, - right: Option, - value: BigUint, - hash: DataHash, - } - - impl core::fmt::Debug for SparseMerkleSumTreeRootNode { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("SparseMerkleSumTreeRootNode") - .field("value", &self.value) - .field("hash", &self.hash) - .finish_non_exhaustive() - } - } - - impl SparseMerkleSumTreeRootNode { - /// The root hash. - pub fn hash(&self) -> &DataHash { - &self.hash - } - - /// The total of all leaf values in the tree. - pub fn value(&self) -> &BigUint { - &self.value - } - - /// Extract the inclusion path for routing `path`. - pub fn get_path(&self, path: &BigUint) -> SparseMerkleSumTreePath { - let steps = generate_path_inner( - path, - &BigUint::one(), - self.left.as_ref(), - self.right.as_ref(), - ); - SparseMerkleSumTreePath::new(self.hash.clone(), steps) - } - } - - fn data_of(branch: Option<&Finalized>) -> Option> { - branch.map(|b| b.hash().data().to_vec()) - } - - fn value_of(branch: Option<&Finalized>) -> BigUint { - branch.map_or(BigUint::ZERO, |b| b.value().clone()) - } - - fn generate_path_branch( - path: &BigUint, - parent: &Finalized, - ) -> Vec { - match parent { - Finalized::Leaf { - path: p, - data, - value, - .. - } => { - vec![SparseMerkleSumTreePathStep::new( - p.clone(), - Some(data.clone()), - value.clone(), - )] - } - Finalized::Node { - path: p, - left, - right, - .. - } => generate_path_inner(path, p, Some(left), Some(right)), - } - } - - fn generate_path_inner( - path: &BigUint, - parent_path: &BigUint, - left: Option<&Finalized>, - right: Option<&Finalized>, - ) -> Vec { - let one = BigUint::one(); - let (length, common) = calculate_common_path(path, parent_path); - let remaining = path >> length_to_u64(&length); - - if &common != parent_path || remaining == one { - return vec![ - SparseMerkleSumTreePathStep::new(BigUint::ZERO, data_of(left), value_of(left)), - SparseMerkleSumTreePathStep::new( - parent_path.clone(), - data_of(right), - value_of(right), - ), - ]; - } - - let is_right = remaining.bit(0); - let (branch, sibling) = if is_right { - (right, left) - } else { - (left, right) - }; - let step = SparseMerkleSumTreePathStep::new( - parent_path.clone(), - data_of(sibling), - value_of(sibling), - ); - - match branch { - None => { - let bit = if is_right { one } else { BigUint::ZERO }; - vec![ - SparseMerkleSumTreePathStep::new(bit, None, BigUint::ZERO), - step, - ] - } - Some(b) => { - let mut steps = generate_path_branch(&remaining, b); - steps.push(step); - steps - } - } - } -} diff --git a/src/verify/error.rs b/src/verify/error.rs index 8a2fd01..a2d8dbe 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -36,32 +36,28 @@ pub enum VerificationError { SplitNetworkMismatch, /// The burned source token in a split justification failed verification. BurnTokenVerificationFailed(Box), - /// The payment asset count does not match the number of split proofs. - SplitAssetCountMismatch, + /// The output token type is not byte-identical to the source token type. + SplitTokenTypeMismatch, + /// The burned source token did not end in a (burn) transfer. + SplitBurnTransferMissing, + /// The burn transfer carried no auxiliary split-manifest data. + SplitManifestMissing, + /// The split manifest failed to decode (wrong tag, length, or structure). + SplitManifestMalformed, + /// The manifest root count does not equal the source asset count. + SplitManifestLengthMismatch, + /// The proof count does not equal the number of output assets. + SplitProofCountMismatch, + /// An RSMST allocation proof did not verify against its manifest root. + SplitAllocationProofInvalid, /// The burned source token carried no decodable payment data. SplitSourcePaymentDataMissing, /// An output asset did not exist in the burned source token. SplitSourceAssetMissing, - /// The split sum-tree total did not equal the burned source amount. + /// A proof's reconstructed root sum did not equal the burned source amount. SplitSourceAmountMismatch, - /// Two split proofs claim the same asset id. - DuplicateSplitProof, - /// A split proof's aggregation-tree path did not verify for its asset id. - SplitAggregationPathInvalid, - /// A split proof's asset-tree path did not verify for the minted token id. - SplitAssetTreePathInvalid, - /// Split proofs are not all derived from the same aggregation tree. - SplitProofRootMismatch, - /// A proof's asset-tree root does not match its aggregation-path leaf. - SplitAssetTreeRootMismatch, - /// A proof's asset id is absent from the genesis payment data. - SplitAssetNotInPayment, - /// A proof's certified amount does not match the payment data amount. - SplitAssetAmountMismatch, - /// The burned token was not locked to the split's aggregation-root burn predicate. + /// The burned token was not locked to the split manifest's burn predicate. SplitBurnPredicateMismatch, - /// Fewer validated proofs than payment assets — some proofs are missing. - SplitProofsIncomplete, /// The inclusion proof had no inclusion certificate. InclusionCertificateMissing, /// The inclusion proof had no certification data. @@ -127,48 +123,36 @@ impl fmt::Display for VerificationError { VerificationError::BurnTokenVerificationFailed(e) => { write!(f, "burned source token verification failed: {e}") } - VerificationError::SplitAssetCountMismatch => { - write!(f, "asset count does not match split proof count") + VerificationError::SplitTokenTypeMismatch => { + write!(f, "output token type does not match source token type") } - VerificationError::SplitSourcePaymentDataMissing => { - write!(f, "burned source token has no valid payment data") - } - VerificationError::SplitSourceAssetMissing => { - write!(f, "split asset is absent from burned source token") + VerificationError::SplitBurnTransferMissing => { + write!(f, "burned source token has no burn transfer") } - VerificationError::SplitSourceAmountMismatch => { - write!( - f, - "split sum-tree total does not match burned source amount" - ) + VerificationError::SplitManifestMissing => { + write!(f, "burn transfer carries no split manifest") } - VerificationError::DuplicateSplitProof => write!(f, "duplicate split proof for asset"), - VerificationError::SplitAggregationPathInvalid => { - write!(f, "split aggregation path invalid") + VerificationError::SplitManifestMalformed => write!(f, "split manifest malformed"), + VerificationError::SplitManifestLengthMismatch => { + write!(f, "manifest root count does not match source asset count") } - VerificationError::SplitAssetTreePathInvalid => { - write!(f, "split asset tree path invalid") + VerificationError::SplitProofCountMismatch => { + write!(f, "proof count does not match output asset count") } - VerificationError::SplitProofRootMismatch => { - write!(f, "split proofs derive from different aggregation trees") + VerificationError::SplitAllocationProofInvalid => { + write!(f, "RSMST allocation proof did not verify") } - VerificationError::SplitAssetTreeRootMismatch => { - write!(f, "asset tree root does not match aggregation path leaf") + VerificationError::SplitSourcePaymentDataMissing => { + write!(f, "burned source token has no valid payment data") } - VerificationError::SplitAssetNotInPayment => { - write!(f, "split proof asset id not present in payment data") + VerificationError::SplitSourceAssetMissing => { + write!(f, "split asset is absent from burned source token") } - VerificationError::SplitAssetAmountMismatch => { - write!(f, "split proof amount does not match payment data") + VerificationError::SplitSourceAmountMismatch => { + write!(f, "reconstructed root sum does not match burned source amount") } VerificationError::SplitBurnPredicateMismatch => { - write!( - f, - "burned token not locked to split aggregation-root burn predicate" - ) - } - VerificationError::SplitProofsIncomplete => { - write!(f, "some split proofs are missing from the token") + write!(f, "burned token not locked to split manifest burn predicate") } VerificationError::InclusionCertificateMissing => { write!(f, "inclusion certificate missing") From 7340eec93c972ade65464910b0e1bf2293e11412 Mon Sep 17 00:00:00 2001 From: ristik Date: Thu, 25 Jun 2026 15:31:39 +0300 Subject: [PATCH 2/3] sync bit order, check before burn --- README.md | 13 ++--- examples/split.rs | 4 ++ src/api/inclusion_certificate.rs | 11 ++--- src/lib.rs | 1 + src/payment/mod.rs | 2 +- src/payment/split.rs | 83 +++++++++++++++++++++++++++++--- src/payment/tests.rs | 18 ++++++- src/radix.rs | 15 ++++++ src/rsmst/build.rs | 14 +++--- src/rsmst/mod.rs | 27 ++++++----- 10 files changed, 147 insertions(+), 41 deletions(-) create mode 100644 src/radix.rs diff --git a/README.md b/README.md index 8d18a93..9cd8dfd 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,11 @@ let assets = verify_payment_token( ``` Constructing a split (mint → split → mint outputs) is a `client`-feature -operation via `payment::TokenSplit::split`. The verifier independently re-checks -value conservation — every output's RSMST proof must reconstruct a root sum -equal to the authenticated source amount — so client-side construction is -untrusted. The split proofs ride on the radix sparse Merkle sum trees in the -`rsmst` module; their decoding is bounded by the per-proof limits and the -cumulative `VerificationPolicy` / `VerificationLimits` (embedded-token depth and -byte budgets) to stay safe on attacker-controlled input. See -`src/payment/tests.rs` for an end-to-end example. +operation via `payment::TokenSplit::split`, which **verifies the source token +fully before constructing the irreversible burn** (history, embedded split +chain, and the registered issuance policy); if verification fails no burn is +produced. `split_unchecked` skips that when the source is already +known good. See `src/payment/tests.rs` for an end-to-end example. ## Feature Flags diff --git a/examples/split.rs b/examples/split.rs index 390baf2..82de66f 100644 --- a/examples/split.rs +++ b/examples/split.rs @@ -204,8 +204,12 @@ fn main() { ), ]; + // `split` verifies the source token (history, embedded chain, issuance + // policy) before constructing the irreversible burn. let split = TokenSplit::split( &source, + &trust_base, + ®istry, PaymentAssetCollection::from_cbor_bytes, requests, Some(BURN_STATE_MASK), diff --git a/src/api/inclusion_certificate.rs b/src/api/inclusion_certificate.rs index b50a3b5..d1aea72 100644 --- a/src/api/inclusion_certificate.rs +++ b/src/api/inclusion_certificate.rs @@ -16,6 +16,8 @@ const BITMAP_SIZE: usize = 32; const HASH_SIZE: usize = 32; const MAX_DEPTH: usize = 255; +use crate::radix::bit_at; + /// A sparse-Merkle-tree inclusion path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InclusionCertificate { @@ -23,11 +25,6 @@ pub struct InclusionCertificate { siblings: Vec<[u8; HASH_SIZE]>, } -/// LSB-first bit at `depth` of `data`. -fn bit_at(data: &[u8], depth: usize) -> u8 { - (data[depth / 8] >> (depth % 8)) & 1 -} - impl InclusionCertificate { /// Decode from the raw byte form, validating bitmap/sibling alignment and /// that the sibling count matches the bitmap population count. @@ -89,7 +86,7 @@ impl InclusionCertificate { let mut position = self.siblings.len(); for depth in (0..=MAX_DEPTH).rev() { - if bit_at(&self.bitmap, depth) == 0 { + if !bit_at(&self.bitmap, depth) { continue; } if position == 0 { @@ -101,7 +98,7 @@ impl InclusionCertificate { let h = DataHasher::new(HashAlgorithm::Sha256) .expect("sha256") .update(&[0x01, depth as u8]); - hash = if bit_at(key, depth) == 1 { + hash = if bit_at(key, depth) { h.update(sibling).update(hash.data()) } else { h.update(hash.data()).update(sibling) diff --git a/src/lib.rs b/src/lib.rs index 67cce5d..efac209 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ pub mod crypto; pub mod error; pub mod payment; pub mod predicate; +mod radix; pub mod rsmst; pub mod transaction; pub mod verify; diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 9028b54..f5660d2 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -37,4 +37,4 @@ pub use verifier::{ pub use crate::rsmst::RsmstInclusionProof; #[cfg(any(feature = "client", test))] -pub use split::{Split, SplitBurn, SplitToken, SplitTokenRequest, TokenSplit}; +pub use split::{Split, SplitBurn, SplitError, SplitToken, SplitTokenRequest, TokenSplit}; diff --git a/src/payment/split.rs b/src/payment/split.rs index 64e602c..36f0678 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -12,10 +12,13 @@ //! [`SplitMintJustificationVerifier`]: super::verifier::SplitMintJustificationVerifier use alloc::vec::Vec; +use core::fmt; use super::asset::PaymentAssetCollection; use super::commitment::split_output_commitment; use super::manifest::SplitManifest; +use super::verifier::{verify_payment_token, PaymentDataDecoder}; +use crate::api::bft::RootTrustBase; use crate::api::network_id::NetworkId; use crate::error::Error; use crate::predicate::builtin::BurnPredicate; @@ -24,6 +27,8 @@ use crate::rsmst::build::Rsmst; use crate::rsmst::RsmstInclusionProof; use crate::transaction::ids::{TokenId, TokenSalt, TokenType}; use crate::transaction::{Token, TransferTransaction}; +use crate::verify::MintJustificationRegistry; +use crate::VerificationError; /// A request to mint one new token as part of a split. #[derive(Debug, Clone)] @@ -90,33 +95,99 @@ pub struct Split { pub tokens: Vec, } +/// Why a split could not be produced (see [`TokenSplit::split`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SplitError { + /// The source token failed verification, so no burn was constructed. The + /// caller's value is untouched. + Verification(VerificationError), + /// The source verified, but the requested split could not be built (e.g. a + /// per-asset total does not equal the source amount, or a duplicate output + /// id). No burn was constructed. + Build(Error), +} + +impl fmt::Display for SplitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SplitError::Verification(e) => write!(f, "source token verification failed: {e}"), + SplitError::Build(e) => write!(f, "split construction failed: {e}"), + } + } +} + +impl core::error::Error for SplitError {} + /// Token splitting. #[derive(Debug)] pub struct TokenSplit; impl TokenSplit { - /// Split `token` into the outputs described by `requests`. + /// Split `token` into the outputs described by `requests`, after fully + /// verifying the source token. + /// + /// The source token is verified with [`verify_payment_token`] — its whole + /// cryptographic history, any embedded split chain, and the registered + /// issuance policy for its token type — **before** the irreversible burn + /// transfer is constructed. If verification fails, no burn is produced and + /// the caller's value is untouched. `registry` must therefore hold the + /// payment-data verifier for the source token type (and the split verifier if + /// the source is itself a split output). /// /// `decode_payment_data` extracts the source token's [`PaymentAssetCollection`] /// from its mint `data`. `burn_state_mask` sets the burn transfer's state /// mask; pass `None` for a random mask (requires the `std` RNG) or a fixed /// value for a reproducible, crash-resumable burn. + /// + /// To split a token whose validity you have already established by other + /// means, use [`TokenSplit::split_unchecked`]. pub fn split( token: &Token, - decode_payment_data: fn(&[u8]) -> Result, + trust_base: &RootTrustBase, + registry: &MintJustificationRegistry, + decode_payment_data: PaymentDataDecoder, requests: Vec, burn_state_mask: Option<[u8; 32]>, - ) -> Result { - let network_id = token.genesis().transaction().network_id(); - let source_token_type = token.token_type().clone(); + ) -> Result { + let assets = verify_payment_token(token, trust_base, registry, decode_payment_data) + .map_err(SplitError::Verification)?; + Self::build_split(token, assets, requests, burn_state_mask).map_err(SplitError::Build) + } - // The source token's declared canonical asset collection (A). + /// Split `token` **without verifying it first**. + /// + /// This constructs an irreversible burn transfer from an unverified source + /// token. Only use it when the source token's validity (and ownership of its + /// current state) is already established; otherwise prefer + /// [`TokenSplit::split`], which verifies before burning. The split outputs + /// are still independently checked by the verifier when later minted, but an + /// invalid source can only be discovered *after* value has been burned. + pub fn split_unchecked( + token: &Token, + decode_payment_data: PaymentDataDecoder, + requests: Vec, + burn_state_mask: Option<[u8; 32]>, + ) -> Result { let source_bytes = token .genesis() .transaction() .data() .ok_or(Error::UnexpectedValue("source token has no payment data"))?; let assets = decode_payment_data(source_bytes)?; + Self::build_split(token, assets, requests, burn_state_mask) + } + + /// Construct the split from the source token's already-decoded canonical + /// asset collection. Shared by [`split`](Self::split) and + /// [`split_unchecked`](Self::split_unchecked). + fn build_split( + token: &Token, + assets: PaymentAssetCollection, + requests: Vec, + burn_state_mask: Option<[u8; 32]>, + ) -> Result { + let network_id = token.genesis().transaction().network_id(); + let source_token_type = token.token_type().clone(); // Validate each request and derive its token id and output commitment. let mut entries: Vec<(SplitTokenRequest, TokenId, [u8; 32])> = Vec::new(); diff --git a/src/payment/tests.rs b/src/payment/tests.rs index e52d91f..79e1ac0 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -357,6 +357,8 @@ fn split_outputs_verify_end_to_end() { let split = TokenSplit::split( &s.source, + &s.tb, + ®istry, PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -441,6 +443,8 @@ fn recursive_split_verification_honors_shared_depth_limit() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -502,6 +506,8 @@ fn rejects_tampered_output_amount() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -538,6 +544,8 @@ fn rejects_dropped_proof() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -570,6 +578,8 @@ fn rejects_wrong_burn_predicate() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -628,6 +638,8 @@ fn rejects_missing_manifest() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -665,6 +677,8 @@ fn rejects_manifest_length_mismatch() { let s = scenario(); let split = TokenSplit::split( &s.source, + &s.tb, + ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), @@ -714,7 +728,7 @@ fn rejects_wrong_output_token_type() { TokenType::new(vec![0xC9; 32]), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split( + assert!(TokenSplit::split_unchecked( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, @@ -738,7 +752,7 @@ fn rejects_unbalanced_split_at_build_time() { coin_type(), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split( + assert!(TokenSplit::split_unchecked( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, diff --git a/src/radix.rs b/src/radix.rs new file mode 100644 index 0000000..a8e2078 --- /dev/null +++ b/src/radix.rs @@ -0,0 +1,15 @@ +//! Shared bit addressing for the protocol's radix sparse Merkle trees. +//! +//! Both the transaction inclusion certificate +//! ([`InclusionCertificate`](crate::api::InclusionCertificate)) and the +//! token-split sum tree ([`rsmst`](crate::rsmst)) address keys (and the +//! certificate bitmap) in the same order, so they share this single helper — +//! there is exactly one key/depth convention in the SDK. +//! +//! Bit `i` is bit `i % 8` of byte `i / 8`: least significant bit first within +//! each byte, byte 0 first. + +/// LSB-first bit `index` of a byte string: bit `index % 8` of byte `index / 8`. +pub(crate) fn bit_at(data: &[u8], index: usize) -> bool { + (data[index / 8] >> (index % 8)) & 1 == 1 +} diff --git a/src/rsmst/build.rs b/src/rsmst/build.rs index 4ce845a..7fe3f36 100644 --- a/src/rsmst/build.rs +++ b/src/rsmst/build.rs @@ -284,9 +284,11 @@ mod tests { /// Golden cross-implementation vector. The digests below were computed /// independently from the yellowpaper hash definitions /// (`SHA-256(0x10 || k || d || u256(v))` for leaves, - /// `SHA-256(0x11 || depth || hL || u256(vL) || hR || u256(vR))` for nodes). - /// Two keys `...01` and `...00` differ at bit 0, so the root bifurcates at - /// depth 0 with `...00` (bit 0 = 0) on the left. + /// `SHA-256(0x11 || depth || hL || u256(vL) || hR || u256(vR))` for nodes) + /// using the SDK's shared bit order ([`crate::radix::bit_at`]). The keys + /// `0x00..01` (byte 31 = 1) and `0x00..00` first differ at bit 248 (the low + /// bit of byte 31), so the root bifurcates at depth 248 with the all-zero key + /// (bit 248 = 0) on the left. #[test] fn golden_two_leaf_root() { use hex_literal::hex; @@ -310,14 +312,14 @@ mod tests { let built = tree.build().unwrap(); assert_eq!( built.root_hash(), - hex!("5aa8ce35ddea4792808b631b70b95e1406bf1a137f5c512f4adf1e36c1999c51") + hex!("8edbd116cc67ca16dfd3b7852a11bdbc7048d446c5ce7f112d6476c28c6b5a96") ); assert_eq!(*built.root_sum(), BigUint::from(30u32)); - // The single-step proof for key_a folds in sibling key_b at depth 0. + // The single-step proof for key_a folds in sibling key_b at depth 248. let proof = built.proof(&key_a).unwrap(); assert_eq!(proof.steps().len(), 1); - assert_eq!(proof.steps()[0].depth(), 0); + assert_eq!(proof.steps()[0].depth(), 248); assert_eq!( proof.verify(&key_a, &data_a, &BigUint::from(10u32), &built.root_hash()), Some(BigUint::from(30u32)) diff --git a/src/rsmst/mod.rs b/src/rsmst/mod.rs index e54a62e..83a11f9 100644 --- a/src/rsmst/mod.rs +++ b/src/rsmst/mod.rs @@ -11,9 +11,10 @@ //! //! The structure deliberately mirrors the plain radix SMT used for transaction //! inclusion ([`InclusionCertificate`](crate::api::InclusionCertificate)) — same -//! key space, same LSB-first bit order, same canonical bifurcation depths — but -//! is domain-separated from it (`0x10`/`0x11` vs `0x00`/`0x01`) and additionally -//! commits the per-subtree sum next to every child hash. +//! key space, same bit order (the single shared [`crate::radix::bit_at`] +//! convention), same canonical bifurcation depths — but is domain-separated from +//! it (`0x10`/`0x11` vs `0x00`/`0x01`) and additionally commits the per-subtree +//! sum next to every child hash. //! //! Hash inputs are fixed binary concatenations, **not** CBOR tuples: //! @@ -148,11 +149,12 @@ pub fn node_hash( Some((hash, sum)) } -/// Bit `index` of a 256-bit key in LSB-first order: bit 0 is the least -/// significant bit of the whole key (the low bit of the last byte). +/// Bit `index` of a 256-bit key, using the SDK's one shared radix bit order +/// ([`crate::radix::bit_at`]): bit `index % 8` of byte `index / 8`. The builder +/// and the verifier go through this same helper, so they share exactly one +/// key/depth convention with the transaction inclusion certificate. fn key_bit(key: &[u8; 32], index: u8) -> bool { - let byte = 31 - (index / 8) as usize; - (key[byte] >> (index % 8)) & 1 == 1 + crate::radix::bit_at(key, index as usize) } #[cfg(test)] @@ -160,17 +162,20 @@ mod tests { use super::*; #[test] - fn key_bit_is_lsb_first() { + fn key_bit_matches_shared_radix_convention() { + // Bit i = bit (i%8) of byte (i/8), byte 0 first (the shared RSMT order). let mut key = [0u8; 32]; - key[31] = 0b0000_0101; // bits 0 and 2 set - key[30] = 0b0000_0010; // bit 9 set - key[0] = 0b1000_0000; // bit 255 set + key[0] = 0b0000_0101; // bits 0 and 2 set + key[1] = 0b0000_0010; // bit 9 set + key[31] = 0b1000_0000; // bit 255 set assert!(key_bit(&key, 0)); assert!(!key_bit(&key, 1)); assert!(key_bit(&key, 2)); assert!(key_bit(&key, 9)); assert!(key_bit(&key, 255)); assert!(!key_bit(&key, 254)); + // Agrees with the helper the inclusion certificate uses. + assert_eq!(key_bit(&key, 9), crate::radix::bit_at(&key, 9)); } #[test] From 0302d10399dc7a6332182e187a27b7db091b3258 Mon Sep 17 00:00:00 2001 From: ristik Date: Thu, 25 Jun 2026 15:57:53 +0300 Subject: [PATCH 3/3] readme --- README.md | 180 ++++++++++++++++++++++++++---------------------------- 1 file changed, 87 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 9cd8dfd..7ad1e90 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,138 @@ -# Unicity State Transition SDK - Rust +# Unicity State Transition SDK -- Rust A Rust SDK for the Unicity token state-transition protocol. -The crate is ready to **cryptographically verify token histories inside a -zkVM guest** (SP1 / RISC0) or `wasm32`, so the core is `no_std`, allocation-light, -and free of C dependencies (RustCrypto `sha2` + `k256`). +The crate is `no_std`-first: the verification core has no C dependencies +(RustCrypto `sha2` + `k256`), is allocation-light, and runs inside a **zkVM +guest** (SP1 / RISC0) or on `wasm32`. The default build adds the necessary +client for minting and transferring tokens. -Token creation expects `client` and `http` features. Async `AggregatorClient` -has to be provided by the user. +```toml +[dependencies] +unicity-token = "0.1" +``` + +## Security model + +Decoding a token proves its structural integrity only. Trust is established only by +`Token::verify`, which walks an unbroken chain of cryptographic checks from a +caller-supplied root of trust down to every state in the token's history. -## Usage +`Token::verify` treats application `data` and any mint *justification* as +**opaque** and rejects a token that carries either — unless you explicitly +register a verifier for it. There is no implicit trust in the API. + +The root of trust is the Unicity Trust Base json. An authentic trust base must +be bundled with the application or left user-configurable. + +## Verify a token (`no_std` core, no features needed) ```rust use unicity_token::Token; use unicity_token::api::bft::RootTrustBase; -// The root of trust (validator set + quorum), supplied out-of-band. -let trust_base = RootTrustBase::from_json(trust_base_json)?; // host; or ::new(..) in no_std +// Root of trust (validator set + quorum), supplied out-of-band. +let trust_base = RootTrustBase::from_json(trust_base_json)?; // ::new(..) in no_std + +let token = Token::from_cbor(&token_bytes)?; // decoding confers NO trust +token.verify(&trust_base)?; // verifies the cryptographic history +``` + +## Mint & transfer against a live aggregator (`http` feature) + +```rust +use std::time::Duration; +use unicity_token::api::bft::RootTrustBase; +use unicity_token::client::{self, HttpAggregatorClient}; + +let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.json")?)?; +let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.network/") + .with_api_key("sk_…") + .with_polling(Duration::from_secs(2), 90); -let token = Token::from_cbor(&token_bytes)?; // decoding confers NO trust -token.verify(&trust_base)?; // verifies cryptographic history +let token = client::mint(&aggregator, &trust_base, trust_base.network_id, + &recipient, token_type, salt, /* data */ None, /* justification */ None)?; ``` -`Token::verify` establishes the cryptographic chain of custody but deliberately -treats application `data` (and any mint *justification*) as opaque — a mint that -carries either is rejected unless you opt in to a verifier for it. +The SDK is generic over the `AggregatorClient` trait, so you can plug in any +transport (or an in-memory one for tests); `HttpAggregatorClient` is the +batteries-included blocking JSON-RPC implementation. -## Payment, assets & token split +## Payment tokens & splits -A token can carry a fungible **payment payload** — a `PaymentAssetCollection`, a -non-empty canonical set of (asset id → amount) entries — in its mint `data`, and -a token can be **split** into several new tokens whose per-asset allocations sum -to the original. The split builds one **radix sparse Merkle sum tree** (RSMST) -per asset, keyed by output token id; the per-asset root hashes form a -`SplitManifest` (CBOR tag 39046) that the source token is burned to (the burn -predicate's reason is `SHA-256` of the manifest, stored as the burn transfer's -auxiliary data). Each output's genesis carries a `SplitMintJustification` -(CBOR tag 39044) with one RSMST allocation proof per asset, proving its share. +A token can carry a fungible **payment payload** (a canonical set of asset id → +amount entries) in its mint `data`, and can be **split** into new tokens whose +per-asset allocations sum to the original. Splitting burns the source and proves +each output's share with a radix sparse Merkle sum tree (RSMST) inclusion proof. -Verifying payment tokens is **fail-closed and policy-gated**: cryptographic -certification alone never authorizes an asset issuer, so you must register the -verifiers you trust and call `payment::verify_payment_token` (not bare -`Token::verify`): +Payment verification is **fail-closed and policy-gated** — cryptographic +validity never authorizes an asset issuer on its own. You register the verifiers +and issuance policy you trust, then call `payment::verify_payment_token` instead +of bare `Token::verify`: ```rust use unicity_token::payment::{ verify_payment_token, PaymentAssetCollection, PaymentDataVerifier, SplitMintJustificationVerifier, }; -use unicity_token::verify::{MintJustificationRegistry, VerificationError}; -use unicity_token::transaction::CertifiedMintTransaction; - -// Your application's issuance policy: is this genesis allowed to mint this -// payload? (Cryptographic validity is necessary but not sufficient.) -fn authorize(genesis: &CertifiedMintTransaction, assets: &PaymentAssetCollection) - -> Result<(), VerificationError> { /* check issuer key, supply, … */ Ok(()) } +use unicity_token::verify::MintJustificationRegistry; +// `authorize` is your closure: given the genesis + decoded assets, decide +// whether this issuance is allowed (issuer key, supply caps, …). let mut registry = MintJustificationRegistry::new(); registry - // accept tokens minted as outputs of a split: - .register(Box::new(SplitMintJustificationVerifier::new()))? - // validate the fungible payload for your token type + run your policy: - .register_token_data(Box::new(PaymentDataVerifier::new(my_token_type, authorize)))?; + .register(Box::new(SplitMintJustificationVerifier::new()))? // accept split outputs + .register_token_data(Box::new(PaymentDataVerifier::new( // validate the payload… + my_token_type, authorize)))?; // …then run *your* policy -// Returns the validated assets; errors fail-closed if anything is off. let assets = verify_payment_token( &token, &trust_base, ®istry, PaymentAssetCollection::from_cbor_bytes, -)?; +)?; // returns the validated assets; fails closed if anything is off ``` -Constructing a split (mint → split → mint outputs) is a `client`-feature -operation via `payment::TokenSplit::split`, which **verifies the source token -fully before constructing the irreversible burn** (history, embedded split -chain, and the registered issuance policy); if verification fails no burn is -produced. `split_unchecked` skips that when the source is already -known good. See `src/payment/tests.rs` for an end-to-end example. - -## Feature Flags +Constructing a split is a `client`-feature operation +(`payment::TokenSplit::split`) that verifies the source token fully before +building the irreversible burn. See `src/payment/tests.rs` for the end-to-end +split → verify flow. -- `std` *(default)* — std error integration, host RNG, JSON trust-base parsing. -- `client` *(default)* — transaction construction, signing, the mint/transfer - flow over a caller-supplied [`AggregatorClient`] transport, and token-split - construction (`payment::TokenSplit`). Payment/asset *verification* is in the - `no_std` core and needs no feature. -- `http` — a blocking JSON-RPC [`HttpAggregatorClient`] for talking to a live - aggregator/gateway (pulls in a TLS HTTP stack; host only). -- `alloc` — required by the core; enabled transitively. +## Feature flags -### Connecting to a live aggregator +| Flag | Default | Purpose | +|------|:-------:|---------| +| `alloc` | (transitive) | Required by the core (`Vec`/`BTreeMap` for CBOR + structures). | +| `std` | y | std error integration, host RNG, JSON trust-base parsing. | +| `client` | y | Transaction construction, signing, mint/transfer flow, split construction. | +| `http` | | Blocking JSON-RPC `HttpAggregatorClient` (host only; pulls in a TLS stack). | -```rust -use std::time::Duration; -use unicity_token::api::bft::RootTrustBase; -use unicity_token::client::{self, HttpAggregatorClient}; - -let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.json")?)?; -let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.network/") - .with_api_key("sk_…") - .with_polling(Duration::from_secs(2), 90); - -let token = client::mint(&aggregator, &trust_base, trust_base.network_id, - &recipient, token_type, salt, /* data */ None, /* justification */ None)?; -``` +Payment/asset **verification** is provided by `no_std` core and needs no feature. +The zkVM/WASM guest build is `--no-default-features --features alloc`. -A live end-to-end test is in `tests/e2e.rs` (reads `e2e/`): +## Building & testing ```sh -cargo test --features http --test e2e -- --ignored --nocapture +cargo test # full suite (host) +cargo test --no-default-features --features alloc # verification core only +cargo build --no-default-features --features alloc --target wasm32-unknown-unknown ``` -Independent demo application is under `./e2e/`. - -The zkVM/WASM guest build is `--no-default-features --features alloc`: pure -`no_std` decode + verify. - -## Building +Live end-to-end test against an aggregator (config in `e2e/`): ```sh -cargo test # full suite (host) -cargo test --no-default-features --features alloc # verification core -cargo build --no-default-features --features alloc --target wasm32-unknown-unknown +cargo test --features http --test e2e -- --ignored --nocapture ``` ## Examples -Runnable example flows (see [`examples/`](./examples)): +Runnable flows in [`examples/`](./examples), talking to a live aggregator via +`HttpAggregatorClient` (config from `e2e/.env`): ```sh -cargo run --example mint --features http # mint a token, print its CBOR -cargo run --example transfer --features http # mint then transfer, print the CBOR -cargo run --example split --features http # mint a coin, split it, verify outputs +cargo run --example mint --features http # mint a token, print its CBOR +cargo run --example transfer --features http # mint then transfer +cargo run --example split --features http # mint a coin, split it, verify outputs ``` -The `mint`/`transfer`/`split` examples talk to a live aggregator via -[`HttpAggregatorClient`] (config from `e2e/.env`). For a transport-agnostic -in-memory flow, the SDK is generic over the `AggregatorClient` trait. The -[Payment, assets & token split](#payment-assets--token-split) section and -`src/payment/tests.rs` cover the split → verify flow end-to-end. - -There is a self-contained application under `e2e/`. +A self-contained demo application is provided under [`e2e/`](./e2e). ## Regenerating cross-SDK fixtures