From 55e8cb6b3b5597ab0f0b4e4546bcf6f9870adc9d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 11:36:31 +0300 Subject: [PATCH 01/12] Commit the round reference time in the SMT leaf value The leaf value the Unicity Service records becomes H(txhash, tau) instead of txhash alone, where tau is the reference time of the round the request was validated in. Certified transactions carry tau and verification uses the carried value. The cross-SDK fixture is regenerated from the TypeScript SDK at the matching commit. Wire changes are not backward compatible: InclusionProof [version, certData, tau, cert, uc] certified transaction [transaction, tau, inclusionProof] Refs #16 --- src/api/inclusion_proof.rs | 23 +++++- src/api/leaf_value.rs | 54 +++++++++++++ src/api/mod.rs | 4 +- src/client/mod.rs | 16 +++- src/payment/tests.rs | 23 +++++- src/transaction/certified.rs | 63 ++++++++++++---- src/verify/mod.rs | 117 ++++++++++++++++++++--------- tests/transition_flow.rs | 1 + tests/vectors/transition_flow.json | 10 +-- 9 files changed, 246 insertions(+), 65 deletions(-) create mode 100644 src/api/leaf_value.rs diff --git a/src/api/inclusion_proof.rs b/src/api/inclusion_proof.rs index 56580c9..a9552a8 100644 --- a/src/api/inclusion_proof.rs +++ b/src/api/inclusion_proof.rs @@ -23,6 +23,13 @@ const VERSION: u64 = 1; pub struct InclusionProof { /// What was certified (present for an inclusion proof). pub certification_data: Option, + /// Reference time of the round the certified leaf was created in (present + /// for an inclusion proof). + /// + /// It cannot be recovered from the certificate chain: an aggregator serves + /// proofs against the current certified root, whose input record time is + /// that of the latest round rather than the one the leaf was created under. + pub reference_time: Option, /// The SMT path (present for an inclusion proof). pub inclusion_certificate: Option, /// The BFT unicity certificate. @@ -33,17 +40,19 @@ impl InclusionProof { /// Decode from CBOR (tagged). pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(INCLUSION_PROOF_TAG)?; - let items = inner.array(Some(4))?; + let items = inner.array(Some(5))?; if items[0].uint()? != VERSION { return Err(Error::UnexpectedValue("unsupported InclusionProof version")); } let certification_data = items[1].nullable(CertificationData::from_cbor)?; + let reference_time = items[2].nullable(|x| x.uint().map_err(Into::into))?; let inclusion_certificate = - items[2].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; + items[3].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; Ok(InclusionProof { certification_data, + reference_time, inclusion_certificate, - unicity_certificate: UnicityCertificate::from_cbor(items[3])?, + unicity_certificate: UnicityCertificate::from_cbor(items[4])?, }) } @@ -54,6 +63,7 @@ impl InclusionProof { &encode_array(&[ &encode_uint(VERSION), &encode_nullable(self.certification_data.as_ref(), |c| c.to_cbor()), + &encode_nullable(self.reference_time.as_ref(), |t| encode_uint(*t)), &encode_nullable(self.inclusion_certificate.as_ref(), |c| { encode_byte_string(&c.encode()) }), @@ -67,11 +77,16 @@ impl InclusionProof { /// This verifies the state relation, certification data, shard, quorum UC, /// and unlock witness. Transaction/token verification may impose additional /// application-level constraints. + /// + /// `reference_time` is the value the leaf was built from; it is taken from + /// the caller rather than from this proof's certificate, because the tree + /// is append-only and a proof may be issued against a later root. pub fn verify_for( &self, state_id: &StateId, + reference_time: u64, trust_base: &RootTrustBase, ) -> Result<(), VerificationError> { - verify::verify_inclusion_proof_for(trust_base, self, state_id) + verify::verify_inclusion_proof_for(trust_base, self, state_id, reference_time) } } diff --git a/src/api/leaf_value.rs b/src/api/leaf_value.rs new file mode 100644 index 0000000..2067139 --- /dev/null +++ b/src/api/leaf_value.rs @@ -0,0 +1,54 @@ +//! Sparse Merkle tree leaf value recorded by the Unicity Service for an +//! accepted certification request. +//! +//! The value binds the reference time the request was validated under, not the +//! transaction hash alone. The tree is append-only, so a leaf can be certified +//! afresh against any later root and a later inclusion proof carries a later +//! round's reference time. Binding the reference time into the leaf value fixes +//! the value the transition was validated under, for any proof of that leaf. + +use crate::cbor::{encode_array, encode_byte_string, encode_uint}; +use crate::crypto::hash::{sha256, DataHash}; + +/// Calculate the leaf value for a certified request: +/// `SHA-256(CBOR([transactionHash, referenceTime]))`. +pub fn calculate_leaf_value(transaction_hash: &DataHash, reference_time: u64) -> DataHash { + sha256(&encode_array(&[ + &encode_byte_string(transaction_hash.data()), + &encode_uint(reference_time), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::hash::HashAlgorithm; + use hex_literal::hex; + + // Shared across the Go, Java and TypeScript implementations. + const TRANSACTION_HASH: [u8; 32] = + hex!("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + const REFERENCE_TIME: u64 = 1755000000; + const EXPECTED: [u8; 32] = + hex!("0235bd52cfa10c9785dfa01942bc396f201fe715dbc3896ee117a97e895e1e36"); + + #[test] + fn matches_the_shared_test_vector() { + let transaction_hash = DataHash::new(HashAlgorithm::Sha256, TRANSACTION_HASH).unwrap(); + + assert_eq!( + calculate_leaf_value(&transaction_hash, REFERENCE_TIME).data(), + EXPECTED + ); + } + + #[test] + fn changes_with_the_reference_time() { + let transaction_hash = DataHash::new(HashAlgorithm::Sha256, TRANSACTION_HASH).unwrap(); + + assert_ne!( + calculate_leaf_value(&transaction_hash, REFERENCE_TIME + 1).data(), + EXPECTED + ); + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1070c7d..650e632 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod certification; pub mod certification_request; pub mod inclusion_certificate; pub mod inclusion_proof; +pub mod leaf_value; pub mod network_id; pub mod non_inclusion_certificate; pub mod non_inclusion_proof; @@ -14,13 +15,14 @@ pub mod state_id; /// Leaf-value width in the Unicity aggregation profile. /// /// The generic RSMT certificate format permits arbitrary byte-string values; -/// Unicity restricts each value to one raw SHA-256 transaction hash. +/// Unicity restricts each value to one raw SHA-256 digest. pub const AGGREGATION_TREE_VALUE_SIZE: usize = 32; pub use certification::CertificationData; pub use certification_request::CertificationRequest; pub use inclusion_certificate::InclusionCertificate; pub use inclusion_proof::InclusionProof; +pub use leaf_value::calculate_leaf_value; pub use network_id::NetworkId; pub use non_inclusion_certificate::NonInclusionCertificate; pub use non_inclusion_proof::NonInclusionProof; diff --git a/src/client/mod.rs b/src/client/mod.rs index 6d145dd..482ea9a 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -154,9 +154,14 @@ pub fn mint( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; + // Fix the reference time now, from the proof that first establishes the + // leaf; a proof fetched later is issued against a later root. + let reference_time = proof + .reference_time + .ok_or(ClientError::Verification(VerificationError::PathInvalid))?; let token = Token::new( - CertifiedMintTransaction::new(transaction, proof), + CertifiedMintTransaction::new(transaction, reference_time, proof), Vec::new(), ); token.verify(trust_base)?; @@ -195,9 +200,16 @@ pub fn transfer( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; + let reference_time = proof + .reference_time + .ok_or(ClientError::Verification(VerificationError::PathInvalid))?; let mut transactions = token.transactions().to_vec(); - transactions.push(CertifiedTransferTransaction::new(transaction, proof)); + transactions.push(CertifiedTransferTransaction::new( + transaction, + reference_time, + proof, + )); let next = Token::new(token.genesis().clone(), transactions); next.verify(trust_base)?; Ok(next) diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 79e1ac0..4255705 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -19,6 +19,7 @@ use crate::api::bft::{ InputRecord, RootTrustBase, RootTrustBaseNodeInfo, ShardId, ShardTreeCertificate, UnicityCertificate, UnicitySeal, UnicityTreeCertificate, }; +use crate::api::calculate_leaf_value; use crate::api::inclusion_proof::InclusionProof; use crate::api::{CertificationData, InclusionCertificate, NetworkId, StateId}; use crate::crypto::hash::{sha256, DataHash}; @@ -37,6 +38,9 @@ use crate::verify::{ VerificationPolicy, }; +/// Reference time every fixture in this module certifies under. +const REFERENCE_TIME: u64 = 1755000000; + // --- proof construction (mirrors the verify-engine test harness) ----------- fn signer(b: u8) -> Secp256k1Signer { @@ -126,7 +130,7 @@ fn valid_proof( ) -> InclusionProof { let tx_hash = transaction.calculate_transaction_hash(); let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); - let root = leaf_root(&state_id, &tx_hash); + let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); let certification_data = CertificationData::new( transaction.lock_script().clone(), @@ -136,6 +140,7 @@ fn valid_proof( ); InclusionProof { certification_data: Some(certification_data), + reference_time: Some(REFERENCE_TIME), inclusion_certificate: Some(InclusionCertificate::decode(&[0u8; 32]).unwrap()), unicity_certificate: signed_uc(node, root), } @@ -174,7 +179,10 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); let proof = valid_proof(&mint, &minter, node); - Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()) + Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ) } /// Wrap a burn transfer into a certified, burned source token. @@ -187,7 +195,11 @@ fn burned_token( let proof = valid_proof(&burn_tx, owner, node); Token::new( source.genesis().clone(), - vec![CertifiedTransferTransaction::new(burn_tx, proof)], + vec![CertifiedTransferTransaction::new( + burn_tx, + REFERENCE_TIME, + proof, + )], ) } @@ -212,7 +224,10 @@ fn mint_output( .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); let proof = valid_proof(&mint, &minter, node); - Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()) + Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ) } fn registry() -> MintJustificationRegistry { diff --git a/src/transaction/certified.rs b/src/transaction/certified.rs index b4d5b81..f9f0933 100644 --- a/src/transaction/certified.rs +++ b/src/transaction/certified.rs @@ -1,13 +1,18 @@ //! Certified transactions: a transaction bundled with its inclusion proof. //! //! These wrap [`MintTransaction`] / [`TransferTransaction`] and are *not* tagged -//! — on the wire each is a 2-element array `[transaction, inclusionProof]`. +//! — on the wire each is a 3-element array +//! `[transaction, referenceTime, inclusionProof]`. +//! +//! The reference time is fixed when the transaction is first bound to a proof +//! and carried from then on: the tree is append-only, so a proof fetched later +//! is issued against a later root and its input record carries a later time. use super::mint::MintTransaction; use super::transfer::TransferTransaction; use super::Transaction; use crate::api::inclusion_proof::InclusionProof; -use crate::cbor::{encode_array, Decoder}; +use crate::cbor::{encode_array, encode_uint, Decoder}; use crate::crypto::hash::DataHash; use crate::error::Error; use crate::predicate::EncodedPredicate; @@ -16,15 +21,21 @@ use crate::predicate::EncodedPredicate; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CertifiedMintTransaction { transaction: MintTransaction, + reference_time: u64, inclusion_proof: InclusionProof, } impl CertifiedMintTransaction { /// Bundle a transaction with a proof (no verification — see /// [`Token::verify`](super::token::Token::verify)). - pub fn new(transaction: MintTransaction, inclusion_proof: InclusionProof) -> Self { + pub fn new( + transaction: MintTransaction, + reference_time: u64, + inclusion_proof: InclusionProof, + ) -> Self { CertifiedMintTransaction { transaction, + reference_time, inclusion_proof, } } @@ -37,6 +48,10 @@ impl CertifiedMintTransaction { pub fn inclusion_proof(&self) -> &InclusionProof { &self.inclusion_proof } + /// The reference time this transition was validated under. + pub fn reference_time(&self) -> u64 { + self.reference_time + } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { self.transaction.recipient() @@ -46,18 +61,23 @@ impl CertifiedMintTransaction { self.transaction.calculate_state_hash() } - /// Decode from CBOR (2-element array). + /// Decode from CBOR (3-element array). pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(2))?; + let items = d.array(Some(3))?; Ok(CertifiedMintTransaction { transaction: MintTransaction::from_cbor(items[0])?, - inclusion_proof: InclusionProof::from_cbor(items[1])?, + reference_time: items[1].uint()?, + inclusion_proof: InclusionProof::from_cbor(items[2])?, }) } - /// Encode to CBOR (2-element array). + /// Encode to CBOR (3-element array). pub fn to_cbor(&self) -> alloc::vec::Vec { - encode_array(&[&self.transaction.to_cbor(), &self.inclusion_proof.to_cbor()]) + encode_array(&[ + &self.transaction.to_cbor(), + &encode_uint(self.reference_time), + &self.inclusion_proof.to_cbor(), + ]) } } @@ -65,14 +85,20 @@ impl CertifiedMintTransaction { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CertifiedTransferTransaction { transaction: TransferTransaction, + reference_time: u64, inclusion_proof: InclusionProof, } impl CertifiedTransferTransaction { /// Bundle a transaction with a proof (no verification). - pub fn new(transaction: TransferTransaction, inclusion_proof: InclusionProof) -> Self { + pub fn new( + transaction: TransferTransaction, + reference_time: u64, + inclusion_proof: InclusionProof, + ) -> Self { CertifiedTransferTransaction { transaction, + reference_time, inclusion_proof, } } @@ -85,6 +111,10 @@ impl CertifiedTransferTransaction { pub fn inclusion_proof(&self) -> &InclusionProof { &self.inclusion_proof } + /// The reference time this transition was validated under. + pub fn reference_time(&self) -> u64 { + self.reference_time + } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { self.transaction.recipient() @@ -94,22 +124,27 @@ impl CertifiedTransferTransaction { self.transaction.calculate_state_hash() } - /// Decode from CBOR (2-element array), reconstructing the transfer's source + /// Decode from CBOR (3-element array), reconstructing the transfer's source /// state hash and lock script from the previous transaction. pub fn from_cbor( d: Decoder<'_>, source_state_hash: DataHash, lock_script: EncodedPredicate, ) -> Result { - let items = d.array(Some(2))?; + let items = d.array(Some(3))?; Ok(CertifiedTransferTransaction { transaction: TransferTransaction::from_cbor(items[0], source_state_hash, lock_script)?, - inclusion_proof: InclusionProof::from_cbor(items[1])?, + reference_time: items[1].uint()?, + inclusion_proof: InclusionProof::from_cbor(items[2])?, }) } - /// Encode to CBOR (2-element array). + /// Encode to CBOR (3-element array). pub fn to_cbor(&self) -> alloc::vec::Vec { - encode_array(&[&self.transaction.to_cbor(), &self.inclusion_proof.to_cbor()]) + encode_array(&[ + &self.transaction.to_cbor(), + &encode_uint(self.reference_time), + &self.inclusion_proof.to_cbor(), + ]) } } diff --git a/src/verify/mod.rs b/src/verify/mod.rs index 503a3db..d625073 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -44,6 +44,7 @@ use alloc::vec::Vec; use crate::api::bft::{RootTrustBase, UnicityCertificate}; use crate::api::inclusion_proof::InclusionProof; +use crate::api::leaf_value::calculate_leaf_value; use crate::api::{NonInclusionProof, StateId}; use crate::crypto::hash::{DataHash, HashAlgorithm}; use crate::predicate::builtin::SignaturePredicate; @@ -99,6 +100,7 @@ pub(crate) fn verify_token_in_context( context.trust_base(), transfer.inclusion_proof(), transfer.transaction(), + transfer.reference_time(), ) .map_err(|e| VerificationError::Transfer { index: i, @@ -138,8 +140,13 @@ fn verify_genesis( return Err(VerificationError::InvalidMintLockScript); } - verify_inclusion_proof(trust_base, genesis.inclusion_proof(), mint) - .map_err(|e| VerificationError::Genesis(alloc::boxed::Box::new(e)))?; + verify_inclusion_proof( + trust_base, + genesis.inclusion_proof(), + mint, + genesis.reference_time(), + ) + .map_err(|e| VerificationError::Genesis(alloc::boxed::Box::new(e)))?; // Mint justification: dispatch through the registry. An empty registry // rejects any present justification (fail closed); a registered verifier @@ -156,6 +163,7 @@ fn verify_inclusion_proof( trust_base: &RootTrustBase, proof: &InclusionProof, transaction: &impl Transaction, + reference_time: u64, ) -> Result<(), VerificationError> { proof .inclusion_certificate @@ -182,17 +190,23 @@ fn verify_inclusion_proof( // delegate the relation, shard, UC, and witness checks to the public // state-membership verifier. let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); - verify_inclusion_proof_for(trust_base, proof, &state_id) + verify_inclusion_proof_for(trust_base, proof, &state_id, reference_time) } /// Verify that `state_id` is included at the certified root carried by `proof`. /// /// This verifies the proof's certification data and witness, but does not claim /// that its transaction hash belongs to a caller-supplied transaction object. +/// +/// `reference_time` is the value the certified leaf was built from. It comes +/// from the caller, not from the proof's own unicity certificate: the tree is +/// append-only, so the proof may have been issued against a later root whose +/// input record carries a later reference time. pub fn verify_inclusion_proof_for( trust_base: &RootTrustBase, proof: &InclusionProof, state_id: &StateId, + reference_time: u64, ) -> Result<(), VerificationError> { trust_base .validate() @@ -219,11 +233,8 @@ pub fn verify_inclusion_proof_for( proof.unicity_certificate.input_record.hash.clone(), ) .map_err(|_| VerificationError::PathInvalid)?; - if !inclusion_certificate.verify( - state_id, - certification_data.transaction_hash(), - &expected_root, - ) { + let leaf_value = calculate_leaf_value(certification_data.transaction_hash(), reference_time); + if !inclusion_certificate.verify(state_id, &leaf_value, &expected_root) { return Err(VerificationError::PathInvalid); } @@ -239,6 +250,7 @@ pub fn verify_inclusion_proof_for( // Finally, the unlock script must satisfy the (reconstructed) lock script. verify_predicate( certification_data.lock_script(), + reference_time, certification_data.source_state_hash(), certification_data.transaction_hash(), certification_data.unlock_script(), @@ -347,6 +359,7 @@ pub fn verify_non_inclusion_proof( fn verify_predicate( lock_script: &EncodedPredicate, + _reference_time: u64, source_state_hash: &DataHash, transaction_hash: &DataHash, unlock_script: &[u8], @@ -376,6 +389,9 @@ fn verify_predicate( mod tests { use super::*; + /// Reference time every fixture in this module certifies under. + const REFERENCE_TIME: u64 = 1755000000; + use alloc::string::{String, ToString}; use crate::api::bft::{ @@ -510,7 +526,7 @@ mod tests { ) -> InclusionProof { let tx_hash = transaction.calculate_transaction_hash(); let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); - let root = leaf_root(&state_id, &tx_hash); + let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); let certification_data = CertificationData::new( transaction.lock_script().clone(), @@ -520,6 +536,7 @@ mod tests { ); InclusionProof { certification_data: Some(certification_data), + reference_time: Some(REFERENCE_TIME), inclusion_certificate: Some(InclusionCertificate::decode(&[0u8; 32]).unwrap()), unicity_certificate: signed_uc(node, root), } @@ -577,11 +594,14 @@ mod tests { #[test] fn baseline_transfer_proof_verifies() { let (tb, _node, _owner, transfer, proof) = transfer_case(); - assert_eq!(verify_inclusion_proof(&tb, &proof, &transfer), Ok(())); + assert_eq!( + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), + Ok(()) + ); let target = StateId::derive(transfer.lock_script(), transfer.source_state_hash()); - assert_eq!(proof.verify_for(&target, &tb), Ok(())); + assert_eq!(proof.verify_for(&target, REFERENCE_TIME, &tb), Ok(())); assert_eq!( - proof.verify_for(&state_id([0xff; 32]), &tb), + proof.verify_for(&state_id([0xff; 32]), REFERENCE_TIME, &tb), Err(VerificationError::CertificationDataMismatch) ); } @@ -688,7 +708,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); proof.inclusion_certificate = None; assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::InclusionCertificateMissing) ); } @@ -698,7 +718,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); proof.certification_data = None; assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMissing) ); } @@ -715,7 +735,7 @@ mod tests { c.unlock_script().to_vec(), )); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) ); } @@ -731,7 +751,7 @@ mod tests { c.unlock_script().to_vec(), )); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) ); } @@ -747,7 +767,7 @@ mod tests { c.unlock_script().to_vec(), )); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::TransactionHashMismatch) ); } @@ -759,7 +779,7 @@ mod tests { proof.unicity_certificate.input_record.hash = alloc::vec![0xCDu8; 32]; reseal(&mut proof, &node); // keep the seal consistent so PATH fails first assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::PathInvalid) ); } @@ -772,7 +792,7 @@ mod tests { encoded.push(0b1000_0000); proof.unicity_certificate.shard_tree_certificate.shard = ShardId::decode(&encoded).unwrap(); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::ShardMismatch) ); } @@ -782,7 +802,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); proof.unicity_certificate.unicity_seal.network_id = NetworkId::MAINNET; assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::SealNetworkMismatch) ); } @@ -792,7 +812,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); proof.unicity_certificate.unicity_seal.hash = alloc::vec![0u8; 32]; assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::SealRootMismatch) ); } @@ -802,7 +822,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); proof.unicity_certificate.unicity_seal.signatures = Vec::new(); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::QuorumNotMet) ); } @@ -814,7 +834,7 @@ mod tests { let rogue = signer(0xEE); let proof = valid_proof(&transfer, &owner, &rogue); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::QuorumNotMet) ); } @@ -832,7 +852,7 @@ mod tests { unlock, )); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::NotAuthenticated) ); } @@ -844,7 +864,7 @@ mod tests { let thief = signer(0x44); let proof = valid_proof(&transfer, &thief, &node); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::NotAuthenticated) ); } @@ -875,7 +895,10 @@ mod tests { fn baseline_genesis_token_verifies() { let node = signer(0x11); let (tb, mint, proof) = genesis_token(&node, None); - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); assert_eq!(token.verify(&tb), Ok(())); } @@ -883,7 +906,10 @@ mod tests { fn rule_network_mismatch() { let node = signer(0x11); let (_, mint, proof) = genesis_token(&node, None); - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); // Mint is on LOCAL; verify against a (valid) MAINNET trust base. let mainnet = RootTrustBase::new( 0, @@ -912,7 +938,10 @@ mod tests { c.transaction_hash().clone(), c.unlock_script().to_vec(), )); - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); assert_eq!( token.verify(&tb), Err(VerificationError::InvalidMintLockScript) @@ -925,7 +954,10 @@ mod tests { // A justified mint whose proof is otherwise fully valid reaches — and // fails at — the justification rule (no verifier is registered). let (tb, mint, proof) = genesis_token(&node, Some(alloc::vec![0xde, 0xad])); - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); assert_eq!( token.verify(&tb), Err(VerificationError::UnsupportedMintJustification) @@ -938,7 +970,10 @@ mod tests { fn rule_invalid_trust_base() { let node = signer(0x11); let (_, mint, proof) = genesis_token(&node, None); - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); // Threshold of zero would accept an unsigned seal. let zero_threshold = RootTrustBase::new( @@ -990,7 +1025,10 @@ mod tests { let node = signer(0x11); let (tb, mint, mut proof) = genesis_token(&node, None); proof.unicity_certificate.unicity_seal.hash = alloc::vec![0u8; 32]; // break seal root - let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); + let token = Token::new( + CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), + Vec::new(), + ); assert_eq!( token.verify(&tb), Err(VerificationError::Genesis(alloc::boxed::Box::new( @@ -1006,7 +1044,7 @@ mod tests { let node = signer(0x11); let owner = signer(0x55); // genesis recipient == transfer owner let (tb, mint, genesis_proof) = genesis_token(&node, None); - let genesis = CertifiedMintTransaction::new(mint, genesis_proof); + let genesis = CertifiedMintTransaction::new(mint, REFERENCE_TIME, genesis_proof); let recipient = signer(0x88); let transfer = TransferTransaction::new( @@ -1023,6 +1061,7 @@ mod tests { genesis.clone(), alloc::vec![CertifiedTransferTransaction::new( transfer.clone(), + REFERENCE_TIME, transfer_proof.clone() )], ); @@ -1032,7 +1071,11 @@ mod tests { transfer_proof.unicity_certificate.unicity_seal.hash = alloc::vec![0u8; 32]; let tampered = Token::new( genesis, - alloc::vec![CertifiedTransferTransaction::new(transfer, transfer_proof)], + alloc::vec![CertifiedTransferTransaction::new( + transfer, + REFERENCE_TIME, + transfer_proof + )], ); assert_eq!( tampered.verify(&tb), @@ -1053,7 +1096,7 @@ mod tests { // matches and the transfer is rejected. let node = signer(0x11); let (tb, mint, genesis_proof) = genesis_token(&node, None); - let genesis = CertifiedMintTransaction::new(mint, genesis_proof.clone()); + let genesis = CertifiedMintTransaction::new(mint, REFERENCE_TIME, genesis_proof.clone()); let recipient = signer(0x88); let transfer = TransferTransaction::new( @@ -1067,7 +1110,11 @@ mod tests { // The genesis proof does not attest to the transfer's transaction. let tampered = Token::new( genesis, - alloc::vec![CertifiedTransferTransaction::new(transfer, genesis_proof)], + alloc::vec![CertifiedTransferTransaction::new( + transfer, + REFERENCE_TIME, + genesis_proof + )], ); let result = tampered.verify(&tb); assert!( diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index 0acb3f6..658553b 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -215,6 +215,7 @@ fn rejects_mismatched_transfer_certification_state() { token.genesis().clone(), vec![CertifiedTransferTransaction::new( certified.transaction().clone(), + certified.reference_time(), proof, )], ); diff --git a/tests/vectors/transition_flow.json b/tests/vectors/transition_flow.json index 645f881..7d35f9e 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,12 +1,12 @@ { - "__comment": "generated by state-transition-sdk-js f4cc9056375759844fc1217289d765948dbbd88b", + "__comment": "generated by state-transition-sdk-js 4a50faeedc9dc4dafb700f1c03dc77f11882b97b", "trustBase": { "networkId": 3, "nodeId": "NODE", - "aggregatorPublicKey": "02d290e9edc006f4b0fbce2f77f5361dac479a47718ab68f6bc33b187139e00834", + "aggregatorPublicKey": "03079264c4b4bfcd7fe3a7b7b92b6c439f3a5b3abcd29189bf7b54d781ff03d722", "quorumThreshold": "1" }, - "aliceToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd160180", - "bobToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd16018182d998858401d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba582094df64fc3f5168111b03ee30ff0c43e213d3320c92cfc290122ad2f78974a176f6d998798401d998778501d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c8875820787e7ace403ead5472c23caafce2655a7e3609a3825f591302e58c0031eb76175820b714555a7187a102a474abae499c21cd817b6fd62da674bbfd46786af0ef6e73584175fd30f6a3180e73a9b1699d548fb21cf6f367b256182507cff9a57b1433d27b11313869693c6ed73ce419ba7b31dfb2819d612b5f62ba3995a40c7c00df4b9501584020000000000000000000000000000000000000000000000000000000000000000aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb60d998598701d9985a8a010000f65820d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820dc5b452f911ce2372a539f7e28eaaafb7e6eabc07c3712a1d98ea57d356a5e8aa1644e4f444558411c3ea96557c571481fa82c68c164690fca34b77b937eb68ee4a235fde58c77435a2b1637be0b76325cc550c2e61badc077c6c74bc8078a68d6987f94d5e197d200", - "carolToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd16018282d998858401d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba582094df64fc3f5168111b03ee30ff0c43e213d3320c92cfc290122ad2f78974a176f6d998798401d998778501d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c8875820787e7ace403ead5472c23caafce2655a7e3609a3825f591302e58c0031eb76175820b714555a7187a102a474abae499c21cd817b6fd62da674bbfd46786af0ef6e73584175fd30f6a3180e73a9b1699d548fb21cf6f367b256182507cff9a57b1433d27b11313869693c6ed73ce419ba7b31dfb2819d612b5f62ba3995a40c7c00df4b9501584020000000000000000000000000000000000000000000000000000000000000000aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb60d998598701d9985a8a010000f65820d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820dc5b452f911ce2372a539f7e28eaaafb7e6eabc07c3712a1d98ea57d356a5e8aa1644e4f444558411c3ea96557c571481fa82c68c164690fca34b77b937eb68ee4a235fde58c77435a2b1637be0b76325cc550c2e61badc077c6c74bc8078a68d6987f94d5e197d20082d998858401d998788301410158210208344650f76b0a1c79b1b735f1d949031bc5f3a510155201cb2e9529a6cd919b5820d1a661ba4a72508795367cec99d720597deb9d5bdb5ac856dcf41519bdb32500f6d998798401d998778501d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba58202d65214ce5470ff9615b370325562f8f38387fc30b54f433584d5221b6c825f0582012e9847ca74d6843679b1d41f3904f7e3bd5f8c756173e25833892047e56d417584114495a5b6fbfe68a99f34dcf7bc45b913e270f467c32ff465f23973a6fad0eb007afd15fbb01414e77ff096fec93ad2c54bc30f3286e0875b7f1ab83d85619f20158408000000000000000000000000000000000000000000000000000000000000000d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97ed998598701d9985a8a010000f65820a03babab0dc839cd821fdaf51aaedd502f2f9a2693c8a49b5fbee9044cee5f4e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820ab26a407db11641b7edd928c5ac36394c54a49e7bb5ae0dbc930867daf156b91a1644e4f444558418a912f4d6d1f93fcf9404e3e94884bcc4458cf2c5573fdcb971c1a72ad1253b508a23f9909cf829afee63bf08d66527ad5000b00e6e89cc8120210dddfb0d36a01" + "aliceToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b10180", + "bobToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b1018183d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820be2bd884dce6be15a9da3466f3d0a64eb5ae63d68f49fb894c0d9708517a2030f61a689b2cc1d998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820411e512b70ecb6ed119364b1b421d0489e1ace25714f95ac2ea45c141eec20ee5820105679889c3bab19659e333b7634e9ebd856a91bcc509a22a0510cb4952f26495841efa6cbf64ecae70abc15e1764436f4de5f3d2ea3e8fa94a3c9eaab6d19f2895e7560d6adef10ccbd84e318f26d0f7e3f3cddb22fc402b101ea375da7b7a0a1f6001a689b2cc158404000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109ed998598701d9985a8a010000f65820b2e94d1767f8ec99ff3f073cc7080b3f40927096d52a6c682c358b7cdf97340d401a689b2cc2f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820587d2234bac646ec32724ed4232474c99b70b687fedce05f4cb8f69c21e9f0cfa1644e4f4445584181ed247eacd493289128add97f483614aa089f3661626760c69c4c5fbff6cb99668f536e5090b262e624996d3578d88c0bac0eb2e92949b5c879658fb768378301", + "carolToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b1018283d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820be2bd884dce6be15a9da3466f3d0a64eb5ae63d68f49fb894c0d9708517a2030f61a689b2cc1d998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820411e512b70ecb6ed119364b1b421d0489e1ace25714f95ac2ea45c141eec20ee5820105679889c3bab19659e333b7634e9ebd856a91bcc509a22a0510cb4952f26495841efa6cbf64ecae70abc15e1764436f4de5f3d2ea3e8fa94a3c9eaab6d19f2895e7560d6adef10ccbd84e318f26d0f7e3f3cddb22fc402b101ea375da7b7a0a1f6001a689b2cc158404000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109ed998598701d9985a8a010000f65820b2e94d1767f8ec99ff3f073cc7080b3f40927096d52a6c682c358b7cdf97340d401a689b2cc2f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820587d2234bac646ec32724ed4232474c99b70b687fedce05f4cb8f69c21e9f0cfa1644e4f4445584181ed247eacd493289128add97f483614aa089f3661626760c69c4c5fbff6cb99668f536e5090b262e624996d3578d88c0bac0eb2e92949b5c879658fb76837830183d998858401d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f958200c79b824019d0a8163b3ceabe003741d72a2188e30ea3cc3c46aec86b4525bd5f61a689b2cc2d998798501d998778501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558200c1e4483868eef220130a3b43fa6edf640c1636b9b8aafcfa33f2fce23912c745820656a9c90b2aa4993c93f7d65e7c1913a5f4661fd068e5bd552fb434db732dc4e5841c73b2008817d9606a2bd27660bbb5b62b4471eaeb563885c14546e0c6454fae243e2463af227d241660152df50717fb868796e571204c501ebf62ad4476df59b001a689b2cc258606000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e0013ee5d7ec45a24a4589a7e3ef57aae0837eeab02c803cb31adb49d0a9bcad4d998598701d9985a8a010000f658202b9d3887515b6e29290dd9c684ec5e77cf46eda2f4457cc4f950d3643e95fca9401a689b2cc3f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a2e7854d02d9bfc0a5030b69d43b8e411354be456d7f08199749fee4177a867fa1644e4f4445584178ea87097180813c71e1c8fe64ab9d4d52e17ebb66b9d84a2d051affb0e89ddd7b247ed601670b013c141c0ee1afbdc5dd895a0e9b4c72ab321c49fd849ec6f301" } From 2978143894fb43c7a49879554853db0a4c7dedba Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 11:40:28 +0300 Subject: [PATCH 02/12] Explicit unicity service request timeout A transaction now carries an exclusive timeout tau_Q. The Unicity Service accepts the request only in a round whose reference time satisfies tau < tau_Q; an expired request is rejected. verify_inclusion_proof_for requires tau < tau_Q from the certification data, and verify_inclusion_proof rejects a proof whose certification data declares a different timeout. The cross-SDK fixture and the golden certification vectors are regenerated from the TypeScript SDK at the matching commit. Wire changes are not backward compatible: MintTransaction [version, networkId, recipient, salt, tokenType, justification, data, tau_Q] TransferTransaction [version, recipient, stateMask, data, tau_Q] CertificationData [version, lockScript, sourceStateHash, transactionHash, tau_Q, witness] Refs #17 --- src/api/certification.rs | 20 +++++++++++++++++--- src/api/certification_request.rs | 4 ++-- src/client/mod.rs | 11 ++++++++++- src/payment/split.rs | 12 +++++++++--- src/payment/tests.rs | 18 ++++++++++++++++++ src/transaction/mint.rs | 21 +++++++++++++++++++-- src/transaction/mod.rs | 5 +++++ src/transaction/transfer.rs | 12 +++++++++++- src/verify/error.rs | 3 +++ src/verify/mod.rs | 18 ++++++++++++++++++ tests/transition_flow.rs | 1 + tests/vectors/transition_flow.json | 8 ++++---- 12 files changed, 117 insertions(+), 16 deletions(-) diff --git a/src/api/certification.rs b/src/api/certification.rs index cef7b32..30a8094 100644 --- a/src/api/certification.rs +++ b/src/api/certification.rs @@ -24,6 +24,7 @@ pub struct CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, + timeout: u64, unlock_script: Vec, } @@ -33,12 +34,14 @@ impl CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, + timeout: u64, unlock_script: Vec, ) -> Self { CertificationData { lock_script, source_state_hash, transaction_hash, + timeout, unlock_script, } } @@ -50,6 +53,7 @@ impl CertificationData { lock_script: transaction.lock_script().clone(), source_state_hash: transaction.source_state_hash().clone(), transaction_hash: transaction.calculate_transaction_hash(), + timeout: transaction.timeout(), unlock_script, } } @@ -66,6 +70,10 @@ impl CertificationData { pub fn transaction_hash(&self) -> &DataHash { &self.transaction_hash } + /// The exclusive timeout of the certification request. + pub fn timeout(&self) -> u64 { + self.timeout + } /// The unlock script (witness). pub fn unlock_script(&self) -> &[u8] { &self.unlock_script @@ -80,6 +88,7 @@ impl CertificationData { &self.lock_script.to_cbor(), &encode_byte_string(self.source_state_hash.data()), &encode_byte_string(self.transaction_hash.data()), + &encode_uint(self.timeout), &encode_byte_string(&self.unlock_script), ]), ) @@ -88,7 +97,7 @@ impl CertificationData { /// Decode from CBOR. The reference SDKs always store SHA-256 hashes here. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(CERTIFICATION_DATA_TAG)?; - let items = inner.array(Some(5))?; + let items = inner.array(Some(6))?; let version = items[0].uint()?; if version != VERSION { return Err(Error::UnexpectedValue( @@ -99,7 +108,8 @@ impl CertificationData { lock_script: EncodedPredicate::from_cbor(items[1])?, source_state_hash: DataHash::new(HashAlgorithm::Sha256, items[2].bytes_value()?)?, transaction_hash: DataHash::new(HashAlgorithm::Sha256, items[3].bytes_value()?)?, - unlock_script: items[4].bytes_value()?.to_vec(), + timeout: items[4].uint()?, + unlock_script: items[5].bytes_value()?.to_vec(), }) } } @@ -107,6 +117,9 @@ impl CertificationData { #[cfg(all(test, feature = "client"))] mod tests { use super::*; + + /// Exclusive certification request timeout used by the golden vector. + const TIMEOUT: u64 = 1755000000; use crate::api::network_id::NetworkId; use crate::crypto::signature::PublicKey; use crate::predicate::builtin::SignaturePredicate; @@ -131,6 +144,7 @@ mod tests { let mint = MintTransaction::create( NetworkId::MAINNET, recipient, + TIMEOUT, TokenType::new([0u8; 32]), TokenSalt::from_bytes([0u8; 32]), None, @@ -147,7 +161,7 @@ mod tests { assert_eq!( cert.to_cbor(), hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" ) ); diff --git a/src/api/certification_request.rs b/src/api/certification_request.rs index 6bee25a..128052d 100644 --- a/src/api/certification_request.rs +++ b/src/api/certification_request.rs @@ -55,7 +55,7 @@ mod tests { #[test] fn certification_request_golden_vector() { let certification_data = hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" ); let data = CertificationData::from_cbor(Decoder::new(&certification_data)).unwrap(); let request = CertificationRequest::new(&data); @@ -63,7 +63,7 @@ mod tests { assert_eq!( request.to_cbor(), hex!( - "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e70000" + "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f880000" ) ); } diff --git a/src/client/mod.rs b/src/client/mod.rs index 482ea9a..2793f2d 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -123,6 +123,7 @@ pub fn mint( trust_base: &RootTrustBase, network: NetworkId, recipient: &impl Predicate, + timeout: u64, token_type: TokenType, salt: TokenSalt, data: Option>, @@ -137,6 +138,7 @@ pub fn mint( let transaction = MintTransaction::create( network, EncodedPredicate::from_predicate(recipient), + timeout, token_type, salt, data, @@ -170,12 +172,14 @@ pub fn mint( /// Transfer `token` to `recipient`, authorised by `signer` (the current /// owner's key), and return the verified successor [`Token`]. +#[allow(clippy::too_many_arguments)] pub fn transfer( aggregator: &A, trust_base: &RootTrustBase, token: &Token, recipient: &impl Predicate, signer: &impl Signer, + timeout: u64, state_mask: StateMask, data: Option>, ) -> Result> { @@ -187,6 +191,7 @@ pub fn transfer( source_state_hash, lock_script, EncodedPredicate::from_predicate(recipient), + timeout, state_mask.bytes().to_vec(), data, ); @@ -218,6 +223,9 @@ pub fn transfer( #[cfg(test)] mod tests { use super::*; + + /// Exclusive certification request timeout used by these tests. + const TIMEOUT: u64 = 1755000000; use crate::crypto::signature::PublicKey; use crate::predicate::builtin::SignaturePredicate; use core::cell::RefCell; @@ -278,6 +286,7 @@ mod tests { &trust_base, NetworkId::MAINNET, &recipient, + TIMEOUT, TokenType::new([0u8; 32]), TokenSalt::from_bytes([0u8; 32]), None, @@ -290,7 +299,7 @@ mod tests { assert_eq!( captured, hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" ) ); } diff --git a/src/payment/split.rs b/src/payment/split.rs index 8824019..0b2a95e 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -135,7 +135,8 @@ impl TokenSplit { /// 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 + /// from its mint `data`. `burn_timeout` is the burn transfer's exclusive + /// certification request timeout. `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. /// @@ -147,11 +148,13 @@ impl TokenSplit { registry: &MintJustificationRegistry, decode_payment_data: PaymentDataDecoder, requests: Vec, + burn_timeout: u64, burn_state_mask: Option<[u8; 32]>, ) -> 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) + Self::build_split(token, assets, requests, burn_timeout, burn_state_mask) + .map_err(SplitError::Build) } /// Split `token` **without verifying it first**. @@ -166,6 +169,7 @@ impl TokenSplit { token: &Token, decode_payment_data: PaymentDataDecoder, requests: Vec, + burn_timeout: u64, burn_state_mask: Option<[u8; 32]>, ) -> Result { let source_bytes = token @@ -174,7 +178,7 @@ impl TokenSplit { .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) + Self::build_split(token, assets, requests, burn_timeout, burn_state_mask) } /// Construct the split from the source token's already-decoded canonical @@ -184,6 +188,7 @@ impl TokenSplit { token: &Token, assets: PaymentAssetCollection, requests: Vec, + burn_timeout: u64, burn_state_mask: Option<[u8; 32]>, ) -> Result { let network_id = token.genesis().transaction().network_id(); @@ -257,6 +262,7 @@ impl TokenSplit { source_state_hash, lock_script, burn_predicate.to_encoded(), + burn_timeout, mask.to_vec(), Some(manifest_bytes.clone()), ); diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 4255705..4979eeb 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -40,6 +40,8 @@ use crate::verify::{ /// Reference time every fixture in this module certifies under. const REFERENCE_TIME: u64 = 1755000000; +/// Exclusive certification request timeout every fixture in this module uses. +const TIMEOUT: u64 = 1755003600; // --- proof construction (mirrors the verify-engine test harness) ----------- @@ -136,6 +138,7 @@ fn valid_proof( transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, + transaction.timeout(), unlock, ); InclusionProof { @@ -171,6 +174,7 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { let mint = MintTransaction::create( NetworkId::LOCAL, sig_pred(owner), + TIMEOUT, coin_type(), TokenSalt::from_bytes([0x01; 32]), Some(payment.to_cbor()), @@ -216,6 +220,7 @@ fn mint_output( let mint = MintTransaction::create( network, recipient, + TIMEOUT, token_type, salt, Some(assets.to_cbor()), @@ -335,6 +340,7 @@ fn forged_output_with_type( source_state_hash, lock_script, burn_predicate.to_encoded(), + TIMEOUT, vec![9u8; 32], Some(manifest.to_cbor()), ); @@ -376,6 +382,7 @@ fn split_outputs_verify_end_to_end() { ®istry, PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -462,6 +469,7 @@ fn recursive_split_verification_honors_shared_depth_limit() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -525,6 +533,7 @@ fn rejects_tampered_output_amount() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -563,6 +572,7 @@ fn rejects_dropped_proof() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -597,6 +607,7 @@ fn rejects_wrong_burn_predicate() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -609,6 +620,7 @@ fn rejects_wrong_burn_predicate() { source_state_hash, lock_script, BurnPredicate::new(b"not-the-manifest-hash".to_vec()).to_encoded(), + TIMEOUT, vec![7u8; 32], Some(split.burn.manifest.clone()), ); @@ -657,6 +669,7 @@ fn rejects_missing_manifest() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -666,6 +679,7 @@ fn rejects_missing_manifest() { source_state_hash, lock_script, BurnPredicate::new(b"x".to_vec()).to_encoded(), + TIMEOUT, vec![3u8; 32], None, ); @@ -696,6 +710,7 @@ fn rejects_manifest_length_mismatch() { ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, + TIMEOUT, Some([7u8; 32]), ) .unwrap(); @@ -707,6 +722,7 @@ fn rejects_manifest_length_mismatch() { source_state_hash, lock_script, BurnPredicate::new(short.reason_hash().to_vec()).to_encoded(), + TIMEOUT, vec![4u8; 32], Some(short.to_cbor()), ); @@ -747,6 +763,7 @@ fn rejects_wrong_output_token_type() { &s.source, PaymentAssetCollection::from_cbor_bytes, bad, + TIMEOUT, Some([7u8; 32]), ) .is_err()); @@ -771,6 +788,7 @@ fn rejects_unbalanced_split_at_build_time() { &s.source, PaymentAssetCollection::from_cbor_bytes, bad, + TIMEOUT, Some([7u8; 32]), ) .is_err()); diff --git a/src/transaction/mint.rs b/src/transaction/mint.rs index 6ad546d..be06411 100644 --- a/src/transaction/mint.rs +++ b/src/transaction/mint.rs @@ -25,6 +25,7 @@ const VERSION: u64 = 1; pub struct MintTransaction { network_id: NetworkId, recipient: EncodedPredicate, + timeout: u64, salt: TokenSalt, token_type: TokenType, justification: Option>, @@ -41,6 +42,7 @@ impl MintTransaction { pub fn create( network_id: NetworkId, recipient: EncodedPredicate, + timeout: u64, token_type: TokenType, salt: TokenSalt, data: Option>, @@ -52,6 +54,7 @@ impl MintTransaction { Ok(MintTransaction { network_id, recipient, + timeout, salt, token_type, justification, @@ -90,7 +93,7 @@ impl MintTransaction { /// Decode from CBOR (tagged), re-deriving the lock script / mint state. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(MINT_TRANSACTION_TAG)?; - let items = inner.array(Some(7))?; + let items = inner.array(Some(8))?; let version = items[0].uint()?; if version != VERSION { return Err(Error::UnexpectedValue( @@ -108,7 +111,16 @@ impl MintTransaction { items[5].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; let data = items[6].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; - MintTransaction::create(network_id, recipient, token_type, salt, data, justification) + let timeout = items[7].uint()?; + MintTransaction::create( + network_id, + recipient, + timeout, + token_type, + salt, + data, + justification, + ) } } @@ -125,6 +137,10 @@ impl Transaction for MintTransaction { self.source_state.hash() } + fn timeout(&self) -> u64 { + self.timeout + } + fn calculate_state_hash(&self) -> DataHash { // stateMask for a mint is the token id bytes. sha256(&encode_array(&[ @@ -144,6 +160,7 @@ impl Transaction for MintTransaction { &self.token_type.to_cbor(), &encode_nullable(self.justification.as_ref(), |v| encode_byte_string(v)), &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), + &encode_uint(self.timeout), ]), ) } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 7702abf..48f02bb 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -29,6 +29,11 @@ pub trait Transaction { fn source_state_hash(&self) -> &DataHash; /// The hash of the state this transaction produces. fn calculate_state_hash(&self) -> DataHash; + /// Exclusive timeout of the certification request. The Unicity Service + /// admits the request only in a round whose reference time is below this + /// value. It is part of the transaction encoding, so the transaction hash + /// commits to it and the unlock script signs it. + fn timeout(&self) -> u64; /// CBOR encoding (tagged). fn to_cbor(&self) -> Vec; diff --git a/src/transaction/transfer.rs b/src/transaction/transfer.rs index 38b5353..69cadfe 100644 --- a/src/transaction/transfer.rs +++ b/src/transaction/transfer.rs @@ -28,6 +28,7 @@ pub struct TransferTransaction { lock_script: EncodedPredicate, // On the wire: recipient: EncodedPredicate, + timeout: u64, state_mask: Vec, data: Option>, } @@ -40,6 +41,7 @@ impl TransferTransaction { source_state_hash: DataHash, lock_script: EncodedPredicate, recipient: EncodedPredicate, + timeout: u64, state_mask: Vec, data: Option>, ) -> Self { @@ -47,6 +49,7 @@ impl TransferTransaction { source_state_hash, lock_script, recipient, + timeout, state_mask, data, } @@ -70,7 +73,7 @@ impl TransferTransaction { lock_script: EncodedPredicate, ) -> Result { let inner = d.expect_tag(TRANSFER_TRANSACTION_TAG)?; - let items = inner.array(Some(4))?; + let items = inner.array(Some(5))?; let version = items[0].uint()?; if version != VERSION { return Err(Error::UnexpectedValue( @@ -81,10 +84,12 @@ impl TransferTransaction { let state_mask = items[2].bytes_value()?.to_vec(); let data = items[3].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; + let timeout = items[4].uint()?; Ok(TransferTransaction::new( source_state_hash, lock_script, recipient, + timeout, state_mask, data, )) @@ -104,6 +109,10 @@ impl Transaction for TransferTransaction { &self.source_state_hash } + fn timeout(&self) -> u64 { + self.timeout + } + fn calculate_state_hash(&self) -> DataHash { sha256(&encode_array(&[ &encode_byte_string(&self.source_state_hash.imprint()), @@ -119,6 +128,7 @@ impl Transaction for TransferTransaction { &self.recipient.to_cbor(), &encode_byte_string(&self.state_mask), &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), + &encode_uint(self.timeout), ]), ) } diff --git a/src/verify/error.rs b/src/verify/error.rs index 81994af..7fd3daa 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -66,6 +66,8 @@ pub enum VerificationError { CertificationDataMismatch, /// The certified transaction hash does not match the recomputed one. TransactionHashMismatch, + /// The round's reference time had already reached the request's timeout. + RequestExpired, /// The sparse-Merkle-tree path did not reproduce the expected root. PathInvalid, /// The non-inclusion certificate did not authenticate against the certified root. @@ -176,6 +178,7 @@ impl fmt::Display for VerificationError { write!(f, "certification data does not match transaction state") } VerificationError::TransactionHashMismatch => write!(f, "transaction hash mismatch"), + VerificationError::RequestExpired => write!(f, "certification request expired"), VerificationError::PathInvalid => write!(f, "inclusion path invalid"), VerificationError::NonInclusionCertificateInvalid => { write!(f, "non-inclusion certificate invalid") diff --git a/src/verify/mod.rs b/src/verify/mod.rs index d625073..bdd03e0 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -176,6 +176,7 @@ fn verify_inclusion_proof( if certification_data.lock_script() != transaction.lock_script() || certification_data.source_state_hash() != transaction.source_state_hash() + || certification_data.timeout() != transaction.timeout() { return Err(VerificationError::CertificationDataMismatch); } @@ -228,6 +229,11 @@ pub fn verify_inclusion_proof_for( return Err(VerificationError::CertificationDataMismatch); } + // The request was admissible only in a round strictly before its timeout. + if reference_time >= certification_data.timeout() { + return Err(VerificationError::RequestExpired); + } + let expected_root = DataHash::new( HashAlgorithm::Sha256, proof.unicity_certificate.input_record.hash.clone(), @@ -391,6 +397,8 @@ mod tests { /// Reference time every fixture in this module certifies under. const REFERENCE_TIME: u64 = 1755000000; + /// Exclusive certification request timeout every fixture in this module uses. + const TIMEOUT: u64 = 1755003600; use alloc::string::{String, ToString}; @@ -532,6 +540,7 @@ mod tests { transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, + transaction.timeout(), unlock, ); InclusionProof { @@ -547,6 +556,7 @@ mod tests { sha256(b"source-state"), SignaturePredicate::new(owner.public_key()).to_encoded(), SignaturePredicate::new(recipient.public_key()).to_encoded(), + TIMEOUT, alloc::vec![7u8; 32], None, ) @@ -732,6 +742,7 @@ mod tests { SignaturePredicate::new(stranger.public_key()).to_encoded(), // wrong lock c.source_state_hash().clone(), c.transaction_hash().clone(), + TIMEOUT, c.unlock_script().to_vec(), )); assert_eq!( @@ -748,6 +759,7 @@ mod tests { c.lock_script().clone(), sha256(b"a-different-source-state"), // wrong source c.transaction_hash().clone(), + TIMEOUT, c.unlock_script().to_vec(), )); assert_eq!( @@ -764,6 +776,7 @@ mod tests { c.lock_script().clone(), c.source_state_hash().clone(), sha256(b"not-the-tx-hash"), // wrong tx hash + TIMEOUT, c.unlock_script().to_vec(), )); assert_eq!( @@ -849,6 +862,7 @@ mod tests { c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), + TIMEOUT, unlock, )); assert_eq!( @@ -880,6 +894,7 @@ mod tests { let mint = MintTransaction::create( NetworkId::LOCAL, SignaturePredicate::new(recipient.public_key()).to_encoded(), + TIMEOUT, TokenType::new(alloc::vec![0xAA; 32]), TokenSalt::from_bytes([0x66; 32]), None, @@ -936,6 +951,7 @@ mod tests { SignaturePredicate::new(stranger.public_key()).to_encoded(), c.source_state_hash().clone(), c.transaction_hash().clone(), + TIMEOUT, c.unlock_script().to_vec(), )); let token = Token::new( @@ -1051,6 +1067,7 @@ mod tests { genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), + TIMEOUT, alloc::vec![9u8; 32], None, ); @@ -1103,6 +1120,7 @@ mod tests { genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), + TIMEOUT, alloc::vec![9u8; 32], None, ); diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index 658553b..ee33640 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -208,6 +208,7 @@ fn rejects_mismatched_transfer_certification_state() { data.lock_script().clone(), sha256(b"unrelated source state"), data.transaction_hash().clone(), + data.timeout(), data.unlock_script().to_vec(), )); diff --git a/tests/vectors/transition_flow.json b/tests/vectors/transition_flow.json index 7d35f9e..865ef53 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,12 +1,12 @@ { - "__comment": "generated by state-transition-sdk-js 4a50faeedc9dc4dafb700f1c03dc77f11882b97b", + "__comment": "generated by state-transition-sdk-js 2c055b59b61c2b8426a7e687552f6aadc885a47e", "trustBase": { "networkId": 3, "nodeId": "NODE", "aggregatorPublicKey": "03079264c4b4bfcd7fe3a7b7b92b6c439f3a5b3abcd29189bf7b54d781ff03d722", "quorumThreshold": "1" }, - "aliceToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b10180", - "bobToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b1018183d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820be2bd884dce6be15a9da3466f3d0a64eb5ae63d68f49fb894c0d9708517a2030f61a689b2cc1d998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820411e512b70ecb6ed119364b1b421d0489e1ace25714f95ac2ea45c141eec20ee5820105679889c3bab19659e333b7634e9ebd856a91bcc509a22a0510cb4952f26495841efa6cbf64ecae70abc15e1764436f4de5f3d2ea3e8fa94a3c9eaab6d19f2895e7560d6adef10ccbd84e318f26d0f7e3f3cddb22fc402b101ea375da7b7a0a1f6001a689b2cc158404000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109ed998598701d9985a8a010000f65820b2e94d1767f8ec99ff3f073cc7080b3f40927096d52a6c682c358b7cdf97340d401a689b2cc2f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820587d2234bac646ec32724ed4232474c99b70b687fedce05f4cb8f69c21e9f0cfa1644e4f4445584181ed247eacd493289128add97f483614aa089f3661626760c69c4c5fbff6cb99668f536e5090b262e624996d3578d88c0bac0eb2e92949b5c879658fb768378301", - "carolToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820853a20dc8afeea788b8adf1449ffdc056f26375a8adbe8b40648ab2e6514cb7858208b35e8e10ef0bd5634748767b45bf6b3325a342b45a3b719f60204237b2c7657f6f61a689b2cc0d998798501d998778501d998788301410158210278b33f104b727fdbc2671f13ff5f27bb65c02676cd6909e936940a3276bc6e8b5820ca686a53e805197f711b5c734b5af711d07bc6cb8120da4f48813bd686a9ec5d58204d025ae14837185584cbac0a6cbbbe1b54f8735bfdc5d3accf084313be6183a758410dc59573c76948d525033f5bff40f2619c89ca3eeffb2b84d8055119f38db3d43e518fecfadfa782cdb9059ae3a0ceccbecf2f4c7ce52052711b8762ce7bf2f4001a689b2cc058200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e401a689b2cc1f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a3859798715c2bb863926c9c060896929be8b956cc44a15d5a16508aadc666f4a1644e4f44455841c5498883b9ceb04c6d6864e15cc5b6e86eb93fbdcc5049df3160a187a838b7830f6349d39c12075bdf6633244e686762ab036ac7e1299fa1d11471bf6b0307b1018283d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820be2bd884dce6be15a9da3466f3d0a64eb5ae63d68f49fb894c0d9708517a2030f61a689b2cc1d998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820411e512b70ecb6ed119364b1b421d0489e1ace25714f95ac2ea45c141eec20ee5820105679889c3bab19659e333b7634e9ebd856a91bcc509a22a0510cb4952f26495841efa6cbf64ecae70abc15e1764436f4de5f3d2ea3e8fa94a3c9eaab6d19f2895e7560d6adef10ccbd84e318f26d0f7e3f3cddb22fc402b101ea375da7b7a0a1f6001a689b2cc158404000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109ed998598701d9985a8a010000f65820b2e94d1767f8ec99ff3f073cc7080b3f40927096d52a6c682c358b7cdf97340d401a689b2cc2f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820587d2234bac646ec32724ed4232474c99b70b687fedce05f4cb8f69c21e9f0cfa1644e4f4445584181ed247eacd493289128add97f483614aa089f3661626760c69c4c5fbff6cb99668f536e5090b262e624996d3578d88c0bac0eb2e92949b5c879658fb76837830183d998858401d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f958200c79b824019d0a8163b3ceabe003741d72a2188e30ea3cc3c46aec86b4525bd5f61a689b2cc2d998798501d998778501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558200c1e4483868eef220130a3b43fa6edf640c1636b9b8aafcfa33f2fce23912c745820656a9c90b2aa4993c93f7d65e7c1913a5f4661fd068e5bd552fb434db732dc4e5841c73b2008817d9606a2bd27660bbb5b62b4471eaeb563885c14546e0c6454fae243e2463af227d241660152df50717fb868796e571204c501ebf62ad4476df59b001a689b2cc258606000000000000000000000000000000000000000000000000000000000000000ea9371f0443f9db4df116ea2349e1f27a3dc2f98a8cf42a03da453272fbb109e0013ee5d7ec45a24a4589a7e3ef57aae0837eeab02c803cb31adb49d0a9bcad4d998598701d9985a8a010000f658202b9d3887515b6e29290dd9c684ec5e77cf46eda2f4457cc4f950d3643e95fca9401a689b2cc3f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820a2e7854d02d9bfc0a5030b69d43b8e411354be456d7f08199749fee4177a867fa1644e4f4445584178ea87097180813c71e1c8fe64ab9d4d52e17ebb66b9d84a2d051affb0e89ddd7b247ed601670b013c141c0ee1afbdc5dd895a0e9b4c72ab321c49fd849ec6f301" + "aliceToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e0180", + "bobToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e018183d998858501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820d0d6fd22ec2e71b73da98907ca75265210b7076995f9189aed71d7cccc2f029ff61a6a86cb691a6a86bd59d998798501d998778601d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582025acb1226c130ec4e6ee0d7b69db6b0354a666903b2da5b275394428c07629555820ceda6ca3b0461210d7f3decac6d659bb215c6240c95e5511de93dae591d611541a6a86cb695841b3cf0539d9aab3ce8dec9b31bc73585f1aa051baed5339ecdef49cfbd1cab12d616e789fe118db1ca97996accbb3066674641bfe46fb92deeee23e78f02a8c67001a6a86bd5958408000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694d998598701d9985a8a010000f65820344deba700c59b51ff2212fbe1a008f891a710482e4b41645d36b3d31ccafadb401a6a86bd5af600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820745e873f8fbb9b8a71bd0887309989507aa9b18c6528a52068e96e56d2b63b82a1644e4f444558412ae4b7aac986528674495e30ef80687b90714bef8a011da8731b9cd3cc834638772d50905f1334a7368dbc0d58c2cf1353607a2bc0ce64daded01c656b13624c01", + "carolToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e018283d998858501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820d0d6fd22ec2e71b73da98907ca75265210b7076995f9189aed71d7cccc2f029ff61a6a86cb691a6a86bd59d998798501d998778601d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582025acb1226c130ec4e6ee0d7b69db6b0354a666903b2da5b275394428c07629555820ceda6ca3b0461210d7f3decac6d659bb215c6240c95e5511de93dae591d611541a6a86cb695841b3cf0539d9aab3ce8dec9b31bc73585f1aa051baed5339ecdef49cfbd1cab12d616e789fe118db1ca97996accbb3066674641bfe46fb92deeee23e78f02a8c67001a6a86bd5958408000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694d998598701d9985a8a010000f65820344deba700c59b51ff2212fbe1a008f891a710482e4b41645d36b3d31ccafadb401a6a86bd5af600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820745e873f8fbb9b8a71bd0887309989507aa9b18c6528a52068e96e56d2b63b82a1644e4f444558412ae4b7aac986528674495e30ef80687b90714bef8a011da8731b9cd3cc834638772d50905f1334a7368dbc0d58c2cf1353607a2bc0ce64daded01c656b13624c0183d998858501d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f95820c14b2cb9f3356d73e3b8534dfac05c58663747c1010a2e14cbaf04b21c259540f61a6a86cb691a6a86bd5ad998798501d998778601d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558204e748eadad84eccacd0d2a7c7817d958456dffb9d18253f40bbf8050f034e0b6582054a7640805e947c24434740c7bcc21815cc980ff62d369f0335f10f829534df51a6a86cb695841defaf8406edae96fca6b6c36c60674b3d9b774bb2c1a6961578b40ff04599edc332a2d29a9415bbdf413a1690ab4dac0345fe653b1355aec6b380cf33158e5d7001a6a86bd5a5860c000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f6946b2e0a1b835feda5ab9079463948d6c1f3f90ee02a734b8b73e233706967963dd998598701d9985a8a010000f65820dcbc1cb2095a0e56b5941ba9ff4be3ae0ef778b9a34d192f15eaf45cf1b546ff401a6a86bd5bf600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658204dcafc2258a683ce8d5bcd98534f048b5b3c5362cfc6e41692f3c1715a354e21a1644e4f44455841cee4fb896133fa5d6fe938e6cf9d8c2fd5d5a22eba2503ebd668a9921ab69d6076ddfc69828ee66ff537d58075aa4beac757f44a5b7fe0a5063090aa5d57454d00" } From 33729fc57d3646f5da3d8c5e785caf07b021cf9d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 13:26:14 +0300 Subject: [PATCH 03/12] Fix timeout call sites in Rust SDK examples --- e2e/src/main.rs | 8 +++++++- examples/mint.rs | 11 ++++++++++- examples/split.rs | 19 +++++++++++++++++-- examples/transfer.rs | 12 +++++++++++- tests/e2e.rs | 7 +++++++ tests/http_transport.rs | 1 + 6 files changed, 53 insertions(+), 5 deletions(-) diff --git a/e2e/src/main.rs b/e2e/src/main.rs index c42e0a3..8788b5b 100644 --- a/e2e/src/main.rs +++ b/e2e/src/main.rs @@ -2,7 +2,7 @@ use std::env; use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use unicity_token::api::bft::RootTrustBase; use unicity_token::cbor::encode_text_string; @@ -16,6 +16,10 @@ const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; const DEFAULT_OUTPUT_DIR: &str = "artifacts"; +fn request_timeout() -> Result> { + Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3600) +} + fn main() -> Result<(), Box> { // Load local development configuration without overriding variables that // were explicitly supplied by the process environment. @@ -52,6 +56,7 @@ fn main() -> Result<(), Box> { &trust_base, trust_base.network_id, &alice_lock, + request_timeout()?, TokenType::random()?, TokenSalt::random()?, Some(encode_text_string("Rust SDK live e2e mint")), @@ -71,6 +76,7 @@ fn main() -> Result<(), Box> { &minted, &bob_lock, &alice, + request_timeout()?, StateMask::random()?, Some(encode_text_string("Rust SDK live e2e transfer")), )?; diff --git a/examples/mint.rs b/examples/mint.rs index ab8ae82..33e2c16 100644 --- a/examples/mint.rs +++ b/examples/mint.rs @@ -9,7 +9,7 @@ //! cargo run --example mint --features http use std::path::Path; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use unicity_token::api::bft::RootTrustBase; use unicity_token::cbor::encode_text_string; @@ -21,6 +21,14 @@ use unicity_token::transaction::ids::{TokenSalt, TokenType}; const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; +fn request_timeout() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs() + + 3600 +} + /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { // Load e2e/.env (values already in the process environment win). @@ -64,6 +72,7 @@ fn main() { &trust_base, trust_base.network_id, &owner_predicate, + request_timeout(), TokenType::random().expect("token type"), TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), diff --git a/examples/split.rs b/examples/split.rs index 82de66f..fe5b57a 100644 --- a/examples/split.rs +++ b/examples/split.rs @@ -16,7 +16,7 @@ //! [`payment::verify_payment_token`]: unicity_token::payment::verify_payment_token use std::path::Path; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use num_bigint::BigUint; @@ -42,6 +42,14 @@ const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; /// through `client::transfer` and reproduced byte-for-byte. const BURN_STATE_MASK: [u8; 32] = [0x42; 32]; +fn request_timeout() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs() + + 3600 +} + /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { dotenvy::from_path("e2e/.env").ok(); @@ -98,6 +106,7 @@ fn mint_split_output( let transaction = MintTransaction::create( out.network_id, out.recipient.clone(), + request_timeout(), out.token_type.clone(), out.salt.clone(), Some(out.assets.to_cbor()), @@ -116,9 +125,12 @@ fn mint_split_output( let proof = aggregator .get_inclusion_proof(&state_id) .expect("split output inclusion proof"); + let reference_time = proof + .reference_time + .expect("split output proof reference time"); let token = Token::new( - CertifiedMintTransaction::new(transaction, proof), + CertifiedMintTransaction::new(transaction, reference_time, proof), Vec::new(), ); @@ -173,6 +185,7 @@ fn main() { &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), + request_timeout(), coin_type.clone(), TokenSalt::random().expect("salt"), Some(source_payment.to_cbor()), @@ -212,6 +225,7 @@ fn main() { ®istry, PaymentAssetCollection::from_cbor_bytes, requests, + request_timeout(), Some(BURN_STATE_MASK), ) .expect("build split"); @@ -225,6 +239,7 @@ fn main() { &source, &split.burn.owner_predicate, &alice, + request_timeout(), StateMask::from_bytes(BURN_STATE_MASK), Some(split.burn.manifest.clone()), ) diff --git a/examples/transfer.rs b/examples/transfer.rs index fa68229..30efa48 100644 --- a/examples/transfer.rs +++ b/examples/transfer.rs @@ -9,7 +9,7 @@ //! cargo run --example transfer --features http use std::path::Path; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use unicity_token::api::bft::RootTrustBase; use unicity_token::cbor::encode_text_string; @@ -22,6 +22,14 @@ use unicity_token::transaction::Token; const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; +fn request_timeout() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs() + + 3600 +} + /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { dotenvy::from_path("e2e/.env").ok(); @@ -60,6 +68,7 @@ fn main() { &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), + request_timeout(), TokenType::random().expect("token type"), TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), @@ -80,6 +89,7 @@ fn main() { &token, &SignaturePredicate::new(bob.public_key()), &alice, + request_timeout(), StateMask::random().expect("state mask"), Some(encode_text_string("My custom transfer data")), ) diff --git a/tests/e2e.rs b/tests/e2e.rs index 46eb336..60d2302 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -37,6 +37,11 @@ fn read_service() -> (String, Option) { #[test] #[ignore = "hits the live testnet2 gateway"] fn e2e_mint_transfer_verify() { + let timeout = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before Unix epoch") + .as_secs() + + 3600; let (gateway, api_key) = read_service(); let trust_json = std::fs::read_to_string("e2e/bft-trustbase.testnet2.json").expect("trust base file"); @@ -56,6 +61,7 @@ fn e2e_mint_transfer_verify() { &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), + timeout, TokenType::random().unwrap(), TokenSalt::random().unwrap(), None, @@ -70,6 +76,7 @@ fn e2e_mint_transfer_verify() { &token, &SignaturePredicate::new(bob.public_key()), &alice, + timeout, StateMask::random().unwrap(), None, ) diff --git a/tests/http_transport.rs b/tests/http_transport.rs index 9843b34..5948dd2 100644 --- a/tests/http_transport.rs +++ b/tests/http_transport.rs @@ -330,6 +330,7 @@ fn get_inclusion_proof_returns_complete_proof() { fn get_inclusion_proof_rejects_incomplete_response_without_polling() { let (proof, data) = fixture_proof_and_data(); let incomplete = InclusionProof { + reference_time: proof.reference_time, certification_data: None, inclusion_certificate: None, unicity_certificate: proof.unicity_certificate.clone(), From 2ddb52f731f1c39f663eae48126dde8b22184e89 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 13:42:35 +0300 Subject: [PATCH 04/12] Document service time and cover timeout boundaries --- README.md | 13 +++++++++++-- e2e/README.md | 3 ++- examples/README.md | 3 ++- src/verify/mod.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74641e6..72d7d55 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ token.verify(&trust_base)?; // verifies the cryptographic histo ## Mint & transfer against a live aggregator (`http` feature) ```rust -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use unicity_token::api::bft::RootTrustBase; use unicity_token::client::{self, HttpAggregatorClient}; @@ -49,11 +49,20 @@ let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.j let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.network/") .with_api_key("sk_…") .with_polling(Duration::from_secs(2), 90); +let timeout = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3600; let token = client::mint(&aggregator, &trust_base, trust_base.network_id, - &recipient, token_type, salt, /* data */ None, /* justification */ None)?; + &recipient, timeout, token_type, salt, /* data */ None, /* justification */ None)?; ``` +The timeout is exclusive and expressed in Unix seconds: the service admits the request only when +the round reference time is strictly below it. It is part of the transaction encoding, so the +transaction hash commits to it. + +The returned certified transaction fixes the reference time at which its leaf was created. The +leaf value is `SHA-256(CBOR([transactionHash, referenceTime]))`; verification keeps using that +carried value even if the proof is later refreshed against a newer append-only tree root. + 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. diff --git a/e2e/README.md b/e2e/README.md index 1fa969d..28b32f8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -23,7 +23,8 @@ cargo run --release The program loads `.env` with `dotenvy`. Values already present in the process environment take precedence, which keeps it suitable for CI and deployed environments. `.env` is git-ignored and the credential is never written to the -generated token files. +generated token files. Mint and transfer requests use exclusive timeouts one +hour ahead of the current Unix time. Defaults: diff --git a/examples/README.md b/examples/README.md index 4fec293..d98e3e5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,7 +28,8 @@ UNICITY_TRUSTBASE=bft-trustbase.testnet2.json Values already present in the process environment take precedence. The examples require the `http` feature (a blocking TLS HTTP stack); they generate ephemeral -in-memory wallets and never persist keys. +in-memory wallets and never persist keys. Each submitted transaction uses an +exclusive request timeout one hour ahead of the current Unix time. For a fuller standalone demo (mint → save → reload → transfer → verify), see the `e2e/` crate. diff --git a/src/verify/mod.rs b/src/verify/mod.rs index bdd03e0..89aea13 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -768,6 +768,32 @@ mod tests { ); } + #[test] + fn rule_certification_data_mismatch_timeout() { + let (tb, _n, _o, transfer, mut proof) = transfer_case(); + let c = cert(&proof); + proof.certification_data = Some(CertificationData::new( + c.lock_script().clone(), + c.source_state_hash().clone(), + c.transaction_hash().clone(), + TIMEOUT + 1, + c.unlock_script().to_vec(), + )); + assert_eq!( + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), + Err(VerificationError::CertificationDataMismatch) + ); + } + + #[test] + fn rule_request_expires_at_timeout_boundary() { + let (tb, _n, _o, transfer, proof) = transfer_case(); + assert_eq!( + verify_inclusion_proof(&tb, &proof, &transfer, TIMEOUT), + Err(VerificationError::RequestExpired) + ); + } + #[test] fn rule_transaction_hash_mismatch() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); From e4d55ec66067757265a4aefbaf73e59586b8a26d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 17:14:05 +0300 Subject: [PATCH 05/12] Make the request timeout optional Wire profiles, distinguished by the version field: MintTransaction v1 [1, networkId, recipient, salt, tokenType, justification, data] v2 [2, ..., tau_Q] TransferTransaction v1 [1, recipient, stateMask, data] v2 [2, ..., tau_Q] CertificationData v1 [1, lockScript, sourceStateHash, transactionHash, witness] v2 [2, ..., tau_Q, witness] Refs #17 --- README.md | 16 +-- examples/README.md | 2 +- examples/mint.rs | 11 +- examples/split.rs | 20 +--- examples/transfer.rs | 12 +-- src/api/certification.rs | 117 +++++++++++++++++---- src/api/certification_request.rs | 4 +- src/client/mod.rs | 161 ++++++++++++++++++++++------- src/payment/split.rs | 66 +++++++++--- src/payment/tests.rs | 50 ++++----- src/transaction/certified.rs | 40 ++++--- src/transaction/mint.rs | 100 ++++++++++++------ src/transaction/mod.rs | 2 +- src/transaction/transfer.rs | 74 ++++++++----- src/verify/error.rs | 5 + src/verify/mod.rs | 80 ++++++-------- tests/e2e.rs | 4 +- tests/transition_flow.rs | 73 +++++++++++-- tests/vectors/transition_flow.json | 10 +- 19 files changed, 560 insertions(+), 287 deletions(-) diff --git a/README.md b/README.md index 72d7d55..20fa8e3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ token.verify(&trust_base)?; // verifies the cryptographic histo ## Mint & transfer against a live aggregator (`http` feature) ```rust -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use unicity_token::api::bft::RootTrustBase; use unicity_token::client::{self, HttpAggregatorClient}; @@ -49,19 +49,13 @@ let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.j let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.network/") .with_api_key("sk_…") .with_polling(Duration::from_secs(2), 90); -let timeout = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3600; - let token = client::mint(&aggregator, &trust_base, trust_base.network_id, - &recipient, timeout, token_type, salt, /* data */ None, /* justification */ None)?; + &recipient, token_type, salt, /* data */ None, /* justification */ None)?; ``` -The timeout is exclusive and expressed in Unix seconds: the service admits the request only when -the round reference time is strictly below it. It is part of the transaction encoding, so the -transaction hash commits to it. - -The returned certified transaction fixes the reference time at which its leaf was created. The -leaf value is `SHA-256(CBOR([transactionHash, referenceTime]))`; verification keeps using that -carried value even if the proof is later refreshed against a newer append-only tree root. +Use `mint_with_timeout`, `transfer_with_timeout`, or the transaction-level +`create_with_timeout`/`new_with_timeout` methods when an application needs an explicit Unix-seconds +deadline. 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 diff --git a/examples/README.md b/examples/README.md index d98e3e5..82100dc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ UNICITY_TRUSTBASE=bft-trustbase.testnet2.json Values already present in the process environment take precedence. The examples require the `http` feature (a blocking TLS HTTP stack); they generate ephemeral in-memory wallets and never persist keys. Each submitted transaction uses an -exclusive request timeout one hour ahead of the current Unix time. +service-assigned timeout derived from consensus time, so the examples do not require a valid system clock. For a fuller standalone demo (mint → save → reload → transfer → verify), see the `e2e/` crate. diff --git a/examples/mint.rs b/examples/mint.rs index 33e2c16..ab8ae82 100644 --- a/examples/mint.rs +++ b/examples/mint.rs @@ -9,7 +9,7 @@ //! cargo run --example mint --features http use std::path::Path; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use unicity_token::api::bft::RootTrustBase; use unicity_token::cbor::encode_text_string; @@ -21,14 +21,6 @@ use unicity_token::transaction::ids::{TokenSalt, TokenType}; const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; -fn request_timeout() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before Unix epoch") - .as_secs() - + 3600 -} - /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { // Load e2e/.env (values already in the process environment win). @@ -72,7 +64,6 @@ fn main() { &trust_base, trust_base.network_id, &owner_predicate, - request_timeout(), TokenType::random().expect("token type"), TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), diff --git a/examples/split.rs b/examples/split.rs index fe5b57a..8729df3 100644 --- a/examples/split.rs +++ b/examples/split.rs @@ -16,7 +16,7 @@ //! [`payment::verify_payment_token`]: unicity_token::payment::verify_payment_token use std::path::Path; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use num_bigint::BigUint; @@ -42,14 +42,6 @@ const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; /// through `client::transfer` and reproduced byte-for-byte. const BURN_STATE_MASK: [u8; 32] = [0x42; 32]; -fn request_timeout() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before Unix epoch") - .as_secs() - + 3600 -} - /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { dotenvy::from_path("e2e/.env").ok(); @@ -106,7 +98,6 @@ fn mint_split_output( let transaction = MintTransaction::create( out.network_id, out.recipient.clone(), - request_timeout(), out.token_type.clone(), out.salt.clone(), Some(out.assets.to_cbor()), @@ -125,12 +116,8 @@ fn mint_split_output( let proof = aggregator .get_inclusion_proof(&state_id) .expect("split output inclusion proof"); - let reference_time = proof - .reference_time - .expect("split output proof reference time"); - let token = Token::new( - CertifiedMintTransaction::new(transaction, reference_time, proof), + CertifiedMintTransaction::new(transaction, proof), Vec::new(), ); @@ -185,7 +172,6 @@ fn main() { &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), - request_timeout(), coin_type.clone(), TokenSalt::random().expect("salt"), Some(source_payment.to_cbor()), @@ -225,7 +211,6 @@ fn main() { ®istry, PaymentAssetCollection::from_cbor_bytes, requests, - request_timeout(), Some(BURN_STATE_MASK), ) .expect("build split"); @@ -239,7 +224,6 @@ fn main() { &source, &split.burn.owner_predicate, &alice, - request_timeout(), StateMask::from_bytes(BURN_STATE_MASK), Some(split.burn.manifest.clone()), ) diff --git a/examples/transfer.rs b/examples/transfer.rs index 30efa48..fa68229 100644 --- a/examples/transfer.rs +++ b/examples/transfer.rs @@ -9,7 +9,7 @@ //! cargo run --example transfer --features http use std::path::Path; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use unicity_token::api::bft::RootTrustBase; use unicity_token::cbor::encode_text_string; @@ -22,14 +22,6 @@ use unicity_token::transaction::Token; const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; -fn request_timeout() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before Unix epoch") - .as_secs() - + 3600 -} - /// Build an aggregator client and its trust base from `e2e/.env`. fn load() -> (HttpAggregatorClient, RootTrustBase) { dotenvy::from_path("e2e/.env").ok(); @@ -68,7 +60,6 @@ fn main() { &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), - request_timeout(), TokenType::random().expect("token type"), TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), @@ -89,7 +80,6 @@ fn main() { &token, &SignaturePredicate::new(bob.public_key()), &alice, - request_timeout(), StateMask::random().expect("state mask"), Some(encode_text_string("My custom transfer data")), ) diff --git a/src/api/certification.rs b/src/api/certification.rs index 30a8094..056e2dc 100644 --- a/src/api/certification.rs +++ b/src/api/certification.rs @@ -12,7 +12,8 @@ use crate::transaction::Transaction; /// CBOR tag for [`CertificationData`]. pub const CERTIFICATION_DATA_TAG: u64 = 39031; -const VERSION: u64 = 1; +const LEGACY_VERSION: u64 = 1; +const TIMEOUT_VERSION: u64 = 2; /// What the aggregator certified for one state transition. /// @@ -24,7 +25,7 @@ pub struct CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, - timeout: u64, + timeout: Option, unlock_script: Vec, } @@ -34,18 +35,35 @@ impl CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, - timeout: u64, unlock_script: Vec, ) -> Self { CertificationData { lock_script, source_state_hash, transaction_hash, - timeout, + timeout: None, unlock_script, } } + /// Construct certification data with an explicit exclusive request timeout. + pub fn new_with_timeout( + lock_script: EncodedPredicate, + source_state_hash: DataHash, + transaction_hash: DataHash, + timeout: u64, + unlock_script: Vec, + ) -> Self { + let mut data = Self::new( + lock_script, + source_state_hash, + transaction_hash, + unlock_script, + ); + data.timeout = Some(timeout); + data + } + /// Build from a transaction and an unlock script, computing the /// transaction hash. pub fn from_transaction(transaction: &impl Transaction, unlock_script: Vec) -> Self { @@ -71,7 +89,7 @@ impl CertificationData { &self.transaction_hash } /// The exclusive timeout of the certification request. - pub fn timeout(&self) -> u64 { + pub fn timeout(&self) -> Option { self.timeout } /// The unlock script (witness). @@ -81,25 +99,36 @@ impl CertificationData { /// Encode to CBOR (tagged). Hashes are encoded as their raw 32-byte data. pub fn to_cbor(&self) -> Vec { - encode_tag( - CERTIFICATION_DATA_TAG, - &encode_array(&[ - &encode_uint(VERSION), + let payload = if let Some(timeout) = self.timeout { + encode_array(&[ + &encode_uint(TIMEOUT_VERSION), &self.lock_script.to_cbor(), &encode_byte_string(self.source_state_hash.data()), &encode_byte_string(self.transaction_hash.data()), - &encode_uint(self.timeout), + &encode_uint(timeout), &encode_byte_string(&self.unlock_script), - ]), - ) + ]) + } else { + encode_array(&[ + &encode_uint(LEGACY_VERSION), + &self.lock_script.to_cbor(), + &encode_byte_string(self.source_state_hash.data()), + &encode_byte_string(self.transaction_hash.data()), + &encode_byte_string(&self.unlock_script), + ]) + }; + encode_tag(CERTIFICATION_DATA_TAG, &payload) } /// Decode from CBOR. The reference SDKs always store SHA-256 hashes here. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(CERTIFICATION_DATA_TAG)?; - let items = inner.array(Some(6))?; + let items = inner.array(None)?; let version = items[0].uint()?; - if version != VERSION { + let has_timeout = version == TIMEOUT_VERSION; + if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || items.len() != if has_timeout { 6 } else { 5 } + { return Err(Error::UnexpectedValue( "unsupported CertificationData version", )); @@ -108,8 +137,14 @@ impl CertificationData { lock_script: EncodedPredicate::from_cbor(items[1])?, source_state_hash: DataHash::new(HashAlgorithm::Sha256, items[2].bytes_value()?)?, transaction_hash: DataHash::new(HashAlgorithm::Sha256, items[3].bytes_value()?)?, - timeout: items[4].uint()?, - unlock_script: items[5].bytes_value()?.to_vec(), + timeout: if has_timeout { + Some(items[4].uint()?) + } else { + None + }, + unlock_script: items[if has_timeout { 5 } else { 4 }] + .bytes_value()? + .to_vec(), }) } } @@ -141,7 +176,7 @@ mod tests { ) .to_encoded(); - let mint = MintTransaction::create( + let mint = MintTransaction::create_with_timeout( NetworkId::MAINNET, recipient, TIMEOUT, @@ -161,7 +196,7 @@ mod tests { assert_eq!( cert.to_cbor(), hex!( - "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" ) ); @@ -172,4 +207,50 @@ mod tests { cert ); } + + #[test] + fn legacy_creation_preserves_v1_bytes_without_a_clock() { + let recipient = SignaturePredicate::new( + PublicKey::from_bytes(&hex!( + "02ce9f22e51333c97a8fb1f807a229ece3a8765a16af5fc1a13e30834be3280026" + )) + .unwrap(), + ) + .to_encoded(); + let mint = MintTransaction::create( + NetworkId::MAINNET, + recipient, + TokenType::new([0u8; 32]), + TokenSalt::from_bytes([0u8; 32]), + None, + None, + ) + .unwrap(); + let signer = Minter::signer(mint.token_id()).unwrap(); + let tx_hash = mint.calculate_transaction_hash(); + let unlock = sign_signature_unlock(&signer, mint.source_state_hash(), &tx_hash); + let cert = CertificationData::from_transaction(&mint, unlock); + + assert_eq!(mint.timeout(), None); + assert_eq!(cert.timeout(), None); + assert_eq!(cert.to_cbor(), hex!( + "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + )); + } + + /// Each version pairs with exactly one field count. A version 1 array with + /// a timeout appended promises bytes the transaction hash does not commit + /// to, so it is not a payload this decoder recognises. + #[test] + fn rejects_a_version_that_does_not_match_the_field_count() { + let mut mismatched = hex!( + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" + ) + .to_vec(); + assert!(CertificationData::from_cbor(Decoder::new(&mismatched)).is_ok()); + + mismatched[4] = 1; + + assert!(CertificationData::from_cbor(Decoder::new(&mismatched)).is_err()); + } } diff --git a/src/api/certification_request.rs b/src/api/certification_request.rs index 128052d..ebc67c8 100644 --- a/src/api/certification_request.rs +++ b/src/api/certification_request.rs @@ -55,7 +55,7 @@ mod tests { #[test] fn certification_request_golden_vector() { let certification_data = hex!( - "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" ); let data = CertificationData::from_cbor(Decoder::new(&certification_data)).unwrap(); let request = CertificationRequest::new(&data); @@ -63,7 +63,7 @@ mod tests { assert_eq!( request.to_cbor(), hex!( - "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f880000" + "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e0000" ) ); } diff --git a/src/client/mod.rs b/src/client/mod.rs index 2793f2d..a034dda 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -119,6 +119,31 @@ pub fn certification_data_for( /// Mint a new token to `recipient` and return the verified [`Token`]. #[allow(clippy::too_many_arguments)] pub fn mint( + aggregator: &A, + trust_base: &RootTrustBase, + network: NetworkId, + recipient: &impl Predicate, + token_type: TokenType, + salt: TokenSalt, + data: Option>, + justification: Option>, +) -> Result> { + mint_impl( + aggregator, + trust_base, + network, + recipient, + None, + token_type, + salt, + data, + justification, + ) +} + +/// Mint a new token with an explicit exclusive certification timeout. +#[allow(clippy::too_many_arguments)] +pub fn mint_with_timeout( aggregator: &A, trust_base: &RootTrustBase, network: NetworkId, @@ -128,6 +153,31 @@ pub fn mint( salt: TokenSalt, data: Option>, justification: Option>, +) -> Result> { + mint_impl( + aggregator, + trust_base, + network, + recipient, + Some(timeout), + token_type, + salt, + data, + justification, + ) +} + +#[allow(clippy::too_many_arguments)] +fn mint_impl( + aggregator: &A, + trust_base: &RootTrustBase, + network: NetworkId, + recipient: &impl Predicate, + timeout: Option, + token_type: TokenType, + salt: TokenSalt, + data: Option>, + justification: Option>, ) -> Result> { trust_base .validate() @@ -135,15 +185,20 @@ pub fn mint( if network != trust_base.network_id { return Err(VerificationError::NetworkMismatch.into()); } - let transaction = MintTransaction::create( - network, - EncodedPredicate::from_predicate(recipient), - timeout, - token_type, - salt, - data, - justification, - )?; + let recipient = EncodedPredicate::from_predicate(recipient); + let transaction = if let Some(timeout) = timeout { + MintTransaction::create_with_timeout( + network, + recipient, + timeout, + token_type, + salt, + data, + justification, + )? + } else { + MintTransaction::create(network, recipient, token_type, salt, data, justification)? + }; // The genesis is unlocked by the deterministic minter key for the token id. let signer = Minter::signer(transaction.token_id())?; @@ -156,14 +211,8 @@ pub fn mint( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; - // Fix the reference time now, from the proof that first establishes the - // leaf; a proof fetched later is issued against a later root. - let reference_time = proof - .reference_time - .ok_or(ClientError::Verification(VerificationError::PathInvalid))?; - let token = Token::new( - CertifiedMintTransaction::new(transaction, reference_time, proof), + CertifiedMintTransaction::new(transaction, proof), Vec::new(), ); token.verify(trust_base)?; @@ -174,6 +223,22 @@ pub fn mint( /// owner's key), and return the verified successor [`Token`]. #[allow(clippy::too_many_arguments)] pub fn transfer( + aggregator: &A, + trust_base: &RootTrustBase, + token: &Token, + recipient: &impl Predicate, + signer: &impl Signer, + state_mask: StateMask, + data: Option>, +) -> Result> { + transfer_impl( + aggregator, trust_base, token, recipient, signer, None, state_mask, data, + ) +} + +/// Transfer a token with an explicit exclusive certification timeout. +#[allow(clippy::too_many_arguments)] +pub fn transfer_with_timeout( aggregator: &A, trust_base: &RootTrustBase, token: &Token, @@ -182,19 +247,53 @@ pub fn transfer( timeout: u64, state_mask: StateMask, data: Option>, +) -> Result> { + transfer_impl( + aggregator, + trust_base, + token, + recipient, + signer, + Some(timeout), + state_mask, + data, + ) +} + +#[allow(clippy::too_many_arguments)] +fn transfer_impl( + aggregator: &A, + trust_base: &RootTrustBase, + token: &Token, + recipient: &impl Predicate, + signer: &impl Signer, + timeout: Option, + state_mask: StateMask, + data: Option>, ) -> Result> { // Reject an untrusted or stale input before causing any aggregator side // effect. The successor is verified again below as defense in depth. token.verify(trust_base)?; let (source_state_hash, lock_script) = token.latest_state(); - let transaction = TransferTransaction::new( - source_state_hash, - lock_script, - EncodedPredicate::from_predicate(recipient), - timeout, - state_mask.bytes().to_vec(), - data, - ); + let recipient = EncodedPredicate::from_predicate(recipient); + let transaction = if let Some(timeout) = timeout { + TransferTransaction::new_with_timeout( + source_state_hash, + lock_script, + recipient, + timeout, + state_mask.bytes().to_vec(), + data, + ) + } else { + TransferTransaction::new( + source_state_hash, + lock_script, + recipient, + state_mask.bytes().to_vec(), + data, + ) + }; let certification_data = certification_data_for(&transaction, signer); aggregator @@ -205,16 +304,8 @@ pub fn transfer( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; - let reference_time = proof - .reference_time - .ok_or(ClientError::Verification(VerificationError::PathInvalid))?; - let mut transactions = token.transactions().to_vec(); - transactions.push(CertifiedTransferTransaction::new( - transaction, - reference_time, - proof, - )); + transactions.push(CertifiedTransferTransaction::new(transaction, proof)); let next = Token::new(token.genesis().clone(), transactions); next.verify(trust_base)?; Ok(next) @@ -281,7 +372,7 @@ mod tests { ); // Fetching the proof fails in the mock, so the flow stops there. - let err = mint( + let err = mint_with_timeout( &agg, &trust_base, NetworkId::MAINNET, @@ -299,7 +390,7 @@ mod tests { assert_eq!( captured, hex!( - "d998778601d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e615241582068a39b55a025f3fc4ff80be2ee8231dbe02afe151279b19fc457d39a6281720b1a689b2cc05841ded0fa3fa2773d2e52d4db8918f883e50be7cdcd351b16bbded03bb2c54f80c130cb08befdfe0f6c78c2e925645f3804953ad41d6f043e9ab8aa81740cbd8f8800" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" ) ); } diff --git a/src/payment/split.rs b/src/payment/split.rs index 0b2a95e..31a583d 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -135,14 +135,27 @@ impl TokenSplit { /// the source is itself a split output). /// /// `decode_payment_data` extracts the source token's [`PaymentAssetCollection`] - /// from its mint `data`. `burn_timeout` is the burn transfer's exclusive - /// certification request timeout. `burn_state_mask` sets the burn transfer's state + /// 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, + trust_base: &RootTrustBase, + registry: &MintJustificationRegistry, + decode_payment_data: PaymentDataDecoder, + requests: Vec, + burn_state_mask: Option<[u8; 32]>, + ) -> Result { + let assets = verify_payment_token(token, trust_base, registry, decode_payment_data) + .map_err(SplitError::Verification)?; + Self::build_split(token, assets, requests, None, burn_state_mask).map_err(SplitError::Build) + } + + /// Split a token using an explicit timeout for the burn transaction. + pub fn split_with_timeout( token: &Token, trust_base: &RootTrustBase, registry: &MintJustificationRegistry, @@ -153,7 +166,7 @@ impl TokenSplit { ) -> Result { let assets = verify_payment_token(token, trust_base, registry, decode_payment_data) .map_err(SplitError::Verification)?; - Self::build_split(token, assets, requests, burn_timeout, burn_state_mask) + Self::build_split(token, assets, requests, Some(burn_timeout), burn_state_mask) .map_err(SplitError::Build) } @@ -166,6 +179,22 @@ impl TokenSplit { /// 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, None, burn_state_mask) + } + + /// Build a split without source verification and with an explicit burn timeout. + pub fn split_unchecked_with_timeout( token: &Token, decode_payment_data: PaymentDataDecoder, requests: Vec, @@ -178,7 +207,7 @@ impl TokenSplit { .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_timeout, burn_state_mask) + Self::build_split(token, assets, requests, Some(burn_timeout), burn_state_mask) } /// Construct the split from the source token's already-decoded canonical @@ -188,7 +217,7 @@ impl TokenSplit { token: &Token, assets: PaymentAssetCollection, requests: Vec, - burn_timeout: u64, + burn_timeout: Option, burn_state_mask: Option<[u8; 32]>, ) -> Result { let network_id = token.genesis().transaction().network_id(); @@ -258,14 +287,25 @@ impl TokenSplit { None => random_mask()?, }; let (source_state_hash, lock_script) = token.latest_state(); - let burn_transaction = TransferTransaction::new( - source_state_hash, - lock_script, - burn_predicate.to_encoded(), - burn_timeout, - mask.to_vec(), - Some(manifest_bytes.clone()), - ); + let recipient = burn_predicate.to_encoded(); + let burn_transaction = if let Some(timeout) = burn_timeout { + TransferTransaction::new_with_timeout( + source_state_hash, + lock_script, + recipient, + timeout, + mask.to_vec(), + Some(manifest_bytes.clone()), + ) + } else { + TransferTransaction::new( + source_state_hash, + lock_script, + recipient, + mask.to_vec(), + Some(manifest_bytes.clone()), + ) + }; // Build each output with its per-asset proofs (canonical output order). let mut tokens = Vec::new(); diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 4979eeb..3ab1813 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -134,11 +134,11 @@ fn valid_proof( let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); - let certification_data = CertificationData::new( + let certification_data = CertificationData::new_with_timeout( transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, - transaction.timeout(), + transaction.timeout().expect("explicit timeout fixture"), unlock, ); InclusionProof { @@ -171,7 +171,7 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { Asset::new(asset_b(), BigUint::from(50u32)), ]) .unwrap(); - let mint = MintTransaction::create( + let mint = MintTransaction::create_with_timeout( NetworkId::LOCAL, sig_pred(owner), TIMEOUT, @@ -183,10 +183,7 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); let proof = valid_proof(&mint, &minter, node); - Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ) + Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()) } /// Wrap a burn transfer into a certified, burned source token. @@ -199,11 +196,7 @@ fn burned_token( let proof = valid_proof(&burn_tx, owner, node); Token::new( source.genesis().clone(), - vec![CertifiedTransferTransaction::new( - burn_tx, - REFERENCE_TIME, - proof, - )], + vec![CertifiedTransferTransaction::new(burn_tx, proof)], ) } @@ -217,7 +210,7 @@ fn mint_output( justification: &SplitMintJustification, node: &Secp256k1Signer, ) -> Token { - let mint = MintTransaction::create( + let mint = MintTransaction::create_with_timeout( network, recipient, TIMEOUT, @@ -229,10 +222,7 @@ fn mint_output( .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); let proof = valid_proof(&mint, &minter, node); - Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ) + Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()) } fn registry() -> MintJustificationRegistry { @@ -336,7 +326,7 @@ fn forged_output_with_type( 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( + let burn = TransferTransaction::new_with_timeout( source_state_hash, lock_script, burn_predicate.to_encoded(), @@ -376,7 +366,7 @@ fn split_outputs_verify_end_to_end() { 2 ); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry, @@ -463,7 +453,7 @@ fn payment_verification_enforces_issuance_policy() { #[test] fn recursive_split_verification_honors_shared_depth_limit() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -527,7 +517,7 @@ fn rejects_asset_absent_from_burned_source() { #[test] fn rejects_tampered_output_amount() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -566,7 +556,7 @@ fn rejects_tampered_output_amount() { #[test] fn rejects_dropped_proof() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -601,7 +591,7 @@ fn rejects_dropped_proof() { #[test] fn rejects_wrong_burn_predicate() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -616,7 +606,7 @@ fn rejects_wrong_burn_predicate() { // 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( + let wrong_burn = TransferTransaction::new_with_timeout( source_state_hash, lock_script, BurnPredicate::new(b"not-the-manifest-hash".to_vec()).to_encoded(), @@ -663,7 +653,7 @@ fn rejects_output_token_type_mismatch_at_verify() { #[test] fn rejects_missing_manifest() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -675,7 +665,7 @@ fn rejects_missing_manifest() { .unwrap(); // Burn with no auxiliary manifest data at all. let (source_state_hash, lock_script) = s.source.latest_state(); - let burn = TransferTransaction::new( + let burn = TransferTransaction::new_with_timeout( source_state_hash, lock_script, BurnPredicate::new(b"x".to_vec()).to_encoded(), @@ -704,7 +694,7 @@ fn rejects_missing_manifest() { #[test] fn rejects_manifest_length_mismatch() { let s = scenario(); - let split = TokenSplit::split( + let split = TokenSplit::split_with_timeout( &s.source, &s.tb, ®istry(), @@ -718,7 +708,7 @@ fn rejects_manifest_length_mismatch() { // 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( + let burn = TransferTransaction::new_with_timeout( source_state_hash, lock_script, BurnPredicate::new(short.reason_hash().to_vec()).to_encoded(), @@ -759,7 +749,7 @@ fn rejects_wrong_output_token_type() { TokenType::new(vec![0xC9; 32]), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split_unchecked( + assert!(TokenSplit::split_unchecked_with_timeout( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, @@ -784,7 +774,7 @@ fn rejects_unbalanced_split_at_build_time() { coin_type(), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split_unchecked( + assert!(TokenSplit::split_unchecked_with_timeout( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, diff --git a/src/transaction/certified.rs b/src/transaction/certified.rs index f9f0933..5f38535 100644 --- a/src/transaction/certified.rs +++ b/src/transaction/certified.rs @@ -1,7 +1,7 @@ //! Certified transactions: a transaction bundled with its inclusion proof. //! -//! These wrap [`MintTransaction`] / [`TransferTransaction`] and are *not* tagged -//! — on the wire each is a 3-element array +//! These wrap [`MintTransaction`] / [`TransferTransaction`] and are *not* tagged. +//! On the wire each value is a 3-element array //! `[transaction, referenceTime, inclusionProof]`. //! //! The reference time is fixed when the transaction is first bound to a proof @@ -28,11 +28,8 @@ pub struct CertifiedMintTransaction { impl CertifiedMintTransaction { /// Bundle a transaction with a proof (no verification — see /// [`Token::verify`](super::token::Token::verify)). - pub fn new( - transaction: MintTransaction, - reference_time: u64, - inclusion_proof: InclusionProof, - ) -> Self { + pub fn new(transaction: MintTransaction, inclusion_proof: InclusionProof) -> Self { + let reference_time = inclusion_proof.reference_time.unwrap_or(0); CertifiedMintTransaction { transaction, reference_time, @@ -64,10 +61,17 @@ impl CertifiedMintTransaction { /// Decode from CBOR (3-element array). pub fn from_cbor(d: Decoder<'_>) -> Result { let items = d.array(Some(3))?; + let reference_time = items[1].uint()?; + let inclusion_proof = InclusionProof::from_cbor(items[2])?; + if inclusion_proof.reference_time != Some(reference_time) { + return Err(Error::UnexpectedValue( + "certified mint reference time mismatch", + )); + } Ok(CertifiedMintTransaction { transaction: MintTransaction::from_cbor(items[0])?, - reference_time: items[1].uint()?, - inclusion_proof: InclusionProof::from_cbor(items[2])?, + reference_time, + inclusion_proof, }) } @@ -91,11 +95,8 @@ pub struct CertifiedTransferTransaction { impl CertifiedTransferTransaction { /// Bundle a transaction with a proof (no verification). - pub fn new( - transaction: TransferTransaction, - reference_time: u64, - inclusion_proof: InclusionProof, - ) -> Self { + pub fn new(transaction: TransferTransaction, inclusion_proof: InclusionProof) -> Self { + let reference_time = inclusion_proof.reference_time.unwrap_or(0); CertifiedTransferTransaction { transaction, reference_time, @@ -132,10 +133,17 @@ impl CertifiedTransferTransaction { lock_script: EncodedPredicate, ) -> Result { let items = d.array(Some(3))?; + let reference_time = items[1].uint()?; + let inclusion_proof = InclusionProof::from_cbor(items[2])?; + if inclusion_proof.reference_time != Some(reference_time) { + return Err(Error::UnexpectedValue( + "certified transfer reference time mismatch", + )); + } Ok(CertifiedTransferTransaction { transaction: TransferTransaction::from_cbor(items[0], source_state_hash, lock_script)?, - reference_time: items[1].uint()?, - inclusion_proof: InclusionProof::from_cbor(items[2])?, + reference_time, + inclusion_proof, }) } diff --git a/src/transaction/mint.rs b/src/transaction/mint.rs index be06411..c329619 100644 --- a/src/transaction/mint.rs +++ b/src/transaction/mint.rs @@ -16,7 +16,8 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`MintTransaction`]. pub const MINT_TRANSACTION_TAG: u64 = 39041; -const VERSION: u64 = 1; +const LEGACY_VERSION: u64 = 1; +const TIMEOUT_VERSION: u64 = 2; /// A token mint transaction. The lock script, source (mint) state, and token id /// are *derived* from the network id and salt — never taken from the wire — so @@ -25,7 +26,7 @@ const VERSION: u64 = 1; pub struct MintTransaction { network_id: NetworkId, recipient: EncodedPredicate, - timeout: u64, + timeout: Option, salt: TokenSalt, token_type: TokenType, justification: Option>, @@ -42,7 +43,6 @@ impl MintTransaction { pub fn create( network_id: NetworkId, recipient: EncodedPredicate, - timeout: u64, token_type: TokenType, salt: TokenSalt, data: Option>, @@ -54,7 +54,7 @@ impl MintTransaction { Ok(MintTransaction { network_id, recipient, - timeout, + timeout: None, salt, token_type, justification, @@ -65,6 +65,22 @@ impl MintTransaction { }) } + /// Build a mint transaction with an explicit exclusive request timeout. + pub fn create_with_timeout( + network_id: NetworkId, + recipient: EncodedPredicate, + timeout: u64, + token_type: TokenType, + salt: TokenSalt, + data: Option>, + justification: Option>, + ) -> Result { + let mut transaction = + Self::create(network_id, recipient, token_type, salt, data, justification)?; + transaction.timeout = Some(timeout); + Ok(transaction) + } + /// The network id. pub fn network_id(&self) -> NetworkId { self.network_id @@ -93,9 +109,12 @@ impl MintTransaction { /// Decode from CBOR (tagged), re-deriving the lock script / mint state. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(MINT_TRANSACTION_TAG)?; - let items = inner.array(Some(8))?; + let items = inner.array(None)?; let version = items[0].uint()?; - if version != VERSION { + let has_timeout = version == TIMEOUT_VERSION; + if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || items.len() != if has_timeout { 8 } else { 7 } + { return Err(Error::UnexpectedValue( "unsupported MintTransaction version", )); @@ -111,16 +130,19 @@ impl MintTransaction { items[5].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; let data = items[6].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; - let timeout = items[7].uint()?; - MintTransaction::create( - network_id, - recipient, - timeout, - token_type, - salt, - data, - justification, - ) + if has_timeout { + MintTransaction::create_with_timeout( + network_id, + recipient, + items[7].uint()?, + token_type, + salt, + data, + justification, + ) + } else { + MintTransaction::create(network_id, recipient, token_type, salt, data, justification) + } } } @@ -137,7 +159,7 @@ impl Transaction for MintTransaction { self.source_state.hash() } - fn timeout(&self) -> u64 { + fn timeout(&self) -> Option { self.timeout } @@ -150,18 +172,36 @@ impl Transaction for MintTransaction { } fn to_cbor(&self) -> Vec { - encode_tag( - MINT_TRANSACTION_TAG, - &encode_array(&[ - &encode_uint(VERSION), - &encode_uint(self.network_id.id() as u64), - &self.recipient.to_cbor(), - &self.salt.to_cbor(), - &self.token_type.to_cbor(), - &encode_nullable(self.justification.as_ref(), |v| encode_byte_string(v)), - &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - &encode_uint(self.timeout), - ]), - ) + let common = [ + &encode_uint(self.network_id.id() as u64), + &self.recipient.to_cbor(), + &self.salt.to_cbor(), + &self.token_type.to_cbor(), + &encode_nullable(self.justification.as_ref(), |v| encode_byte_string(v)), + &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), + ]; + let payload = if let Some(timeout) = self.timeout { + encode_array(&[ + &encode_uint(TIMEOUT_VERSION), + common[0], + common[1], + common[2], + common[3], + common[4], + common[5], + &encode_uint(timeout), + ]) + } else { + encode_array(&[ + &encode_uint(LEGACY_VERSION), + common[0], + common[1], + common[2], + common[3], + common[4], + common[5], + ]) + }; + encode_tag(MINT_TRANSACTION_TAG, &payload) } } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 48f02bb..e0005d6 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -33,7 +33,7 @@ pub trait Transaction { /// admits the request only in a round whose reference time is below this /// value. It is part of the transaction encoding, so the transaction hash /// commits to it and the unlock script signs it. - fn timeout(&self) -> u64; + fn timeout(&self) -> Option; /// CBOR encoding (tagged). fn to_cbor(&self) -> Vec; diff --git a/src/transaction/transfer.rs b/src/transaction/transfer.rs index 69cadfe..3d25a80 100644 --- a/src/transaction/transfer.rs +++ b/src/transaction/transfer.rs @@ -18,7 +18,8 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`TransferTransaction`]. pub const TRANSFER_TRANSACTION_TAG: u64 = 39045; -const VERSION: u64 = 1; +const LEGACY_VERSION: u64 = 1; +const TIMEOUT_VERSION: u64 = 2; /// A token transfer transaction. #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,7 +29,7 @@ pub struct TransferTransaction { lock_script: EncodedPredicate, // On the wire: recipient: EncodedPredicate, - timeout: u64, + timeout: Option, state_mask: Vec, data: Option>, } @@ -41,7 +42,6 @@ impl TransferTransaction { source_state_hash: DataHash, lock_script: EncodedPredicate, recipient: EncodedPredicate, - timeout: u64, state_mask: Vec, data: Option>, ) -> Self { @@ -49,12 +49,27 @@ impl TransferTransaction { source_state_hash, lock_script, recipient, - timeout, + timeout: None, state_mask, data, } } + /// Construct a transfer with an explicit exclusive request timeout. + pub fn new_with_timeout( + source_state_hash: DataHash, + lock_script: EncodedPredicate, + recipient: EncodedPredicate, + timeout: u64, + state_mask: Vec, + data: Option>, + ) -> Self { + let mut transaction = + Self::new(source_state_hash, lock_script, recipient, state_mask, data); + transaction.timeout = Some(timeout); + transaction + } + /// The state mask mixed into the resulting state hash. pub fn state_mask(&self) -> &[u8] { &self.state_mask @@ -73,9 +88,12 @@ impl TransferTransaction { lock_script: EncodedPredicate, ) -> Result { let inner = d.expect_tag(TRANSFER_TRANSACTION_TAG)?; - let items = inner.array(Some(5))?; + let items = inner.array(None)?; let version = items[0].uint()?; - if version != VERSION { + let has_timeout = version == TIMEOUT_VERSION; + if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) + || items.len() != if has_timeout { 5 } else { 4 } + { return Err(Error::UnexpectedValue( "unsupported TransferTransaction version", )); @@ -84,15 +102,18 @@ impl TransferTransaction { let state_mask = items[2].bytes_value()?.to_vec(); let data = items[3].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; - let timeout = items[4].uint()?; - Ok(TransferTransaction::new( - source_state_hash, - lock_script, - recipient, - timeout, - state_mask, - data, - )) + Ok(if has_timeout { + TransferTransaction::new_with_timeout( + source_state_hash, + lock_script, + recipient, + items[4].uint()?, + state_mask, + data, + ) + } else { + TransferTransaction::new(source_state_hash, lock_script, recipient, state_mask, data) + }) } } @@ -109,7 +130,7 @@ impl Transaction for TransferTransaction { &self.source_state_hash } - fn timeout(&self) -> u64 { + fn timeout(&self) -> Option { self.timeout } @@ -121,15 +142,22 @@ impl Transaction for TransferTransaction { } fn to_cbor(&self) -> Vec { - encode_tag( - TRANSFER_TRANSACTION_TAG, - &encode_array(&[ - &encode_uint(VERSION), + let payload = if let Some(timeout) = self.timeout { + encode_array(&[ + &encode_uint(TIMEOUT_VERSION), + &self.recipient.to_cbor(), + &encode_byte_string(&self.state_mask), + &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), + &encode_uint(timeout), + ]) + } else { + encode_array(&[ + &encode_uint(LEGACY_VERSION), &self.recipient.to_cbor(), &encode_byte_string(&self.state_mask), &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - &encode_uint(self.timeout), - ]), - ) + ]) + }; + encode_tag(TRANSFER_TRANSACTION_TAG, &payload) } } diff --git a/src/verify/error.rs b/src/verify/error.rs index 7fd3daa..35bfc12 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -66,6 +66,8 @@ pub enum VerificationError { CertificationDataMismatch, /// The certified transaction hash does not match the recomputed one. TransactionHashMismatch, + /// The inclusion proof omitted or disagreed on the leaf creation reference time. + MissingReferenceTime, /// The round's reference time had already reached the request's timeout. RequestExpired, /// The sparse-Merkle-tree path did not reproduce the expected root. @@ -178,6 +180,9 @@ impl fmt::Display for VerificationError { write!(f, "certification data does not match transaction state") } VerificationError::TransactionHashMismatch => write!(f, "transaction hash mismatch"), + VerificationError::MissingReferenceTime => { + write!(f, "inclusion proof reference time missing or mismatched") + } VerificationError::RequestExpired => write!(f, "certification request expired"), VerificationError::PathInvalid => write!(f, "inclusion path invalid"), VerificationError::NonInclusionCertificateInvalid => { diff --git a/src/verify/mod.rs b/src/verify/mod.rs index 89aea13..e5bb053 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -230,8 +230,10 @@ pub fn verify_inclusion_proof_for( } // The request was admissible only in a round strictly before its timeout. - if reference_time >= certification_data.timeout() { - return Err(VerificationError::RequestExpired); + if let Some(timeout) = certification_data.timeout() { + if reference_time >= timeout { + return Err(VerificationError::RequestExpired); + } } let expected_root = DataHash::new( @@ -239,6 +241,9 @@ pub fn verify_inclusion_proof_for( proof.unicity_certificate.input_record.hash.clone(), ) .map_err(|_| VerificationError::PathInvalid)?; + if proof.reference_time != Some(reference_time) { + return Err(VerificationError::MissingReferenceTime); + } let leaf_value = calculate_leaf_value(certification_data.transaction_hash(), reference_time); if !inclusion_certificate.verify(state_id, &leaf_value, &expected_root) { return Err(VerificationError::PathInvalid); @@ -536,11 +541,11 @@ mod tests { let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); - let certification_data = CertificationData::new( + let certification_data = CertificationData::new_with_timeout( transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, - transaction.timeout(), + transaction.timeout().expect("explicit timeout fixture"), unlock, ); InclusionProof { @@ -552,7 +557,7 @@ mod tests { } fn make_transfer(owner: &Secp256k1Signer, recipient: &Secp256k1Signer) -> TransferTransaction { - TransferTransaction::new( + TransferTransaction::new_with_timeout( sha256(b"source-state"), SignaturePredicate::new(owner.public_key()).to_encoded(), SignaturePredicate::new(recipient.public_key()).to_encoded(), @@ -738,7 +743,7 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let stranger = signer(0xAB); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( SignaturePredicate::new(stranger.public_key()).to_encoded(), // wrong lock c.source_state_hash().clone(), c.transaction_hash().clone(), @@ -755,7 +760,7 @@ mod tests { fn rule_certification_data_mismatch_source_state() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( c.lock_script().clone(), sha256(b"a-different-source-state"), // wrong source c.transaction_hash().clone(), @@ -772,7 +777,7 @@ mod tests { fn rule_certification_data_mismatch_timeout() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), @@ -798,7 +803,7 @@ mod tests { fn rule_transaction_hash_mismatch() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( c.lock_script().clone(), c.source_state_hash().clone(), sha256(b"not-the-tx-hash"), // wrong tx hash @@ -884,7 +889,7 @@ mod tests { let c = cert(&proof); let mut unlock = c.unlock_script().to_vec(); unlock[0] ^= 0xff; // corrupt the signature (still 65 bytes) - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), @@ -917,7 +922,7 @@ mod tests { justification: Option>, ) -> (RootTrustBase, MintTransaction, InclusionProof) { let recipient = signer(0x55); - let mint = MintTransaction::create( + let mint = MintTransaction::create_with_timeout( NetworkId::LOCAL, SignaturePredicate::new(recipient.public_key()).to_encoded(), TIMEOUT, @@ -936,10 +941,7 @@ mod tests { fn baseline_genesis_token_verifies() { let node = signer(0x11); let (tb, mint, proof) = genesis_token(&node, None); - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!(token.verify(&tb), Ok(())); } @@ -947,10 +949,7 @@ mod tests { fn rule_network_mismatch() { let node = signer(0x11); let (_, mint, proof) = genesis_token(&node, None); - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); // Mint is on LOCAL; verify against a (valid) MAINNET trust base. let mainnet = RootTrustBase::new( 0, @@ -973,17 +972,14 @@ mod tests { // Replace the certified lock script with one that is not the minter key. let stranger = signer(0x77); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = Some(CertificationData::new_with_timeout( SignaturePredicate::new(stranger.public_key()).to_encoded(), c.source_state_hash().clone(), c.transaction_hash().clone(), TIMEOUT, c.unlock_script().to_vec(), )); - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!( token.verify(&tb), Err(VerificationError::InvalidMintLockScript) @@ -996,10 +992,7 @@ mod tests { // A justified mint whose proof is otherwise fully valid reaches — and // fails at — the justification rule (no verifier is registered). let (tb, mint, proof) = genesis_token(&node, Some(alloc::vec![0xde, 0xad])); - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!( token.verify(&tb), Err(VerificationError::UnsupportedMintJustification) @@ -1012,10 +1005,7 @@ mod tests { fn rule_invalid_trust_base() { let node = signer(0x11); let (_, mint, proof) = genesis_token(&node, None); - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); // Threshold of zero would accept an unsigned seal. let zero_threshold = RootTrustBase::new( @@ -1067,10 +1057,7 @@ mod tests { let node = signer(0x11); let (tb, mint, mut proof) = genesis_token(&node, None); proof.unicity_certificate.unicity_seal.hash = alloc::vec![0u8; 32]; // break seal root - let token = Token::new( - CertifiedMintTransaction::new(mint, REFERENCE_TIME, proof), - Vec::new(), - ); + let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!( token.verify(&tb), Err(VerificationError::Genesis(alloc::boxed::Box::new( @@ -1086,10 +1073,10 @@ mod tests { let node = signer(0x11); let owner = signer(0x55); // genesis recipient == transfer owner let (tb, mint, genesis_proof) = genesis_token(&node, None); - let genesis = CertifiedMintTransaction::new(mint, REFERENCE_TIME, genesis_proof); + let genesis = CertifiedMintTransaction::new(mint, genesis_proof); let recipient = signer(0x88); - let transfer = TransferTransaction::new( + let transfer = TransferTransaction::new_with_timeout( genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), @@ -1104,7 +1091,6 @@ mod tests { genesis.clone(), alloc::vec![CertifiedTransferTransaction::new( transfer.clone(), - REFERENCE_TIME, transfer_proof.clone() )], ); @@ -1114,11 +1100,7 @@ mod tests { transfer_proof.unicity_certificate.unicity_seal.hash = alloc::vec![0u8; 32]; let tampered = Token::new( genesis, - alloc::vec![CertifiedTransferTransaction::new( - transfer, - REFERENCE_TIME, - transfer_proof - )], + alloc::vec![CertifiedTransferTransaction::new(transfer, transfer_proof)], ); assert_eq!( tampered.verify(&tb), @@ -1139,10 +1121,10 @@ mod tests { // matches and the transfer is rejected. let node = signer(0x11); let (tb, mint, genesis_proof) = genesis_token(&node, None); - let genesis = CertifiedMintTransaction::new(mint, REFERENCE_TIME, genesis_proof.clone()); + let genesis = CertifiedMintTransaction::new(mint, genesis_proof.clone()); let recipient = signer(0x88); - let transfer = TransferTransaction::new( + let transfer = TransferTransaction::new_with_timeout( genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), @@ -1154,11 +1136,7 @@ mod tests { // The genesis proof does not attest to the transfer's transaction. let tampered = Token::new( genesis, - alloc::vec![CertifiedTransferTransaction::new( - transfer, - REFERENCE_TIME, - genesis_proof - )], + alloc::vec![CertifiedTransferTransaction::new(transfer, genesis_proof)], ); let result = tampered.verify(&tb); assert!( diff --git a/tests/e2e.rs b/tests/e2e.rs index 60d2302..d8f9821 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -56,7 +56,7 @@ fn e2e_mint_transfer_verify() { let alice = Secp256k1Signer::generate().unwrap(); let bob = Secp256k1Signer::generate().unwrap(); - let token = client::mint( + let token = client::mint_with_timeout( &aggregator, &trust_base, trust_base.network_id, @@ -70,7 +70,7 @@ fn e2e_mint_transfer_verify() { .expect("mint"); token.verify(&trust_base).expect("verify minted token"); - let transferred = client::transfer( + let transferred = client::transfer_with_timeout( &aggregator, &trust_base, &token, diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index ee33640..f9329d6 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -8,13 +8,18 @@ //! confirms the Rust SDK decodes those exact bytes, round-trips them //! byte-for-byte, and reaches the same verification decisions (RSMT v6a, //! big-endian bit order). +//! +//! The Alice/Bob/Carol tokens use the service-default request profile, which +//! carries no timeout and needs no client clock. `explicitTimeoutToken` uses +//! the explicit-timeout profile, so both encodings are covered by the same +//! cross-SDK vector. use unicity_token::api::bft::root_trust_base::RootTrustBaseNodeInfo; use unicity_token::api::bft::RootTrustBase; use unicity_token::api::{CertificationData, NetworkId}; use unicity_token::crypto::hash::sha256; use unicity_token::crypto::signature::PublicKey; -use unicity_token::transaction::{CertifiedTransferTransaction, Token}; +use unicity_token::transaction::{CertifiedTransferTransaction, Token, Transaction}; use unicity_token::verify::VerificationError; const FIXTURE: &str = include_str!("vectors/transition_flow.json"); @@ -58,16 +63,53 @@ fn token(name: &str) -> (Vec, Token) { #[test] fn decodes_and_roundtrips_byte_for_byte() { - for name in ["aliceToken", "bobToken", "carolToken"] { + for name in [ + "aliceToken", + "bobToken", + "carolToken", + "explicitTimeoutToken", + ] { let (bytes, token) = token(name); assert_eq!(token.to_cbor(), bytes, "{name} did not round-trip"); } } +/// Both request profiles cross the SDK boundary: the default flow carries no +/// timeout, and the explicit one carries a deadline the round's reference time +/// is below. +#[test] +fn covers_both_request_profiles() { + let tb = trust_base(); + + let (_, default_token) = token("aliceToken"); + assert_eq!(default_token.genesis().transaction().timeout(), None); + default_token.verify(&tb).expect("default profile verifies"); + + let (_, explicit_token) = token("explicitTimeoutToken"); + let genesis = explicit_token.genesis(); + let timeout = genesis + .transaction() + .timeout() + .expect("explicit profile carries a timeout"); + assert!( + genesis.reference_time() < timeout, + "certified reference time {} must precede the request timeout {timeout}", + genesis.reference_time() + ); + explicit_token + .verify(&tb) + .expect("explicit profile verifies"); +} + #[test] fn verifies_against_trust_base() { let tb = trust_base(); - for name in ["aliceToken", "bobToken", "carolToken"] { + for name in [ + "aliceToken", + "bobToken", + "carolToken", + "explicitTimeoutToken", + ] { let (_, token) = token(name); token .verify(&tb) @@ -204,19 +246,28 @@ fn rejects_mismatched_transfer_certification_state() { .certification_data .as_ref() .expect("fixture has certification data"); - proof.certification_data = Some(CertificationData::new( - data.lock_script().clone(), - sha256(b"unrelated source state"), - data.transaction_hash().clone(), - data.timeout(), - data.unlock_script().to_vec(), - )); + // Rebuild in whichever request profile the fixture used, so only the + // substituted source state differs. + proof.certification_data = Some(match data.timeout() { + None => CertificationData::new( + data.lock_script().clone(), + sha256(b"unrelated source state"), + data.transaction_hash().clone(), + data.unlock_script().to_vec(), + ), + Some(timeout) => CertificationData::new_with_timeout( + data.lock_script().clone(), + sha256(b"unrelated source state"), + data.transaction_hash().clone(), + timeout, + data.unlock_script().to_vec(), + ), + }); let forged = Token::new( token.genesis().clone(), vec![CertifiedTransferTransaction::new( certified.transaction().clone(), - certified.reference_time(), proof, )], ); diff --git a/tests/vectors/transition_flow.json b/tests/vectors/transition_flow.json index 865ef53..dfef55a 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,12 +1,14 @@ { - "__comment": "generated by state-transition-sdk-js 2c055b59b61c2b8426a7e687552f6aadc885a47e", + "__comment": "generated by state-transition-sdk-js 03632632ce61150ffe09f140e12670ff26812b03", "trustBase": { "networkId": 3, "nodeId": "NODE", "aggregatorPublicKey": "03079264c4b4bfcd7fe3a7b7b92b6c439f3a5b3abcd29189bf7b54d781ff03d722", "quorumThreshold": "1" }, - "aliceToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e0180", - "bobToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e018183d998858501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820d0d6fd22ec2e71b73da98907ca75265210b7076995f9189aed71d7cccc2f029ff61a6a86cb691a6a86bd59d998798501d998778601d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582025acb1226c130ec4e6ee0d7b69db6b0354a666903b2da5b275394428c07629555820ceda6ca3b0461210d7f3decac6d659bb215c6240c95e5511de93dae591d611541a6a86cb695841b3cf0539d9aab3ce8dec9b31bc73585f1aa051baed5339ecdef49cfbd1cab12d616e789fe118db1ca97996accbb3066674641bfe46fb92deeee23e78f02a8c67001a6a86bd5958408000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694d998598701d9985a8a010000f65820344deba700c59b51ff2212fbe1a008f891a710482e4b41645d36b3d31ccafadb401a6a86bd5af600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820745e873f8fbb9b8a71bd0887309989507aa9b18c6528a52068e96e56d2b63b82a1644e4f444558412ae4b7aac986528674495e30ef80687b90714bef8a011da8731b9cd3cc834638772d50905f1334a7368dbc0d58c2cf1353607a2bc0ce64daded01c656b13624c01", - "carolToken": "d99880830183d99881880103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858207ba368332c326059cfa49b16bddba33dbf4469119829bd50c0b6e6442e63c41f5820fd92d5be5f60326e824745ae9e2b91a52d75833183d7296484bbff0635af1cd4f6f61a6a86cb681a6a86bd58d998798501d998778601d9987883014101582102e7c143a54f4bf459f84120d70b346f20fc6cef46b3c3ec723734c7ec65ebd9085820bf645b3d39dcddecf646527f8971dab6649e9b9c51b9eb8d744a1f4e3c6904b158209c9ebefe306b030acbdc7f78226741ae88392b558ef17b52be390f2c35c756411a6a86cb6858418e1e60de80998a1ad0286f6da517cd46748bb35874d31fa67c367f965f8fb99b3ad055939412a9a52114816b909d3bbe8acb9ce9d6f96ebf0f19801d1349f9ff001a6a86bd5858200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694401a6a86bd59f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582066b9420bfe20095c79f6fd8873a648bf46c4ab2f6dbe6bf64760a955e59febe8a1644e4f44455841736f02ea420bcb515aeb31f389fe6a362f4ac733403d35cc5872b7c89316526713db184de4eb72a7769fdc7bf2b089a96f1b1c1e332346f799f57412f6ce118e018283d998858501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820d0d6fd22ec2e71b73da98907ca75265210b7076995f9189aed71d7cccc2f029ff61a6a86cb691a6a86bd59d998798501d998778601d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582025acb1226c130ec4e6ee0d7b69db6b0354a666903b2da5b275394428c07629555820ceda6ca3b0461210d7f3decac6d659bb215c6240c95e5511de93dae591d611541a6a86cb695841b3cf0539d9aab3ce8dec9b31bc73585f1aa051baed5339ecdef49cfbd1cab12d616e789fe118db1ca97996accbb3066674641bfe46fb92deeee23e78f02a8c67001a6a86bd5958408000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f694d998598701d9985a8a010000f65820344deba700c59b51ff2212fbe1a008f891a710482e4b41645d36b3d31ccafadb401a6a86bd5af600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820745e873f8fbb9b8a71bd0887309989507aa9b18c6528a52068e96e56d2b63b82a1644e4f444558412ae4b7aac986528674495e30ef80687b90714bef8a011da8731b9cd3cc834638772d50905f1334a7368dbc0d58c2cf1353607a2bc0ce64daded01c656b13624c0183d998858501d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f95820c14b2cb9f3356d73e3b8534dfac05c58663747c1010a2e14cbaf04b21c259540f61a6a86cb691a6a86bd5ad998798501d998778601d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558204e748eadad84eccacd0d2a7c7817d958456dffb9d18253f40bbf8050f034e0b6582054a7640805e947c24434740c7bcc21815cc980ff62d369f0335f10f829534df51a6a86cb695841defaf8406edae96fca6b6c36c60674b3d9b774bb2c1a6961578b40ff04599edc332a2d29a9415bbdf413a1690ab4dac0345fe653b1355aec6b380cf33158e5d7001a6a86bd5a5860c000000000000000000000000000000000000000000000000000000000000000b51a75dd61d598a1b8e2f8d1f5ca7e41b9d5b1693956909121335d2d0e16f6946b2e0a1b835feda5ab9079463948d6c1f3f90ee02a734b8b73e233706967963dd998598701d9985a8a010000f65820dcbc1cb2095a0e56b5941ba9ff4be3ae0ef778b9a34d192f15eaf45cf1b546ff401a6a86bd5bf600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658204dcafc2258a683ce8d5bcd98534f048b5b3c5362cfc6e41692f3c1715a354e21a1644e4f44455841cee4fb896133fa5d6fe938e6cf9d8c2fd5d5a22eba2503ebd668a9921ab69d6076ddfc69828ee66ff537d58075aa4beac757f44a5b7fe0a5063090aa5d57454d00" + "aliceToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f40180", + "bobToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f4018183d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820cd5c6c2fb0f7f12596451a6fe5515651b55066a43ea5c24b203dde24c1a569f7f61a6a870a8fd998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858201d6a15772d417b78c4d5ef509efaa18381bb4899c94c8ebda48283e50070cb4b58206c67863f813bb018a4ee564328e9e89f2258fe980431daa517e50b47fb18b1005841e870c3791b77fda780dea07633f233fde2b2a58b3791654f0f80ede135591ad925b3148cd8f55fa56127a7b0371ff541754f749e606cfd346996e095076c539a011a6a870a8f58402000000000000000000000000000000000000000000000000000000000000000b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8d998598701d9985a8a010000f65820e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318401a6a870a90f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f9a878d647710831da5c25e4ae5cb975e896cafc7025a017e4d1c6062c59911ca1644e4f44455841c4188c38422e60facdfdf172d5e36283a404ceb7457914509563e30fba4a615f16b8fe8f45efdb1e8c060c96c2c03bf267b381c07856eb6cad44688a044907ca01", + "carolToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f4018283d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820cd5c6c2fb0f7f12596451a6fe5515651b55066a43ea5c24b203dde24c1a569f7f61a6a870a8fd998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858201d6a15772d417b78c4d5ef509efaa18381bb4899c94c8ebda48283e50070cb4b58206c67863f813bb018a4ee564328e9e89f2258fe980431daa517e50b47fb18b1005841e870c3791b77fda780dea07633f233fde2b2a58b3791654f0f80ede135591ad925b3148cd8f55fa56127a7b0371ff541754f749e606cfd346996e095076c539a011a6a870a8f58402000000000000000000000000000000000000000000000000000000000000000b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8d998598701d9985a8a010000f65820e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318401a6a870a90f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f9a878d647710831da5c25e4ae5cb975e896cafc7025a017e4d1c6062c59911ca1644e4f44455841c4188c38422e60facdfdf172d5e36283a404ceb7457914509563e30fba4a615f16b8fe8f45efdb1e8c060c96c2c03bf267b381c07856eb6cad44688a044907ca0183d998858401d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9582047faefdc46c39a10bf7eacd2ebd980020d7ce7f8a79e78847b82cf36c7a0ecdaf61a6a870a90d998798501d998778501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558203d495ffb7ba8b62d37ee16340d886a09f4fabaa4cb01f2e233fb94cce2a27c8e5820a68d2781060ec943aca56ee96d0c128f6903c784e99d9c1dc821f08126ecaa0e58410b833e5de722182af675982b34eeb840cadb938146d4861201bd048a9b885ac73bdc9859a8500a594ba9d77d6f95bf34bcbede978e791d87617345175287529a001a6a870a9058408000000000000000000000000000000000000000000000000000000000000000e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318d998598701d9985a8a010000f65820083c58df2c06d5c5b6f51e689115cc9594c3ff52dc5e798b5b39165bc0f176a4401a6a870a91f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f38d42718b7e38df7e55b5911709fd6457770f645f58203b5e4744b7b00797efa1644e4f44455841245c91a35e4879d4c78aa59ce950eae83dc320eeb560c63d9ee3ed02aced1a0a507035554e2f57347cd58a2a705cbb5fdf77470406c7d718741917e01e31033300", + "explicitTimeout": 1787321358, + "explicitTimeoutToken": "d99880830183d99881880203d9987883014101582102e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd135820938d4d6859192b96e772ce4a72f42a0f285780b4c21ea5e359f38c1c432a36b05820449eb8e4903030e402d74b0d1be0a8629a86d968de0bb6cde49beedb9f9424b9f6f61a6a885c0e1a6a870a91d998798501d998778602d9987883014101582102e41838ac5b2740da9f2cdd0bfb96bb9597df55135cdd75798ad73c6d43ad66ac5820618d7e908b188999b48090b0fb85efa54b92abbbbfd7094da8838c53aed8f7985820a5aaf8e0bb5fc97f72a1a54df93ec7faf247462270da1c88b51a4cd181038faf1a6a885c0e58411a81892142fdbb4d150f15dad43ba77c58d0ed6072061da13a79db524799a7f762c5ea3b3307bcbe1ba5daeaf97aed3fdab44e12a5db7cb2ca2c9e60643c294a001a6a870a915860c000000000000000000000000000000000000000000000000000000000000000e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318d24ed7af1734b816404e1554d0ad344f213719e9cbbb2b8e30cfd49f15f5e25fd998598701d9985a8a010000f65820cd11b7cc8eec973b343aed919a50db5f3005bcf83a248222f17aa021aac85157401a6a870a92f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582057492ea149ad3fdf91c3468412ce2ae074c78cf4b7e90885ab33ed8279a32334a1644e4f444558418b2d4e9bb774149bb9034d19461fa2a58445865532d874a082e9c63eebd86ef6318bc4b696aa09b926dd71270afbb85925f731190cf4e535ac1077824ad25e6d0080" } From 84209353fb9af40031fe81477225181db1688479 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 21:49:58 +0300 Subject: [PATCH 06/12] Collapse the request timeout profiles and name the field expires_at MintTransaction, TransferTransaction and CertificationData each carried the optional request timeout as two wire versions: version 1 without the field, version 2 with it. The version was then derived from the field rather than read, so it carried no information, and each decoder had to pair a version with an element count by hand. Use one shape per structure. The deadline keeps a fixed position and is encoded as CBOR null when the caller did not supply one, using the existing encode_nullable/Decoder::nullable helpers that already encode `data` and `justification` in these same arrays. Version 2 is the only accepted version and the array length is fixed, so array(Some(N)) checks it once. The explicit-deadline bytes are unchanged; only the absent case moves, from a shorter array to a null in the same slot. Rename timeout to expires_at: the value is an absolute exclusive instant in Unix seconds, not a duration. Take Option as a plain trailing parameter instead of pairing every constructor with a *_with_timeout twin. Rust has no overloading and no default arguments, and Option is how it spells optional, so this deletes create_with_timeout, new_with_timeout, mint_with_timeout, transfer_with_timeout, split_with_timeout, split_unchecked_with_timeout, and the mint_impl/transfer_impl bodies that existed only to hold the shared code. It also removes the post-construction field mutation in create_with_timeout, which assigned the field after create had already derived the token id, lock script and mint state. Regenerate tests/vectors/transition_flow.json from the TypeScript SDK. The vector now covers a token whose deadline is absent and one whose deadline is set, which are the same version with the same element count. --- README.md | 9 +- e2e/README.md | 4 +- examples/README.md | 2 +- src/api/certification.rs | 100 +++++++----------- src/client/mod.rs | 157 ++++++----------------------- src/payment/split.rs | 66 +++--------- src/payment/tests.rs | 64 ++++++------ src/transaction/mint.rs | 94 ++++++----------- src/transaction/mod.rs | 2 +- src/transaction/transfer.rs | 85 ++++++---------- src/verify/mod.rs | 59 ++++++----- tests/e2e.rs | 8 +- tests/transition_flow.rs | 52 ++++------ tests/vectors/transition_flow.json | 12 +-- 14 files changed, 241 insertions(+), 473 deletions(-) diff --git a/README.md b/README.md index 20fa8e3..ad306d6 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,13 @@ let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.net .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)?; + &recipient, token_type, salt, /* data */ None, /* justification */ None, + /* expires_at */ None)?; ``` -Use `mint_with_timeout`, `transfer_with_timeout`, or the transaction-level -`create_with_timeout`/`new_with_timeout` methods when an application needs an explicit Unix-seconds -deadline. +`expires_at` is the exclusive request deadline in Unix seconds. Pass `None` to let the Unicity +Service assign one from consensus time, which requires no local clock; pass `Some(deadline)` when +the application needs its own. Either way the value is committed by the transaction hash. 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 diff --git a/e2e/README.md b/e2e/README.md index 28b32f8..4f7d893 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -23,8 +23,8 @@ cargo run --release The program loads `.env` with `dotenvy`. Values already present in the process environment take precedence, which keeps it suitable for CI and deployed environments. `.env` is git-ignored and the credential is never written to the -generated token files. Mint and transfer requests use exclusive timeouts one -hour ahead of the current Unix time. +generated token files. Mint and transfer requests set an exclusive +`expiresAt` deadline one hour ahead of the current Unix time. Defaults: diff --git a/examples/README.md b/examples/README.md index 82100dc..3d03bff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ UNICITY_TRUSTBASE=bft-trustbase.testnet2.json Values already present in the process environment take precedence. The examples require the `http` feature (a blocking TLS HTTP stack); they generate ephemeral in-memory wallets and never persist keys. Each submitted transaction uses an -service-assigned timeout derived from consensus time, so the examples do not require a valid system clock. +service-assigned deadline derived from consensus time, so the examples do not require a valid system clock. For a fuller standalone demo (mint → save → reload → transfer → verify), see the `e2e/` crate. diff --git a/src/api/certification.rs b/src/api/certification.rs index 056e2dc..177ba17 100644 --- a/src/api/certification.rs +++ b/src/api/certification.rs @@ -4,7 +4,9 @@ use alloc::vec::Vec; -use crate::cbor::{encode_array, encode_byte_string, encode_tag, encode_uint, Decoder}; +use crate::cbor::{ + encode_array, encode_byte_string, encode_nullable, encode_tag, encode_uint, Decoder, +}; use crate::crypto::hash::{DataHash, HashAlgorithm}; use crate::error::Error; use crate::predicate::EncodedPredicate; @@ -12,8 +14,9 @@ use crate::transaction::Transaction; /// CBOR tag for [`CertificationData`]. pub const CERTIFICATION_DATA_TAG: u64 = 39031; -const LEGACY_VERSION: u64 = 1; -const TIMEOUT_VERSION: u64 = 2; +/// The only accepted wire version. One version, one element count. +pub const CERTIFICATION_DATA_VERSION: u64 = 2; +const FIELD_COUNT: usize = 6; /// What the aggregator certified for one state transition. /// @@ -25,45 +28,30 @@ pub struct CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, - timeout: Option, + expires_at: Option, unlock_script: Vec, } impl CertificationData { - /// Construct from parts. + /// Construct from parts. `expires_at` is the exclusive request deadline in + /// Unix seconds, or `None` to let the Unicity Service assign one, which + /// requires no local clock. pub fn new( lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, unlock_script: Vec, + expires_at: Option, ) -> Self { CertificationData { lock_script, source_state_hash, transaction_hash, - timeout: None, + expires_at, unlock_script, } } - /// Construct certification data with an explicit exclusive request timeout. - pub fn new_with_timeout( - lock_script: EncodedPredicate, - source_state_hash: DataHash, - transaction_hash: DataHash, - timeout: u64, - unlock_script: Vec, - ) -> Self { - let mut data = Self::new( - lock_script, - source_state_hash, - transaction_hash, - unlock_script, - ); - data.timeout = Some(timeout); - data - } - /// Build from a transaction and an unlock script, computing the /// transaction hash. pub fn from_transaction(transaction: &impl Transaction, unlock_script: Vec) -> Self { @@ -71,7 +59,7 @@ impl CertificationData { lock_script: transaction.lock_script().clone(), source_state_hash: transaction.source_state_hash().clone(), transaction_hash: transaction.calculate_transaction_hash(), - timeout: transaction.timeout(), + expires_at: transaction.expires_at(), unlock_script, } } @@ -88,9 +76,10 @@ impl CertificationData { pub fn transaction_hash(&self) -> &DataHash { &self.transaction_hash } - /// The exclusive timeout of the certification request. - pub fn timeout(&self) -> Option { - self.timeout + /// The exclusive certification request deadline, or `None` when the Unicity + /// Service assigned one. + pub fn expires_at(&self) -> Option { + self.expires_at } /// The unlock script (witness). pub fn unlock_script(&self) -> &[u8] { @@ -99,36 +88,22 @@ impl CertificationData { /// Encode to CBOR (tagged). Hashes are encoded as their raw 32-byte data. pub fn to_cbor(&self) -> Vec { - let payload = if let Some(timeout) = self.timeout { - encode_array(&[ - &encode_uint(TIMEOUT_VERSION), - &self.lock_script.to_cbor(), - &encode_byte_string(self.source_state_hash.data()), - &encode_byte_string(self.transaction_hash.data()), - &encode_uint(timeout), - &encode_byte_string(&self.unlock_script), - ]) - } else { - encode_array(&[ - &encode_uint(LEGACY_VERSION), - &self.lock_script.to_cbor(), - &encode_byte_string(self.source_state_hash.data()), - &encode_byte_string(self.transaction_hash.data()), - &encode_byte_string(&self.unlock_script), - ]) - }; + let payload = encode_array(&[ + &encode_uint(CERTIFICATION_DATA_VERSION), + &self.lock_script.to_cbor(), + &encode_byte_string(self.source_state_hash.data()), + &encode_byte_string(self.transaction_hash.data()), + &encode_nullable(self.expires_at.as_ref(), |v| encode_uint(*v)), + &encode_byte_string(&self.unlock_script), + ]); encode_tag(CERTIFICATION_DATA_TAG, &payload) } /// Decode from CBOR. The reference SDKs always store SHA-256 hashes here. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(CERTIFICATION_DATA_TAG)?; - let items = inner.array(None)?; - let version = items[0].uint()?; - let has_timeout = version == TIMEOUT_VERSION; - if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || items.len() != if has_timeout { 6 } else { 5 } - { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != CERTIFICATION_DATA_VERSION { return Err(Error::UnexpectedValue( "unsupported CertificationData version", )); @@ -137,14 +112,8 @@ impl CertificationData { lock_script: EncodedPredicate::from_cbor(items[1])?, source_state_hash: DataHash::new(HashAlgorithm::Sha256, items[2].bytes_value()?)?, transaction_hash: DataHash::new(HashAlgorithm::Sha256, items[3].bytes_value()?)?, - timeout: if has_timeout { - Some(items[4].uint()?) - } else { - None - }, - unlock_script: items[if has_timeout { 5 } else { 4 }] - .bytes_value()? - .to_vec(), + expires_at: items[4].nullable(|d| d.uint().map_err(Into::into))?, + unlock_script: items[5].bytes_value()?.to_vec(), }) } } @@ -176,14 +145,14 @@ mod tests { ) .to_encoded(); - let mint = MintTransaction::create_with_timeout( + let mint = MintTransaction::create( NetworkId::MAINNET, recipient, - TIMEOUT, TokenType::new([0u8; 32]), TokenSalt::from_bytes([0u8; 32]), None, None, + Some(TIMEOUT), ) .unwrap(); @@ -224,6 +193,7 @@ mod tests { TokenSalt::from_bytes([0u8; 32]), None, None, + None, ) .unwrap(); let signer = Minter::signer(mint.token_id()).unwrap(); @@ -231,10 +201,10 @@ mod tests { let unlock = sign_signature_unlock(&signer, mint.source_state_hash(), &tx_hash); let cert = CertificationData::from_transaction(&mint, unlock); - assert_eq!(mint.timeout(), None); - assert_eq!(cert.timeout(), None); + assert_eq!(mint.expires_at(), None); + assert_eq!(cert.expires_at(), None); assert_eq!(cert.to_cbor(), hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820c034e096d7bdf71ba759558663b5cafb7279ecb7e284443e5e6cbce0461aceeef6584154ca6b19a7dbcae7a6adc38af5c8672f81943ecaf51345436684299b4b7ac81a57db2653f32048981e37913db4749ca08d998d1fac4a52ab5579988bc2c50de900" )); } diff --git a/src/client/mod.rs b/src/client/mod.rs index a034dda..88c79b7 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -116,7 +116,10 @@ pub fn certification_data_for( CertificationData::from_transaction(transaction, unlock) } -/// Mint a new token to `recipient` and return the verified [`Token`]. +/// Mint a new token and return the verified [`Token`]. +/// +/// `expires_at` is the exclusive request deadline in Unix seconds, or `None` to +/// let the Unicity Service assign one, which requires no local clock. #[allow(clippy::too_many_arguments)] pub fn mint( aggregator: &A, @@ -127,57 +130,7 @@ pub fn mint( salt: TokenSalt, data: Option>, justification: Option>, -) -> Result> { - mint_impl( - aggregator, - trust_base, - network, - recipient, - None, - token_type, - salt, - data, - justification, - ) -} - -/// Mint a new token with an explicit exclusive certification timeout. -#[allow(clippy::too_many_arguments)] -pub fn mint_with_timeout( - aggregator: &A, - trust_base: &RootTrustBase, - network: NetworkId, - recipient: &impl Predicate, - timeout: u64, - token_type: TokenType, - salt: TokenSalt, - data: Option>, - justification: Option>, -) -> Result> { - mint_impl( - aggregator, - trust_base, - network, - recipient, - Some(timeout), - token_type, - salt, - data, - justification, - ) -} - -#[allow(clippy::too_many_arguments)] -fn mint_impl( - aggregator: &A, - trust_base: &RootTrustBase, - network: NetworkId, - recipient: &impl Predicate, - timeout: Option, - token_type: TokenType, - salt: TokenSalt, - data: Option>, - justification: Option>, + expires_at: Option, ) -> Result> { trust_base .validate() @@ -185,20 +138,15 @@ fn mint_impl( if network != trust_base.network_id { return Err(VerificationError::NetworkMismatch.into()); } - let recipient = EncodedPredicate::from_predicate(recipient); - let transaction = if let Some(timeout) = timeout { - MintTransaction::create_with_timeout( - network, - recipient, - timeout, - token_type, - salt, - data, - justification, - )? - } else { - MintTransaction::create(network, recipient, token_type, salt, data, justification)? - }; + let transaction = MintTransaction::create( + network, + EncodedPredicate::from_predicate(recipient), + token_type, + salt, + data, + justification, + expires_at, + )?; // The genesis is unlocked by the deterministic minter key for the token id. let signer = Minter::signer(transaction.token_id())?; @@ -221,6 +169,9 @@ fn mint_impl( /// Transfer `token` to `recipient`, authorised by `signer` (the current /// owner's key), and return the verified successor [`Token`]. +/// +/// `expires_at` is the exclusive request deadline in Unix seconds, or `None` to +/// let the Unicity Service assign one, which requires no local clock. #[allow(clippy::too_many_arguments)] pub fn transfer( aggregator: &A, @@ -230,70 +181,20 @@ pub fn transfer( signer: &impl Signer, state_mask: StateMask, data: Option>, -) -> Result> { - transfer_impl( - aggregator, trust_base, token, recipient, signer, None, state_mask, data, - ) -} - -/// Transfer a token with an explicit exclusive certification timeout. -#[allow(clippy::too_many_arguments)] -pub fn transfer_with_timeout( - aggregator: &A, - trust_base: &RootTrustBase, - token: &Token, - recipient: &impl Predicate, - signer: &impl Signer, - timeout: u64, - state_mask: StateMask, - data: Option>, -) -> Result> { - transfer_impl( - aggregator, - trust_base, - token, - recipient, - signer, - Some(timeout), - state_mask, - data, - ) -} - -#[allow(clippy::too_many_arguments)] -fn transfer_impl( - aggregator: &A, - trust_base: &RootTrustBase, - token: &Token, - recipient: &impl Predicate, - signer: &impl Signer, - timeout: Option, - state_mask: StateMask, - data: Option>, + expires_at: Option, ) -> Result> { // Reject an untrusted or stale input before causing any aggregator side // effect. The successor is verified again below as defense in depth. token.verify(trust_base)?; let (source_state_hash, lock_script) = token.latest_state(); - let recipient = EncodedPredicate::from_predicate(recipient); - let transaction = if let Some(timeout) = timeout { - TransferTransaction::new_with_timeout( - source_state_hash, - lock_script, - recipient, - timeout, - state_mask.bytes().to_vec(), - data, - ) - } else { - TransferTransaction::new( - source_state_hash, - lock_script, - recipient, - state_mask.bytes().to_vec(), - data, - ) - }; + let transaction = TransferTransaction::new( + source_state_hash, + lock_script, + EncodedPredicate::from_predicate(recipient), + state_mask.bytes().to_vec(), + data, + expires_at, + ); let certification_data = certification_data_for(&transaction, signer); aggregator @@ -315,7 +216,7 @@ fn transfer_impl( mod tests { use super::*; - /// Exclusive certification request timeout used by these tests. + /// Exclusive certification request deadline used by these tests. const TIMEOUT: u64 = 1755000000; use crate::crypto::signature::PublicKey; use crate::predicate::builtin::SignaturePredicate; @@ -372,16 +273,16 @@ mod tests { ); // Fetching the proof fails in the mock, so the flow stops there. - let err = mint_with_timeout( + let err = mint( &agg, &trust_base, NetworkId::MAINNET, &recipient, - TIMEOUT, TokenType::new([0u8; 32]), TokenSalt::from_bytes([0u8; 32]), None, None, + Some(TIMEOUT), ) .unwrap_err(); assert_eq!(err, ClientError::Aggregator("no proof in mock")); diff --git a/src/payment/split.rs b/src/payment/split.rs index 31a583d..8fbf302 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -148,25 +148,11 @@ impl TokenSplit { decode_payment_data: PaymentDataDecoder, requests: Vec, burn_state_mask: Option<[u8; 32]>, + burn_expires_at: Option, ) -> Result { let assets = verify_payment_token(token, trust_base, registry, decode_payment_data) .map_err(SplitError::Verification)?; - Self::build_split(token, assets, requests, None, burn_state_mask).map_err(SplitError::Build) - } - - /// Split a token using an explicit timeout for the burn transaction. - pub fn split_with_timeout( - token: &Token, - trust_base: &RootTrustBase, - registry: &MintJustificationRegistry, - decode_payment_data: PaymentDataDecoder, - requests: Vec, - burn_timeout: u64, - burn_state_mask: Option<[u8; 32]>, - ) -> Result { - let assets = verify_payment_token(token, trust_base, registry, decode_payment_data) - .map_err(SplitError::Verification)?; - Self::build_split(token, assets, requests, Some(burn_timeout), burn_state_mask) + Self::build_split(token, assets, requests, burn_state_mask, burn_expires_at) .map_err(SplitError::Build) } @@ -183,6 +169,7 @@ impl TokenSplit { decode_payment_data: PaymentDataDecoder, requests: Vec, burn_state_mask: Option<[u8; 32]>, + burn_expires_at: Option, ) -> Result { let source_bytes = token .genesis() @@ -190,24 +177,7 @@ impl TokenSplit { .data() .ok_or(Error::UnexpectedValue("source token has no payment data"))?; let assets = decode_payment_data(source_bytes)?; - Self::build_split(token, assets, requests, None, burn_state_mask) - } - - /// Build a split without source verification and with an explicit burn timeout. - pub fn split_unchecked_with_timeout( - token: &Token, - decode_payment_data: PaymentDataDecoder, - requests: Vec, - burn_timeout: u64, - 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, Some(burn_timeout), burn_state_mask) + Self::build_split(token, assets, requests, burn_state_mask, burn_expires_at) } /// Construct the split from the source token's already-decoded canonical @@ -217,8 +187,8 @@ impl TokenSplit { token: &Token, assets: PaymentAssetCollection, requests: Vec, - burn_timeout: Option, burn_state_mask: Option<[u8; 32]>, + burn_expires_at: Option, ) -> Result { let network_id = token.genesis().transaction().network_id(); let source_token_type = token.token_type().clone(); @@ -288,24 +258,14 @@ impl TokenSplit { }; let (source_state_hash, lock_script) = token.latest_state(); let recipient = burn_predicate.to_encoded(); - let burn_transaction = if let Some(timeout) = burn_timeout { - TransferTransaction::new_with_timeout( - source_state_hash, - lock_script, - recipient, - timeout, - mask.to_vec(), - Some(manifest_bytes.clone()), - ) - } else { - TransferTransaction::new( - source_state_hash, - lock_script, - recipient, - mask.to_vec(), - Some(manifest_bytes.clone()), - ) - }; + let burn_transaction = TransferTransaction::new( + source_state_hash, + lock_script, + recipient, + mask.to_vec(), + Some(manifest_bytes.clone()), + burn_expires_at, + ); // Build each output with its per-asset proofs (canonical output order). let mut tokens = Vec::new(); diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 3ab1813..7b4e740 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -134,12 +134,12 @@ fn valid_proof( let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); - let certification_data = CertificationData::new_with_timeout( + let certification_data = CertificationData::new( transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, - transaction.timeout().expect("explicit timeout fixture"), unlock, + Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { certification_data: Some(certification_data), @@ -171,14 +171,14 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { Asset::new(asset_b(), BigUint::from(50u32)), ]) .unwrap(); - let mint = MintTransaction::create_with_timeout( + let mint = MintTransaction::create( NetworkId::LOCAL, sig_pred(owner), - TIMEOUT, coin_type(), TokenSalt::from_bytes([0x01; 32]), Some(payment.to_cbor()), None, + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -210,14 +210,14 @@ fn mint_output( justification: &SplitMintJustification, node: &Secp256k1Signer, ) -> Token { - let mint = MintTransaction::create_with_timeout( + let mint = MintTransaction::create( network, recipient, - TIMEOUT, token_type, salt, Some(assets.to_cbor()), Some(justification.to_cbor()), + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -326,13 +326,13 @@ fn forged_output_with_type( 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_with_timeout( + let burn = TransferTransaction::new( source_state_hash, lock_script, burn_predicate.to_encoded(), - TIMEOUT, vec![9u8; 32], Some(manifest.to_cbor()), + Some(TIMEOUT), ); let burned = burned_token(&s.source, burn, &s.alice, &s.node); let justification = SplitMintJustification::create(burned, vec![proof]).unwrap(); @@ -366,14 +366,14 @@ fn split_outputs_verify_end_to_end() { 2 ); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry, PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); @@ -453,14 +453,14 @@ fn payment_verification_enforces_issuance_policy() { #[test] fn recursive_split_verification_honors_shared_depth_limit() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -517,14 +517,14 @@ fn rejects_asset_absent_from_burned_source() { #[test] fn rejects_tampered_output_amount() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -556,14 +556,14 @@ fn rejects_tampered_output_amount() { #[test] fn rejects_dropped_proof() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -591,14 +591,14 @@ fn rejects_dropped_proof() { #[test] fn rejects_wrong_burn_predicate() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let registry = registry(); @@ -606,13 +606,13 @@ fn rejects_wrong_burn_predicate() { // 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_with_timeout( + let wrong_burn = TransferTransaction::new( source_state_hash, lock_script, BurnPredicate::new(b"not-the-manifest-hash".to_vec()).to_encoded(), - TIMEOUT, vec![7u8; 32], Some(split.burn.manifest.clone()), + Some(TIMEOUT), ); let burned = burned_token(&s.source, wrong_burn, &s.alice, &s.node); assert_eq!( @@ -653,25 +653,25 @@ fn rejects_output_token_type_mismatch_at_verify() { #[test] fn rejects_missing_manifest() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); // Burn with no auxiliary manifest data at all. let (source_state_hash, lock_script) = s.source.latest_state(); - let burn = TransferTransaction::new_with_timeout( + let burn = TransferTransaction::new( source_state_hash, lock_script, BurnPredicate::new(b"x".to_vec()).to_encoded(), - TIMEOUT, vec![3u8; 32], None, + Some(TIMEOUT), ); let burned = burned_token(&s.source, burn, &s.alice, &s.node); let out = &split.tokens[0]; @@ -694,27 +694,27 @@ fn rejects_missing_manifest() { #[test] fn rejects_manifest_length_mismatch() { let s = scenario(); - let split = TokenSplit::split_with_timeout( + let split = TokenSplit::split( &s.source, &s.tb, ®istry(), PaymentAssetCollection::from_cbor_bytes, s.requests, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT), ) .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_with_timeout( + let burn = TransferTransaction::new( source_state_hash, lock_script, BurnPredicate::new(short.reason_hash().to_vec()).to_encoded(), - TIMEOUT, vec![4u8; 32], Some(short.to_cbor()), + Some(TIMEOUT), ); let burned = burned_token(&s.source, burn, &s.alice, &s.node); let out = &split.tokens[0]; @@ -749,12 +749,12 @@ fn rejects_wrong_output_token_type() { TokenType::new(vec![0xC9; 32]), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split_unchecked_with_timeout( + assert!(TokenSplit::split_unchecked( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT) ) .is_err()); } @@ -774,12 +774,12 @@ fn rejects_unbalanced_split_at_build_time() { coin_type(), TokenSalt::from_bytes([0x10; 32]), )]; - assert!(TokenSplit::split_unchecked_with_timeout( + assert!(TokenSplit::split_unchecked( &s.source, PaymentAssetCollection::from_cbor_bytes, bad, - TIMEOUT, Some([7u8; 32]), + Some(TIMEOUT) ) .is_err()); } diff --git a/src/transaction/mint.rs b/src/transaction/mint.rs index c329619..1f236c9 100644 --- a/src/transaction/mint.rs +++ b/src/transaction/mint.rs @@ -16,8 +16,9 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`MintTransaction`]. pub const MINT_TRANSACTION_TAG: u64 = 39041; -const LEGACY_VERSION: u64 = 1; -const TIMEOUT_VERSION: u64 = 2; +/// The only accepted wire version. One version, one element count. +pub const MINT_TRANSACTION_VERSION: u64 = 2; +const FIELD_COUNT: usize = 8; /// A token mint transaction. The lock script, source (mint) state, and token id /// are *derived* from the network id and salt — never taken from the wire — so @@ -26,7 +27,7 @@ const TIMEOUT_VERSION: u64 = 2; pub struct MintTransaction { network_id: NetworkId, recipient: EncodedPredicate, - timeout: Option, + expires_at: Option, salt: TokenSalt, token_type: TokenType, justification: Option>, @@ -40,6 +41,11 @@ pub struct MintTransaction { impl MintTransaction { /// Build a mint transaction, deriving the token id, lock script, and mint /// state. + /// + /// `expires_at` is the exclusive request deadline in Unix seconds, or + /// `None` to let the Unicity Service assign one, which requires no local + /// clock. Either way it is committed by the transaction hash. + #[allow(clippy::too_many_arguments)] pub fn create( network_id: NetworkId, recipient: EncodedPredicate, @@ -47,6 +53,7 @@ impl MintTransaction { salt: TokenSalt, data: Option>, justification: Option>, + expires_at: Option, ) -> Result { let token_id = TokenId::derive(network_id, &salt); let lock_script = SignaturePredicate::new(Minter::public_key(&token_id)?).to_encoded(); @@ -54,7 +61,7 @@ impl MintTransaction { Ok(MintTransaction { network_id, recipient, - timeout: None, + expires_at, salt, token_type, justification, @@ -65,22 +72,6 @@ impl MintTransaction { }) } - /// Build a mint transaction with an explicit exclusive request timeout. - pub fn create_with_timeout( - network_id: NetworkId, - recipient: EncodedPredicate, - timeout: u64, - token_type: TokenType, - salt: TokenSalt, - data: Option>, - justification: Option>, - ) -> Result { - let mut transaction = - Self::create(network_id, recipient, token_type, salt, data, justification)?; - transaction.timeout = Some(timeout); - Ok(transaction) - } - /// The network id. pub fn network_id(&self) -> NetworkId { self.network_id @@ -109,12 +100,8 @@ impl MintTransaction { /// Decode from CBOR (tagged), re-deriving the lock script / mint state. pub fn from_cbor(d: Decoder<'_>) -> Result { let inner = d.expect_tag(MINT_TRANSACTION_TAG)?; - let items = inner.array(None)?; - let version = items[0].uint()?; - let has_timeout = version == TIMEOUT_VERSION; - if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || items.len() != if has_timeout { 8 } else { 7 } - { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != MINT_TRANSACTION_VERSION { return Err(Error::UnexpectedValue( "unsupported MintTransaction version", )); @@ -130,19 +117,16 @@ impl MintTransaction { items[5].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; let data = items[6].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; - if has_timeout { - MintTransaction::create_with_timeout( - network_id, - recipient, - items[7].uint()?, - token_type, - salt, - data, - justification, - ) - } else { - MintTransaction::create(network_id, recipient, token_type, salt, data, justification) - } + let expires_at = items[7].nullable(|d| d.uint().map_err(Into::into))?; + MintTransaction::create( + network_id, + recipient, + token_type, + salt, + data, + justification, + expires_at, + ) } } @@ -159,8 +143,8 @@ impl Transaction for MintTransaction { self.source_state.hash() } - fn timeout(&self) -> Option { - self.timeout + fn expires_at(&self) -> Option { + self.expires_at } fn calculate_state_hash(&self) -> DataHash { @@ -172,36 +156,16 @@ impl Transaction for MintTransaction { } fn to_cbor(&self) -> Vec { - let common = [ + let payload = encode_array(&[ + &encode_uint(MINT_TRANSACTION_VERSION), &encode_uint(self.network_id.id() as u64), &self.recipient.to_cbor(), &self.salt.to_cbor(), &self.token_type.to_cbor(), &encode_nullable(self.justification.as_ref(), |v| encode_byte_string(v)), &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - ]; - let payload = if let Some(timeout) = self.timeout { - encode_array(&[ - &encode_uint(TIMEOUT_VERSION), - common[0], - common[1], - common[2], - common[3], - common[4], - common[5], - &encode_uint(timeout), - ]) - } else { - encode_array(&[ - &encode_uint(LEGACY_VERSION), - common[0], - common[1], - common[2], - common[3], - common[4], - common[5], - ]) - }; + &encode_nullable(self.expires_at.as_ref(), |v| encode_uint(*v)), + ]); encode_tag(MINT_TRANSACTION_TAG, &payload) } } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index e0005d6..dce17f7 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -33,7 +33,7 @@ pub trait Transaction { /// admits the request only in a round whose reference time is below this /// value. It is part of the transaction encoding, so the transaction hash /// commits to it and the unlock script signs it. - fn timeout(&self) -> Option; + fn expires_at(&self) -> Option; /// CBOR encoding (tagged). fn to_cbor(&self) -> Vec; diff --git a/src/transaction/transfer.rs b/src/transaction/transfer.rs index 3d25a80..f62f673 100644 --- a/src/transaction/transfer.rs +++ b/src/transaction/transfer.rs @@ -18,8 +18,9 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`TransferTransaction`]. pub const TRANSFER_TRANSACTION_TAG: u64 = 39045; -const LEGACY_VERSION: u64 = 1; -const TIMEOUT_VERSION: u64 = 2; +/// The only accepted wire version. One version, one element count. +pub const TRANSFER_TRANSACTION_VERSION: u64 = 2; +const FIELD_COUNT: usize = 5; /// A token transfer transaction. #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,7 +30,7 @@ pub struct TransferTransaction { lock_script: EncodedPredicate, // On the wire: recipient: EncodedPredicate, - timeout: Option, + expires_at: Option, state_mask: Vec, data: Option>, } @@ -38,38 +39,28 @@ impl TransferTransaction { /// Construct a transfer from explicit parts. `source_state_hash` and /// `lock_script` come from the previous transaction's resulting state / /// recipient. + /// + /// `expires_at` is the exclusive request deadline in Unix seconds, or + /// `None` to let the Unicity Service assign one, which requires no local + /// clock. Either way it is committed by the transaction hash. pub fn new( source_state_hash: DataHash, lock_script: EncodedPredicate, recipient: EncodedPredicate, state_mask: Vec, data: Option>, + expires_at: Option, ) -> Self { TransferTransaction { source_state_hash, lock_script, recipient, - timeout: None, + expires_at, state_mask, data, } } - /// Construct a transfer with an explicit exclusive request timeout. - pub fn new_with_timeout( - source_state_hash: DataHash, - lock_script: EncodedPredicate, - recipient: EncodedPredicate, - timeout: u64, - state_mask: Vec, - data: Option>, - ) -> Self { - let mut transaction = - Self::new(source_state_hash, lock_script, recipient, state_mask, data); - transaction.timeout = Some(timeout); - transaction - } - /// The state mask mixed into the resulting state hash. pub fn state_mask(&self) -> &[u8] { &self.state_mask @@ -88,12 +79,8 @@ impl TransferTransaction { lock_script: EncodedPredicate, ) -> Result { let inner = d.expect_tag(TRANSFER_TRANSACTION_TAG)?; - let items = inner.array(None)?; - let version = items[0].uint()?; - let has_timeout = version == TIMEOUT_VERSION; - if (version != LEGACY_VERSION && version != TIMEOUT_VERSION) - || items.len() != if has_timeout { 5 } else { 4 } - { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != TRANSFER_TRANSACTION_VERSION { return Err(Error::UnexpectedValue( "unsupported TransferTransaction version", )); @@ -102,18 +89,15 @@ impl TransferTransaction { let state_mask = items[2].bytes_value()?.to_vec(); let data = items[3].nullable(|d| d.bytes_value().map(|b| b.to_vec()).map_err(Into::into))?; - Ok(if has_timeout { - TransferTransaction::new_with_timeout( - source_state_hash, - lock_script, - recipient, - items[4].uint()?, - state_mask, - data, - ) - } else { - TransferTransaction::new(source_state_hash, lock_script, recipient, state_mask, data) - }) + let expires_at = items[4].nullable(|d| d.uint().map_err(Into::into))?; + Ok(TransferTransaction::new( + source_state_hash, + lock_script, + recipient, + state_mask, + data, + expires_at, + )) } } @@ -130,8 +114,8 @@ impl Transaction for TransferTransaction { &self.source_state_hash } - fn timeout(&self) -> Option { - self.timeout + fn expires_at(&self) -> Option { + self.expires_at } fn calculate_state_hash(&self) -> DataHash { @@ -142,22 +126,13 @@ impl Transaction for TransferTransaction { } fn to_cbor(&self) -> Vec { - let payload = if let Some(timeout) = self.timeout { - encode_array(&[ - &encode_uint(TIMEOUT_VERSION), - &self.recipient.to_cbor(), - &encode_byte_string(&self.state_mask), - &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - &encode_uint(timeout), - ]) - } else { - encode_array(&[ - &encode_uint(LEGACY_VERSION), - &self.recipient.to_cbor(), - &encode_byte_string(&self.state_mask), - &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), - ]) - }; + let payload = encode_array(&[ + &encode_uint(TRANSFER_TRANSACTION_VERSION), + &self.recipient.to_cbor(), + &encode_byte_string(&self.state_mask), + &encode_nullable(self.data.as_ref(), |v| encode_byte_string(v)), + &encode_nullable(self.expires_at.as_ref(), |v| encode_uint(*v)), + ]); encode_tag(TRANSFER_TRANSACTION_TAG, &payload) } } diff --git a/src/verify/mod.rs b/src/verify/mod.rs index e5bb053..5f4f820 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -176,7 +176,7 @@ fn verify_inclusion_proof( if certification_data.lock_script() != transaction.lock_script() || certification_data.source_state_hash() != transaction.source_state_hash() - || certification_data.timeout() != transaction.timeout() + || certification_data.expires_at() != transaction.expires_at() { return Err(VerificationError::CertificationDataMismatch); } @@ -229,9 +229,11 @@ pub fn verify_inclusion_proof_for( return Err(VerificationError::CertificationDataMismatch); } - // The request was admissible only in a round strictly before its timeout. - if let Some(timeout) = certification_data.timeout() { - if reference_time >= timeout { + // The request was admissible only in a round strictly before its deadline. A + // request that carried no deadline was admitted under a service-assigned + // one, which is not recorded and is not re-checked here. + if let Some(expires_at) = certification_data.expires_at() { + if reference_time >= expires_at { return Err(VerificationError::RequestExpired); } } @@ -541,12 +543,12 @@ mod tests { let state_id = StateId::derive(transaction.lock_script(), transaction.source_state_hash()); let root = leaf_root(&state_id, &calculate_leaf_value(&tx_hash, REFERENCE_TIME)); let unlock = sign_signature_unlock(owner, transaction.source_state_hash(), &tx_hash); - let certification_data = CertificationData::new_with_timeout( + let certification_data = CertificationData::new( transaction.lock_script().clone(), transaction.source_state_hash().clone(), tx_hash, - transaction.timeout().expect("explicit timeout fixture"), unlock, + Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { certification_data: Some(certification_data), @@ -557,13 +559,13 @@ mod tests { } fn make_transfer(owner: &Secp256k1Signer, recipient: &Secp256k1Signer) -> TransferTransaction { - TransferTransaction::new_with_timeout( + TransferTransaction::new( sha256(b"source-state"), SignaturePredicate::new(owner.public_key()).to_encoded(), SignaturePredicate::new(recipient.public_key()).to_encoded(), - TIMEOUT, alloc::vec![7u8; 32], None, + Some(TIMEOUT), ) } @@ -743,12 +745,12 @@ mod tests { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let stranger = signer(0xAB); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( SignaturePredicate::new(stranger.public_key()).to_encoded(), // wrong lock c.source_state_hash().clone(), c.transaction_hash().clone(), - TIMEOUT, c.unlock_script().to_vec(), + Some(TIMEOUT), )); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), @@ -760,12 +762,12 @@ mod tests { fn rule_certification_data_mismatch_source_state() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( c.lock_script().clone(), sha256(b"a-different-source-state"), // wrong source c.transaction_hash().clone(), - TIMEOUT, c.unlock_script().to_vec(), + Some(TIMEOUT), )); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), @@ -777,12 +779,12 @@ mod tests { fn rule_certification_data_mismatch_timeout() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), - TIMEOUT + 1, c.unlock_script().to_vec(), + Some(TIMEOUT + 1), )); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), @@ -803,12 +805,15 @@ mod tests { fn rule_transaction_hash_mismatch() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), - sha256(b"not-the-tx-hash"), // wrong tx hash - TIMEOUT, + sha256(b"not-the-tx-hash"), c.unlock_script().to_vec(), + Some( + // wrong tx hash + TIMEOUT, + ), )); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), @@ -889,12 +894,12 @@ mod tests { let c = cert(&proof); let mut unlock = c.unlock_script().to_vec(); unlock[0] ^= 0xff; // corrupt the signature (still 65 bytes) - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), - TIMEOUT, unlock, + Some(TIMEOUT), )); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), @@ -922,14 +927,14 @@ mod tests { justification: Option>, ) -> (RootTrustBase, MintTransaction, InclusionProof) { let recipient = signer(0x55); - let mint = MintTransaction::create_with_timeout( + let mint = MintTransaction::create( NetworkId::LOCAL, SignaturePredicate::new(recipient.public_key()).to_encoded(), - TIMEOUT, TokenType::new(alloc::vec![0xAA; 32]), TokenSalt::from_bytes([0x66; 32]), None, justification, + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -972,12 +977,12 @@ mod tests { // Replace the certified lock script with one that is not the minter key. let stranger = signer(0x77); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new_with_timeout( + proof.certification_data = Some(CertificationData::new( SignaturePredicate::new(stranger.public_key()).to_encoded(), c.source_state_hash().clone(), c.transaction_hash().clone(), - TIMEOUT, c.unlock_script().to_vec(), + Some(TIMEOUT), )); let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!( @@ -1076,13 +1081,13 @@ mod tests { let genesis = CertifiedMintTransaction::new(mint, genesis_proof); let recipient = signer(0x88); - let transfer = TransferTransaction::new_with_timeout( + let transfer = TransferTransaction::new( genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), - TIMEOUT, alloc::vec![9u8; 32], None, + Some(TIMEOUT), ); let mut transfer_proof = valid_proof(&transfer, &owner, &node); @@ -1124,13 +1129,13 @@ mod tests { let genesis = CertifiedMintTransaction::new(mint, genesis_proof.clone()); let recipient = signer(0x88); - let transfer = TransferTransaction::new_with_timeout( + let transfer = TransferTransaction::new( genesis.result_state_hash(), genesis.recipient().clone(), SignaturePredicate::new(recipient.public_key()).to_encoded(), - TIMEOUT, alloc::vec![9u8; 32], None, + Some(TIMEOUT), ); // The genesis proof does not attest to the transfer's transaction. diff --git a/tests/e2e.rs b/tests/e2e.rs index d8f9821..ba2ca88 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -56,29 +56,29 @@ fn e2e_mint_transfer_verify() { let alice = Secp256k1Signer::generate().unwrap(); let bob = Secp256k1Signer::generate().unwrap(); - let token = client::mint_with_timeout( + let token = client::mint( &aggregator, &trust_base, trust_base.network_id, &SignaturePredicate::new(alice.public_key()), - timeout, TokenType::random().unwrap(), TokenSalt::random().unwrap(), None, None, + Some(timeout), ) .expect("mint"); token.verify(&trust_base).expect("verify minted token"); - let transferred = client::transfer_with_timeout( + let transferred = client::transfer( &aggregator, &trust_base, &token, &SignaturePredicate::new(bob.public_key()), &alice, - timeout, StateMask::random().unwrap(), None, + Some(timeout), ) .expect("transfer"); transferred diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index f9329d6..263ed17 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -9,10 +9,11 @@ //! byte-for-byte, and reaches the same verification decisions (RSMT v6a, //! big-endian bit order). //! -//! The Alice/Bob/Carol tokens use the service-default request profile, which -//! carries no timeout and needs no client clock. `explicitTimeoutToken` uses -//! the explicit-timeout profile, so both encodings are covered by the same -//! cross-SDK vector. +//! The Alice/Bob/Carol tokens leave the request deadline to the service, so +//! their `expiresAt` is CBOR null and no client clock is involved. +//! `explicitTimeoutToken` carries a sender-chosen deadline. Both are the same +//! wire version with the same element count, so one vector covers the encoding +//! with and without a value in that slot. use unicity_token::api::bft::root_trust_base::RootTrustBaseNodeInfo; use unicity_token::api::bft::RootTrustBase; @@ -74,31 +75,31 @@ fn decodes_and_roundtrips_byte_for_byte() { } } -/// Both request profiles cross the SDK boundary: the default flow carries no -/// timeout, and the explicit one carries a deadline the round's reference time +/// Both cases cross the SDK boundary: the default flow carries no deadline, +/// and the explicit one carries a deadline the round's reference time /// is below. #[test] -fn covers_both_request_profiles() { +fn covers_present_and_absent_deadlines() { let tb = trust_base(); let (_, default_token) = token("aliceToken"); - assert_eq!(default_token.genesis().transaction().timeout(), None); - default_token.verify(&tb).expect("default profile verifies"); + assert_eq!(default_token.genesis().transaction().expires_at(), None); + default_token.verify(&tb).expect("absent deadline verifies"); let (_, explicit_token) = token("explicitTimeoutToken"); let genesis = explicit_token.genesis(); let timeout = genesis .transaction() - .timeout() - .expect("explicit profile carries a timeout"); + .expires_at() + .expect("explicit deadline is present"); assert!( genesis.reference_time() < timeout, - "certified reference time {} must precede the request timeout {timeout}", + "certified reference time {} must precede the request deadline {timeout}", genesis.reference_time() ); explicit_token .verify(&tb) - .expect("explicit profile verifies"); + .expect("explicit deadline verifies"); } #[test] @@ -246,23 +247,14 @@ fn rejects_mismatched_transfer_certification_state() { .certification_data .as_ref() .expect("fixture has certification data"); - // Rebuild in whichever request profile the fixture used, so only the - // substituted source state differs. - proof.certification_data = Some(match data.timeout() { - None => CertificationData::new( - data.lock_script().clone(), - sha256(b"unrelated source state"), - data.transaction_hash().clone(), - data.unlock_script().to_vec(), - ), - Some(timeout) => CertificationData::new_with_timeout( - data.lock_script().clone(), - sha256(b"unrelated source state"), - data.transaction_hash().clone(), - timeout, - data.unlock_script().to_vec(), - ), - }); + // Rebuild with the same fields, so only the substituted source state differs. + proof.certification_data = Some(CertificationData::new( + data.lock_script().clone(), + sha256(b"unrelated source state"), + data.transaction_hash().clone(), + data.unlock_script().to_vec(), + data.expires_at(), + )); let forged = Token::new( token.genesis().clone(), diff --git a/tests/vectors/transition_flow.json b/tests/vectors/transition_flow.json index dfef55a..7144bce 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,14 +1,14 @@ { - "__comment": "generated by state-transition-sdk-js 03632632ce61150ffe09f140e12670ff26812b03", + "__comment": "generated by state-transition-sdk-js: mint to Alice, transfer Alice -> Bob -> Carol", "trustBase": { "networkId": 3, "nodeId": "NODE", - "aggregatorPublicKey": "03079264c4b4bfcd7fe3a7b7b92b6c439f3a5b3abcd29189bf7b54d781ff03d722", + "aggregatorPublicKey": "03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe", "quorumThreshold": "1" }, - "aliceToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f40180", - "bobToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f4018183d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820cd5c6c2fb0f7f12596451a6fe5515651b55066a43ea5c24b203dde24c1a569f7f61a6a870a8fd998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858201d6a15772d417b78c4d5ef509efaa18381bb4899c94c8ebda48283e50070cb4b58206c67863f813bb018a4ee564328e9e89f2258fe980431daa517e50b47fb18b1005841e870c3791b77fda780dea07633f233fde2b2a58b3791654f0f80ede135591ad925b3148cd8f55fa56127a7b0371ff541754f749e606cfd346996e095076c539a011a6a870a8f58402000000000000000000000000000000000000000000000000000000000000000b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8d998598701d9985a8a010000f65820e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318401a6a870a90f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f9a878d647710831da5c25e4ae5cb975e896cafc7025a017e4d1c6062c59911ca1644e4f44455841c4188c38422e60facdfdf172d5e36283a404ceb7457914509563e30fba4a615f16b8fe8f45efdb1e8c060c96c2c03bf267b381c07856eb6cad44688a044907ca01", - "carolToken": "d99880830183d99881870103d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582039a1f12823992a3362cba489a8aab30b26c7de7aef41f6566f84fd54a06227dd58208b4a5de3015bd17a7d84050e511903e049850af0f74dcb8a48ded021ae6b88ecf6f61a6a870a8ed998798501d998778501d9987883014101582103938361518d25479c802d405dc56765a4ea9c653a7c40cd72ef7d98517558eabd5820f66088e199d1758a32651ee218be0b29e675d9d712112a4b591f9182d1c7978a5820380b5d6ef707d2556f5f2abc8c9847016861576897af6e60cd5b5ac666f92f91584106da7aea329818d5577b53e213783afb9c332e1fe7bfe77392b80931c8b8c2a62fe61ee7ba3522abbdd9b426a0469401af5e5f8709892fef15ba4312bfdb717b011a6a870a8e58200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8401a6a870a8ff600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c26b9c8738a2336a27dcff71b2c4689838c26ff6361704d1f0a4f6634bd07d7ea1644e4f444558411d568094e30133d02a7abc18ca8b89299610d48bdac83d142ce6fb57a3ef1a8e4fc15c3e7eae9d4558c866a71d815ce0c233e51def0ae2a46cc84756a37e38f4018283d998858401d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820cd5c6c2fb0f7f12596451a6fe5515651b55066a43ea5c24b203dde24c1a569f7f61a6a870a8fd998798501d998778501d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179858201d6a15772d417b78c4d5ef509efaa18381bb4899c94c8ebda48283e50070cb4b58206c67863f813bb018a4ee564328e9e89f2258fe980431daa517e50b47fb18b1005841e870c3791b77fda780dea07633f233fde2b2a58b3791654f0f80ede135591ad925b3148cd8f55fa56127a7b0371ff541754f749e606cfd346996e095076c539a011a6a870a8f58402000000000000000000000000000000000000000000000000000000000000000b26ee4dcdfdfe11d5c30bc7e7f3b7a6ec8050b28aa7733639be1ce73389d01f8d998598701d9985a8a010000f65820e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318401a6a870a90f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f9a878d647710831da5c25e4ae5cb975e896cafc7025a017e4d1c6062c59911ca1644e4f44455841c4188c38422e60facdfdf172d5e36283a404ceb7457914509563e30fba4a615f16b8fe8f45efdb1e8c060c96c2c03bf267b381c07856eb6cad44688a044907ca0183d998858401d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9582047faefdc46c39a10bf7eacd2ebd980020d7ce7f8a79e78847b82cf36c7a0ecdaf61a6a870a90d998798501d998778501d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558203d495ffb7ba8b62d37ee16340d886a09f4fabaa4cb01f2e233fb94cce2a27c8e5820a68d2781060ec943aca56ee96d0c128f6903c784e99d9c1dc821f08126ecaa0e58410b833e5de722182af675982b34eeb840cadb938146d4861201bd048a9b885ac73bdc9859a8500a594ba9d77d6f95bf34bcbede978e791d87617345175287529a001a6a870a9058408000000000000000000000000000000000000000000000000000000000000000e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318d998598701d9985a8a010000f65820083c58df2c06d5c5b6f51e689115cc9594c3ff52dc5e798b5b39165bc0f176a4401a6a870a91f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820f38d42718b7e38df7e55b5911709fd6457770f645f58203b5e4744b7b00797efa1644e4f44455841245c91a35e4879d4c78aa59ce950eae83dc320eeb560c63d9ee3ed02aced1a0a507035554e2f57347cd58a2a705cbb5fdf77470406c7d718741917e01e31033300", + "aliceToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a50180", + "bobToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a5018183d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5582008ff4aa567f859f2c0242d40e5caaabb7356b2aa4017505d6516004436667f01f6f61a6a874c12d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582031fc774936ce7d8adeae738ccf2e67df68d7013220bf24864cb1eba644cb1649582035d703101e48e723e8619ae6db52b3374c4017864b28b908efed189704e18a6df65841f1d75480c387a6551476ff275bd9232955901ede90dd17fc51467be066f4091b43a6aabf0bc70d81d5e5a326d78cf692b7335808a7ddb292b3733d5ad913540a011a6a874c1258402000000000000000000000000000000000000000000000000000000000000000188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490d998598701d9985a8a010000f65820f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1eb401a6a874c13f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658205ddb10b938842278df153691f8d0f480a050b52ef7a24814b2de764384d426f8a1644e4f444558412b1b340c65dc1cbe6e0f8b5aeed8b1d49f88f66c4e49dd7c69289e89e72540671e86c82bc49607707e42792424a849a8b01e592858fde6fcdb9e666d1010d83000", + "carolToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a5018283d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5582008ff4aa567f859f2c0242d40e5caaabb7356b2aa4017505d6516004436667f01f6f61a6a874c12d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582031fc774936ce7d8adeae738ccf2e67df68d7013220bf24864cb1eba644cb1649582035d703101e48e723e8619ae6db52b3374c4017864b28b908efed189704e18a6df65841f1d75480c387a6551476ff275bd9232955901ede90dd17fc51467be066f4091b43a6aabf0bc70d81d5e5a326d78cf692b7335808a7ddb292b3733d5ad913540a011a6a874c1258402000000000000000000000000000000000000000000000000000000000000000188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490d998598701d9985a8a010000f65820f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1eb401a6a874c13f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658205ddb10b938842278df153691f8d0f480a050b52ef7a24814b2de764384d426f8a1644e4f444558412b1b340c65dc1cbe6e0f8b5aeed8b1d49f88f66c4e49dd7c69289e89e72540671e86c82bc49607707e42792424a849a8b01e592858fde6fcdb9e666d1010d8300083d998858502d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f958207e02c5478e18cf299e4dcaf6996f42f887e2befecd19399ff692ef93fd7a4ed1f6f61a6a874c13d998798501d998778602d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820ad5c056d99670a25b4a719340223dca85e144652e49ad57ce3b88ce0b406c9db5820d92fdc569ccda1470e146df8d9b7afd7191f123aea2204797f462a8fb380e20ef65841736b6a5a3d22aeb35350c92662b59f7ce3048a41a7b04a811cf4aebfebebf9732cfa80933ad0fefa11f99391c3542d8e5e921b469188f593ab2e0cf1e3eca404011a6a874c1358408000000000000000000000000000000000000000000000000000000000000000f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1ebd998598701d9985a8a010000f658205f1abf5d8e804b71c23474d625ee1e7e9dad697581f6c779c257cbae8503b0d5401a6a874c14f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582082d3199f5cdb717f7599e7b46b99d0623a8d34e6e44357b92474ebc4610754c8a1644e4f44455841bc533d571265a3e49827093bd0da553c87018762b6b4ab6427562d9f90713d786a7bd3007be6415e8b29cf4694d4ce4f1dbce881de055306d14ea0feccb359ef00", "explicitTimeout": 1787321358, - "explicitTimeoutToken": "d99880830183d99881880203d9987883014101582102e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd135820938d4d6859192b96e772ce4a72f42a0f285780b4c21ea5e359f38c1c432a36b05820449eb8e4903030e402d74b0d1be0a8629a86d968de0bb6cde49beedb9f9424b9f6f61a6a885c0e1a6a870a91d998798501d998778602d9987883014101582102e41838ac5b2740da9f2cdd0bfb96bb9597df55135cdd75798ad73c6d43ad66ac5820618d7e908b188999b48090b0fb85efa54b92abbbbfd7094da8838c53aed8f7985820a5aaf8e0bb5fc97f72a1a54df93ec7faf247462270da1c88b51a4cd181038faf1a6a885c0e58411a81892142fdbb4d150f15dad43ba77c58d0ed6072061da13a79db524799a7f762c5ea3b3307bcbe1ba5daeaf97aed3fdab44e12a5db7cb2ca2c9e60643c294a001a6a870a915860c000000000000000000000000000000000000000000000000000000000000000e8ba91309a4f4da33f29f531fc7f655150a85f902635b50945d5a3561ff7e318d24ed7af1734b816404e1554d0ad344f213719e9cbbb2b8e30cfd49f15f5e25fd998598701d9985a8a010000f65820cd11b7cc8eec973b343aed919a50db5f3005bcf83a248222f17aa021aac85157401a6a870a92f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582057492ea149ad3fdf91c3468412ce2ae074c78cf4b7e90885ab33ed8279a32334a1644e4f444558418b2d4e9bb774149bb9034d19461fa2a58445865532d874a082e9c63eebd86ef6318bc4b696aa09b926dd71270afbb85925f731190cf4e535ac1077824ad25e6d0080" + "explicitTimeoutToken": "d99880830183d99881880203d9987883014101582102e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd1358204736924cc521182952c19380bd6c5a36316fe193b2688d423659e9d3621d10e35820767eefce993635a4fa7086fc5feef46fc8426da9e1533b1f730947955776f5bef6f61a6a885c0e1a6a874c14d998798501d998778602d9987883014101582103d048a89e11fe7ee9397456d1e6b52a9daed9adf0984825b8c270afccb240c5cd5820b445368641a6dd1b9dece2ef5bb95777d877d5ac55de557f0bcfeb361f1be3ba58206f0d897868c4e2ec5538276232707c4ba4a0ad1bafd0f585889588e88685bf571a6a885c0e5841ab2722fe0d6b1cadabeb792bb6d4a8f0726ccb6cadceb6b0b3de95ce3e5f92b902abe73771d9a7e61ee53b3468c4c0158f45346bf836782109b26169156c33b3001a6a874c145860c000000000000000000000000000000000000000000000000000000000000000f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1ebbab2fc01cf1d44470a18527063e35423d760b9a35163ae88df8833625915fbe0d998598701d9985a8a010000f6582053fd2f64027b521e86b1c472f360c05f9493384c7ab4e3533f3907140c0d6eb4401a6a874c15f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820bcb60f1b6a4301f2607cefb75b845b917028a34393ee80747a960332fe11dec5a1644e4f44455841a24e201c7b1001a483891eb9677699cfaeb90d989d764783dbf6bb778b0486d55de46f895198687d2807a99697ddf9e9c9c24e19abc114975101e559aeee32780180" } From 8bd9b215909805f86a025c893ede3a7949fd58a1 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 22:05:50 +0300 Subject: [PATCH 07/12] Pass the request deadline in the examples The examples are only built under --all-features, so the constructor signature change did not surface locally. --- README.md | 8 ++------ examples/mint.rs | 1 + examples/split.rs | 4 ++++ examples/transfer.rs | 2 ++ src/client/mod.rs | 6 ------ src/transaction/mint.rs | 4 ---- src/transaction/mod.rs | 6 ++---- src/transaction/transfer.rs | 5 ----- src/verify/mod.rs | 5 ----- 9 files changed, 11 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ad306d6..1a871ad 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,8 @@ let token = client::mint(&aggregator, &trust_base, trust_base.network_id, /* expires_at */ None)?; ``` -`expires_at` is the exclusive request deadline in Unix seconds. Pass `None` to let the Unicity -Service assign one from consensus time, which requires no local clock; pass `Some(deadline)` when -the application needs its own. Either way the value is committed by the transaction hash. +`expires_at` is the exclusive request deadline in Unix seconds. Use `None` to let the Unicity +Service assign a default one from consensus time, or explicitly `Some(deadline)`. 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 @@ -67,9 +66,6 @@ consuming the polling budget. ## Prove that a state is absent -Non-inclusion has a relation-specific API; applications never need to know -that its Merkle path has internal machinery in common with inclusion: - ```rust use unicity_token::client::NonInclusionAggregatorClient; diff --git a/examples/mint.rs b/examples/mint.rs index ab8ae82..026fff7 100644 --- a/examples/mint.rs +++ b/examples/mint.rs @@ -68,6 +68,7 @@ fn main() { TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), None, + /* expires_at */ None, ) .expect("mint"); diff --git a/examples/split.rs b/examples/split.rs index 8729df3..0dfc5ec 100644 --- a/examples/split.rs +++ b/examples/split.rs @@ -102,6 +102,7 @@ fn mint_split_output( out.salt.clone(), Some(out.assets.to_cbor()), Some(justification.to_cbor()), + /* expires_at */ None, ) .expect("build split output mint"); @@ -176,6 +177,7 @@ fn main() { TokenSalt::random().expect("salt"), Some(source_payment.to_cbor()), None, + /* expires_at */ None, ) .expect("mint source coin"); @@ -212,6 +214,7 @@ fn main() { PaymentAssetCollection::from_cbor_bytes, requests, Some(BURN_STATE_MASK), + /* expires_at */ None, ) .expect("build split"); @@ -226,6 +229,7 @@ fn main() { &alice, StateMask::from_bytes(BURN_STATE_MASK), Some(split.burn.manifest.clone()), + /* expires_at */ None, ) .expect("burn source coin"); diff --git a/examples/transfer.rs b/examples/transfer.rs index fa68229..058b409 100644 --- a/examples/transfer.rs +++ b/examples/transfer.rs @@ -64,6 +64,7 @@ fn main() { TokenSalt::random().expect("salt"), Some(encode_text_string("My custom data")), None, + /* expires_at */ None, ) .expect("mint"); @@ -82,6 +83,7 @@ fn main() { &alice, StateMask::random().expect("state mask"), Some(encode_text_string("My custom transfer data")), + /* expires_at */ None, ) .expect("transfer"); diff --git a/src/client/mod.rs b/src/client/mod.rs index 88c79b7..94f38d8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -117,9 +117,6 @@ pub fn certification_data_for( } /// Mint a new token and return the verified [`Token`]. -/// -/// `expires_at` is the exclusive request deadline in Unix seconds, or `None` to -/// let the Unicity Service assign one, which requires no local clock. #[allow(clippy::too_many_arguments)] pub fn mint( aggregator: &A, @@ -169,9 +166,6 @@ pub fn mint( /// Transfer `token` to `recipient`, authorised by `signer` (the current /// owner's key), and return the verified successor [`Token`]. -/// -/// `expires_at` is the exclusive request deadline in Unix seconds, or `None` to -/// let the Unicity Service assign one, which requires no local clock. #[allow(clippy::too_many_arguments)] pub fn transfer( aggregator: &A, diff --git a/src/transaction/mint.rs b/src/transaction/mint.rs index 1f236c9..d149902 100644 --- a/src/transaction/mint.rs +++ b/src/transaction/mint.rs @@ -41,10 +41,6 @@ pub struct MintTransaction { impl MintTransaction { /// Build a mint transaction, deriving the token id, lock script, and mint /// state. - /// - /// `expires_at` is the exclusive request deadline in Unix seconds, or - /// `None` to let the Unicity Service assign one, which requires no local - /// clock. Either way it is committed by the transaction hash. #[allow(clippy::too_many_arguments)] pub fn create( network_id: NetworkId, diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index dce17f7..172a96e 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -29,10 +29,8 @@ pub trait Transaction { fn source_state_hash(&self) -> &DataHash; /// The hash of the state this transaction produces. fn calculate_state_hash(&self) -> DataHash; - /// Exclusive timeout of the certification request. The Unicity Service - /// admits the request only in a round whose reference time is below this - /// value. It is part of the transaction encoding, so the transaction hash - /// commits to it and the unlock script signs it. + /// The Unicity Service admits the request only in a round whose reference + /// time is below this value. fn expires_at(&self) -> Option; /// CBOR encoding (tagged). fn to_cbor(&self) -> Vec; diff --git a/src/transaction/transfer.rs b/src/transaction/transfer.rs index f62f673..e9fb73a 100644 --- a/src/transaction/transfer.rs +++ b/src/transaction/transfer.rs @@ -18,7 +18,6 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`TransferTransaction`]. pub const TRANSFER_TRANSACTION_TAG: u64 = 39045; -/// The only accepted wire version. One version, one element count. pub const TRANSFER_TRANSACTION_VERSION: u64 = 2; const FIELD_COUNT: usize = 5; @@ -39,10 +38,6 @@ impl TransferTransaction { /// Construct a transfer from explicit parts. `source_state_hash` and /// `lock_script` come from the previous transaction's resulting state / /// recipient. - /// - /// `expires_at` is the exclusive request deadline in Unix seconds, or - /// `None` to let the Unicity Service assign one, which requires no local - /// clock. Either way it is committed by the transaction hash. pub fn new( source_state_hash: DataHash, lock_script: EncodedPredicate, diff --git a/src/verify/mod.rs b/src/verify/mod.rs index 5f4f820..efbfef5 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -198,11 +198,6 @@ fn verify_inclusion_proof( /// /// This verifies the proof's certification data and witness, but does not claim /// that its transaction hash belongs to a caller-supplied transaction object. -/// -/// `reference_time` is the value the certified leaf was built from. It comes -/// from the caller, not from the proof's own unicity certificate: the tree is -/// append-only, so the proof may have been issued against a later root whose -/// input record carries a later reference time. pub fn verify_inclusion_proof_for( trust_base: &RootTrustBase, proof: &InclusionProof, From e0fdd82671f33478dec991007aae9da0502386d1 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Thu, 20 Aug 2026 23:22:20 +0300 Subject: [PATCH 08/12] Mirror the JS review feedback Two of the three defects the TypeScript review found apply here. The redundant presence guard does not: `reference_time != Some(x)` was already the idiomatic comparison, and the decoders already returned Error::UnexpectedValue rather than a generic error. - VerificationError::MissingReferenceTime fires when the proof's reference time differs from the one the transition carries; its only site is that inequality. Renamed to ReferenceTimeMismatch, which is what it detects. An absent reference time still lands there, since it cannot match. - InclusionProof's certification data, reference time and inclusion certificate describe a leaf and belong together: all three are present once the request is in a certified round, and all three are absent while it is pending. from_cbor now rejects any proof carrying some but not all of them. --- src/api/inclusion_proof.rs | 13 +++++++++++++ src/verify/error.rs | 9 +++++---- src/verify/mod.rs | 38 +++++++++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/api/inclusion_proof.rs b/src/api/inclusion_proof.rs index a9552a8..7bdda06 100644 --- a/src/api/inclusion_proof.rs +++ b/src/api/inclusion_proof.rs @@ -48,6 +48,19 @@ impl InclusionProof { let reference_time = items[2].nullable(|x| x.uint().map_err(Into::into))?; let inclusion_certificate = items[3].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; + + // A proof either establishes a leaf or reports that there is none yet. A + // partially present proof is neither, and would let a caller reach a leaf + // check with a reference time nothing certified. + let present = certification_data.is_some() as u8 + + reference_time.is_some() as u8 + + inclusion_certificate.is_some() as u8; + if present != 0 && present != 3 { + return Err(Error::UnexpectedValue( + "InclusionProof must carry certification data, reference time and inclusion certificate together, or none of them", + )); + } + Ok(InclusionProof { certification_data, reference_time, diff --git a/src/verify/error.rs b/src/verify/error.rs index 35bfc12..00cc24c 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -66,8 +66,9 @@ pub enum VerificationError { CertificationDataMismatch, /// The certified transaction hash does not match the recomputed one. TransactionHashMismatch, - /// The inclusion proof omitted or disagreed on the leaf creation reference time. - MissingReferenceTime, + /// The inclusion proof's reference time differs from the one the transition carries. + /// An absent reference time also lands here, since it cannot match. + ReferenceTimeMismatch, /// The round's reference time had already reached the request's timeout. RequestExpired, /// The sparse-Merkle-tree path did not reproduce the expected root. @@ -180,8 +181,8 @@ impl fmt::Display for VerificationError { write!(f, "certification data does not match transaction state") } VerificationError::TransactionHashMismatch => write!(f, "transaction hash mismatch"), - VerificationError::MissingReferenceTime => { - write!(f, "inclusion proof reference time missing or mismatched") + VerificationError::ReferenceTimeMismatch => { + write!(f, "inclusion proof reference time mismatch") } VerificationError::RequestExpired => write!(f, "certification request expired"), VerificationError::PathInvalid => write!(f, "inclusion path invalid"), diff --git a/src/verify/mod.rs b/src/verify/mod.rs index efbfef5..e5d9802 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -238,8 +238,9 @@ pub fn verify_inclusion_proof_for( proof.unicity_certificate.input_record.hash.clone(), ) .map_err(|_| VerificationError::PathInvalid)?; + // An absent reference time on the proof also fails this comparison. if proof.reference_time != Some(reference_time) { - return Err(VerificationError::MissingReferenceTime); + return Err(VerificationError::ReferenceTimeMismatch); } let leaf_value = calculate_leaf_value(certification_data.transaction_hash(), reference_time); if !inclusion_certificate.verify(state_id, &leaf_value, &expected_root) { @@ -787,6 +788,41 @@ mod tests { ); } + /// A proof either establishes a leaf or reports that there is none yet. The + /// aggregators emit all three leaf fields together or none of them, so a + /// partially present proof is a protocol violation and is rejected at + /// decode rather than surfacing as a None somewhere downstream. + #[test] + fn rejects_a_partially_present_proof() { + let (_tb, _n, _o, _transfer, proof) = transfer_case(); + + for partial in [ + InclusionProof { + inclusion_certificate: None, + ..proof.clone() + }, + InclusionProof { + reference_time: None, + ..proof.clone() + }, + InclusionProof { + certification_data: None, + ..proof.clone() + }, + InclusionProof { + certification_data: None, + reference_time: None, + ..proof.clone() + }, + ] { + let bytes = partial.to_cbor(); + assert!(matches!( + InclusionProof::from_cbor(Decoder::new(&bytes)), + Err(crate::error::Error::UnexpectedValue(_)) + )); + } + } + #[test] fn rule_request_expires_at_timeout_boundary() { let (tb, _n, _o, transfer, proof) = transfer_case(); From 4d431aa1cef71b2e9a5f187091dfbf0f6144e0a8 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Fri, 21 Aug 2026 00:08:51 +0300 Subject: [PATCH 09/12] Work against aggregator-go, and move off its error-code range Two things kept the HTTP client tied to rugregator. The pending signal. Polling accepted only rugregator's explicit INCLUSION_PENDING status and treated a successful response with absent leaf fields as a protocol error. That empty response is how aggregator-go reports a leaf that is not certified yet, so the client failed on the first poll against it. Accept both. get_inclusion_proof.v2 never answers with a non-inclusion proof -- that is get_non_inclusion_proof.v1 -- so an empty response on this method is unambiguous. Only the explicit status carries the extra information, letting the client separate "not yet" from "no such state"; against a server without it an unknown state id polls to the attempt limit, which is the pre-existing behaviour. The codes. -32001, -32002 and -32003 are inside the range aggregator-go reserves for its own errors, where they mean CommitmentExists, CommitmentNotFound and BlockNotFound. None are emitted today, so nothing was misreading them yet, but a BlockNotFound would have been swallowed as "pending" and polled to timeout. Move to -32020..-32022, leaving -32000..-32019 to implementation-private codes and reserving -32020..-32039 for states that describe the protocol, so another aggregator can adopt them verbatim. Non-inclusion proofs remain unsupported by aggregator-go; that is a missing method there, not an incompatibility here. --- README.md | 11 +++++++--- src/client/http.rs | 27 ++++++++++++++++++----- tests/http_transport.rs | 47 +++++++++++++++++++++++++++++++++++------ 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1a871ad..26456b0 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,14 @@ Service assign a default one from consensus time, or explicitly `Some(deadline)` 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. -Inclusion polling is limited to the server's explicit `-32003` pending status; -an unknown StateID fails immediately as `HttpError::StateNotFound` instead of -consuming the polling budget. +Inclusion polling accepts either way a server reports a leaf that is not +certified yet: an explicit `-32021` pending status, or a successful response +whose leaf fields are absent. `get_inclusion_proof.v2` never answers with a +non-inclusion proof, so the empty response is unambiguous. Only the explicit +status lets the client tell "not yet" apart from "no such state": against a +server that reports it, an unknown StateID fails immediately as +`HttpError::StateNotFound` rather than consuming the polling budget; against one +that does not, it polls to the attempt limit. ## Prove that a state is absent diff --git a/src/client/http.rs b/src/client/http.rs index 69ea22d..959f2c3 100644 --- a/src/client/http.rs +++ b/src/client/http.rs @@ -26,8 +26,14 @@ use super::{AggregatorClient, MembershipStatus, NonInclusionAggregatorClient}; const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024; const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; const MAX_PROOF_HEX_CHARS: usize = MAX_RESPONSE_BODY_BYTES - 1024; -const RPC_STATE_INCLUDED: i64 = -32002; -const RPC_INCLUSION_PENDING: i64 = -32003; +// Shared proof-lookup states. JSON-RPC 2.0 reserves -32000..-32099 for +// implementation-defined server errors; the Unicity aggregators keep +// -32000..-32019 for implementation-private codes (aggregator-go allocates +// -32000..-32006 there) and -32020..-32039 for these, which describe the +// protocol rather than one server. +const RPC_STATE_NOT_FOUND: i64 = -32020; +const RPC_INCLUSION_PENDING: i64 = -32021; +const RPC_STATE_INCLUDED: i64 = -32022; /// Errors from the HTTP aggregator client. #[derive(Debug)] @@ -402,6 +408,9 @@ impl AggregatorClient for HttpAggregatorClient { } continue; } + Err(HttpError::Rpc { code, .. }) if code == RPC_STATE_NOT_FOUND => { + return Err(HttpError::StateNotFound); + } Err(HttpError::Http { status: 404, .. }) => { return Err(HttpError::StateNotFound); } @@ -418,10 +427,18 @@ impl AggregatorClient for HttpAggregatorClient { let bytes = hex::decode(encoded).map_err(|e| HttpError::Decode(e.to_string()))?; let proof = decode_inclusion_proof_response(&bytes)?; + // An empty proof on this method means the leaf is not certified yet. + // `get_inclusion_proof.v2` never answers with a non-inclusion proof + // (that is `get_non_inclusion_proof.v1`), so the empty response is + // unambiguous and is aggregator-go's way of reporting pending. An + // aggregator that sends the explicit INCLUSION_PENDING code above + // never reaches this branch, and only that one lets the client tell + // "not yet" apart from "no such state". if proof.certification_data.is_none() || proof.inclusion_certificate.is_none() { - return Err(HttpError::Decode( - "inclusion proof is missing required relation data".to_string(), - )); + if attempt + 1 < self.poll_attempts { + std::thread::sleep(self.poll_interval); + } + continue; } return Ok(proof); } diff --git a/tests/http_transport.rs b/tests/http_transport.rs index 5948dd2..a597e13 100644 --- a/tests/http_transport.rs +++ b/tests/http_transport.rs @@ -57,6 +57,20 @@ fn proof_response_hex(proof: &InclusionProof) -> String { hex::encode(body) } +/// The same wrapper for a leaf that is not certified yet: the three leaf fields +/// are absent together, which is what aggregator-go returns while pending. +fn empty_proof_response_hex(proof: &InclusionProof) -> String { + let pending = InclusionProof { + certification_data: None, + reference_time: None, + inclusion_certificate: None, + unicity_certificate: proof.unicity_certificate.clone(), + }; + let block = encode_uint(7); + let body = encode_array(&[block.as_slice(), pending.to_cbor().as_slice()]); + hex::encode(body) +} + fn fixture_non_inclusion_proof() -> NonInclusionProof { let (inclusion, _) = fixture_proof_and_data(); NonInclusionProof::new( @@ -348,7 +362,7 @@ fn get_inclusion_proof_rejects_incomplete_response_without_polling() { #[test] fn get_inclusion_proof_fails_fast_for_unknown_state() { - let body = r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"not found"}}"#; + let body = r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32020,"message":"not found"}}"#; let server = MockServer::start(vec![http_response("404 Not Found", body)]); let (_, data) = fixture_proof_and_data(); let state_id = StateId::derive(data.lock_script(), data.source_state_hash()); @@ -360,12 +374,33 @@ fn get_inclusion_proof_fails_fast_for_unknown_state() { assert_eq!(server.request_count(), 1); } +/// aggregator-go reports a pending leaf in band, as a successful response whose +/// certification data and inclusion certificate are absent. `get_inclusion_proof +/// .v2` never answers with a non-inclusion proof, so that response is +/// unambiguous and the client keeps polling on it. +#[test] +fn get_inclusion_proof_polls_an_in_band_pending_response() { + let (proof, data) = fixture_proof_and_data(); + let pending = ok_json(&format!("\"{}\"", empty_proof_response_hex(&proof))); + let complete = ok_json(&format!("\"{}\"", proof_response_hex(&proof))); + let server = MockServer::start(vec![pending.clone(), pending, complete]); + let state_id = StateId::derive(data.lock_script(), data.source_state_hash()); + + let got = client(&server.url) + .get_inclusion_proof(&state_id) + .expect("pending request should eventually resolve"); + assert_eq!(got, proof); + assert_eq!(server.request_count(), 3); +} + +/// rugregator reports it out of band instead, which additionally lets the client +/// tell "not yet" apart from "no such state". #[test] -fn get_inclusion_proof_polls_only_explicit_pending_status() { +fn get_inclusion_proof_polls_an_explicit_pending_status() { let (proof, data) = fixture_proof_and_data(); let pending = http_response( "200 OK", - r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32003,"message":"certification is pending"}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32021,"message":"certification is pending"}}"#, ); let complete = ok_json(&format!("\"{}\"", proof_response_hex(&proof))); let server = MockServer::start(vec![pending.clone(), pending, complete]); @@ -417,7 +452,7 @@ fn membership_status_hides_relation_endpoint_selection() { let (included_proof, _) = fixture_proof_and_data(); let relation_false = http_response( "200 OK", - r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32002,"message":"state is already included"}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32022,"message":"state is already included"}}"#, ); let included = MockServer::start(vec![ relation_false, @@ -436,7 +471,7 @@ fn non_inclusion_lookup_maps_false_relation_and_missing_root() { let included = MockServer::start(vec![http_response( "200 OK", - r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32002,"message":"state is already included"}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32022,"message":"state is already included"}}"#, )]); assert!(matches!( client(&included.url).get_non_inclusion_proof(&state_id), @@ -445,7 +480,7 @@ fn non_inclusion_lookup_maps_false_relation_and_missing_root() { let unavailable = MockServer::start(vec![http_response( "404 Not Found", - r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"not found"}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32020,"message":"not found"}}"#, )]); assert!(matches!( client(&unavailable.url).get_non_inclusion_proof(&state_id), From 783c23a10ffd71840ae0738235c041f68f54720f Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Tue, 25 Aug 2026 13:03:12 +0300 Subject: [PATCH 10/12] fix e2e request deadline arguments --- e2e/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/src/main.rs b/e2e/src/main.rs index 8788b5b..d957ea2 100644 --- a/e2e/src/main.rs +++ b/e2e/src/main.rs @@ -56,11 +56,11 @@ fn main() -> Result<(), Box> { &trust_base, trust_base.network_id, &alice_lock, - request_timeout()?, TokenType::random()?, TokenSalt::random()?, Some(encode_text_string("Rust SDK live e2e mint")), None, + Some(request_timeout()?), )?; let minted_path = config.output_dir.join("token-minted.cbor"); @@ -76,9 +76,9 @@ fn main() -> Result<(), Box> { &minted, &bob_lock, &alice, - request_timeout()?, StateMask::random()?, Some(encode_text_string("Rust SDK live e2e transfer")), + Some(request_timeout()?), )?; let transferred_path = config.output_dir.join("token-transferred.cbor"); From 175fae549d3969b8b780743f29139ed164b987ce Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Mon, 31 Aug 2026 12:36:27 +0300 Subject: [PATCH 11/12] Match the shipped SDKs on the wire, and make an inclusion proof total The cross-SDK fixture this crate tests against had been regenerated from an unreleased intermediate state of the TypeScript SDK rather than from a tag, so it drifted together with the code and every test kept passing. Regenerating it from state-transition-sdk-js v3.0.1 shows two divergences no test could have caught, because the fixture agreed with the bug. Token was still at wire version 1. The TypeScript and Java SDKs moved it to 2 in 3.0.0 alongside MintTransaction, TransferTransaction and CertificationData, and Token.fromCBOR rejects a version-1 token outright. Anything this crate minted was unreadable by either of them, and anything they minted was unreadable here. Certified transactions carried three elements, [transaction, referenceTime, inclusionProof]. Both other SDKs encode two and read the reference time off the proof, which is the only copy consensus certified. The separate slot could only agree with the proof or be wrong, and both decoders here held a guard checking exactly that. The slot is gone and reference_time() reads the proof. InclusionProof now requires every field, mirroring js#151 and java#84. The absence of a certified leaf belongs to the aggregator's answer, not to the proof, so InclusionProofResponse owns the wire's two shapes: it decodes the tagged structure, decides certified from not, rejects a partially present proof, and builds the InclusionProof from the parts. That removes the four Option fields, the casts on the reference time, and the absence branches at the top of both verification rules. InclusionCertificateMissing and CertificationDataMissing are gone from VerificationError because neither can occur. The http transport tests are gated behind a non-default feature, so `cargo test` never compiled them and CI never ran them; they went stale unnoticed. CI now runs `cargo test --all-features` as well. tests/vectors/ gains the generator and a README, because regenerating a fixture by hand from whatever the reference SDK happened to be is how this drifted. Refs #16 Refs #17 --- .github/workflows/ci.yml | 4 + src/api/inclusion_proof.rs | 138 ++++++++++++++++++-------- src/api/inclusion_proof_response.rs | 116 ++++++++++++++++++++++ src/api/mod.rs | 2 + src/client/http.rs | 41 ++++---- src/payment/tests.rs | 6 +- src/transaction/certified.rs | 62 ++++-------- src/transaction/token.rs | 2 +- src/verify/error.rs | 9 -- src/verify/mod.rs | 149 ++++++++++++---------------- tests/http_transport.rs | 69 ++++++++----- tests/transition_flow.rs | 53 ++++++++-- tests/vectors/README.md | 40 ++++++++ tests/vectors/generate-vector.ts | 123 +++++++++++++++++++++++ tests/vectors/transition_flow.json | 24 ++--- 15 files changed, 582 insertions(+), 256 deletions(-) create mode 100644 src/api/inclusion_proof_response.rs create mode 100644 tests/vectors/README.md create mode 100644 tests/vectors/generate-vector.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39eb65..d58da7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,10 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Test (default features) run: cargo test + - name: Test (all features) + # The `http` transport tests and the live-e2e target are gated behind a + # non-default feature, so `cargo test` alone never compiles or runs them. + run: cargo test --all-features - name: Test (no default features, verification core) run: cargo test --no-default-features --features alloc diff --git a/src/api/inclusion_proof.rs b/src/api/inclusion_proof.rs index 7bdda06..926761d 100644 --- a/src/api/inclusion_proof.rs +++ b/src/api/inclusion_proof.rs @@ -7,66 +7,51 @@ use super::bft::{RootTrustBase, UnicityCertificate}; use super::certification::CertificationData; use super::inclusion_certificate::InclusionCertificate; use super::StateId; -use crate::cbor::{ - encode_array, encode_byte_string, encode_nullable, encode_tag, encode_uint, Decoder, -}; +use crate::cbor::{encode_array, encode_byte_string, encode_tag, encode_uint, Decoder}; use crate::error::Error; use crate::verify::{self, VerificationError}; /// CBOR tag for [`InclusionProof`]. pub const INCLUSION_PROOF_TAG: u64 = 39033; -const VERSION: u64 = 1; +pub(super) const VERSION: u64 = 1; /// A proof of inclusion in the sparse Merkle tree, plus the unicity certificate /// that anchors the tree root to the BFT consensus. +/// +/// An `InclusionProof` describes a certified leaf, so every field is present. +/// The aggregator's answer for a state it has not certified yet is not an +/// inclusion proof at all: see [`InclusionProofResponse`], which is the type +/// that can express it. +/// +/// [`InclusionProofResponse`]: super::InclusionProofResponse #[derive(Debug, Clone, PartialEq, Eq)] pub struct InclusionProof { - /// What was certified (present for an inclusion proof). - pub certification_data: Option, - /// Reference time of the round the certified leaf was created in (present - /// for an inclusion proof). + /// What was certified. + pub certification_data: CertificationData, + /// Reference time of the round the certified leaf was created in. /// /// It cannot be recovered from the certificate chain: an aggregator serves /// proofs against the current certified root, whose input record time is /// that of the latest round rather than the one the leaf was created under. - pub reference_time: Option, - /// The SMT path (present for an inclusion proof). - pub inclusion_certificate: Option, + pub reference_time: u64, + /// The SMT path. + pub inclusion_certificate: InclusionCertificate, /// The BFT unicity certificate. pub unicity_certificate: UnicityCertificate, } impl InclusionProof { /// Decode from CBOR (tagged). + /// + /// The bytes must describe a certified leaf. The same wire form can also + /// say that no leaf is certified yet, but that is not an `InclusionProof`: + /// [`InclusionProofResponse`] is the type that carries it, and it decodes + /// that case itself. + /// + /// [`InclusionProofResponse`]: super::InclusionProofResponse pub fn from_cbor(d: Decoder<'_>) -> Result { - let inner = d.expect_tag(INCLUSION_PROOF_TAG)?; - let items = inner.array(Some(5))?; - if items[0].uint()? != VERSION { - return Err(Error::UnexpectedValue("unsupported InclusionProof version")); - } - let certification_data = items[1].nullable(CertificationData::from_cbor)?; - let reference_time = items[2].nullable(|x| x.uint().map_err(Into::into))?; - let inclusion_certificate = - items[3].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; - - // A proof either establishes a leaf or reports that there is none yet. A - // partially present proof is neither, and would let a caller reach a leaf - // check with a reference time nothing certified. - let present = certification_data.is_some() as u8 - + reference_time.is_some() as u8 - + inclusion_certificate.is_some() as u8; - if present != 0 && present != 3 { - return Err(Error::UnexpectedValue( - "InclusionProof must carry certification data, reference time and inclusion certificate together, or none of them", - )); - } - - Ok(InclusionProof { - certification_data, - reference_time, - inclusion_certificate, - unicity_certificate: UnicityCertificate::from_cbor(items[4])?, - }) + let parts = DecodedParts::from_cbor(d)?; + parts.into_certified() } /// Encode to CBOR (tagged). @@ -75,11 +60,9 @@ impl InclusionProof { INCLUSION_PROOF_TAG, &encode_array(&[ &encode_uint(VERSION), - &encode_nullable(self.certification_data.as_ref(), |c| c.to_cbor()), - &encode_nullable(self.reference_time.as_ref(), |t| encode_uint(*t)), - &encode_nullable(self.inclusion_certificate.as_ref(), |c| { - encode_byte_string(&c.encode()) - }), + &self.certification_data.to_cbor(), + &encode_uint(self.reference_time), + &encode_byte_string(&self.inclusion_certificate.encode()), &self.unicity_certificate.to_cbor(), ]), ) @@ -103,3 +86,70 @@ impl InclusionProof { verify::verify_inclusion_proof_for(trust_base, self, state_id, reference_time) } } + +/// The five wire slots of a tag-39033 structure, with the three leaf fields +/// still optional. +/// +/// Shared by [`InclusionProof::from_cbor`] and the response decoder so that the +/// tag, version and "all three or none" checks exist once. +pub(super) struct DecodedParts { + pub(super) certification_data: Option, + pub(super) reference_time: Option, + pub(super) inclusion_certificate: Option, + pub(super) unicity_certificate: UnicityCertificate, +} + +impl DecodedParts { + pub(super) fn from_cbor(d: Decoder<'_>) -> Result { + let inner = d.expect_tag(INCLUSION_PROOF_TAG)?; + let items = inner.array(Some(5))?; + if items[0].uint()? != VERSION { + return Err(Error::UnexpectedValue("unsupported InclusionProof version")); + } + let certification_data = items[1].nullable(CertificationData::from_cbor)?; + let reference_time = items[2].nullable(|x| x.uint().map_err(Into::into))?; + let inclusion_certificate = + items[3].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; + + // The three leaf fields travel together: all present once the request + // has been included in a certified round, all absent while it is still + // pending. Anything in between is a protocol violation, and rejecting + // it here is what lets `InclusionProof` require all three. + let present = certification_data.is_some() as u8 + + reference_time.is_some() as u8 + + inclusion_certificate.is_some() as u8; + if present != 0 && present != 3 { + return Err(Error::UnexpectedValue( + "InclusionProof must carry certification data, reference time and inclusion certificate together, or none of them", + )); + } + + Ok(DecodedParts { + certification_data, + reference_time, + inclusion_certificate, + unicity_certificate: UnicityCertificate::from_cbor(items[4])?, + }) + } + + /// Require a certified leaf. + pub(super) fn into_certified(self) -> Result { + match ( + self.certification_data, + self.reference_time, + self.inclusion_certificate, + ) { + (Some(certification_data), Some(reference_time), Some(inclusion_certificate)) => { + Ok(InclusionProof { + certification_data, + reference_time, + inclusion_certificate, + unicity_certificate: self.unicity_certificate, + }) + } + _ => Err(Error::UnexpectedValue( + "expected a certified leaf, but the inclusion proof describes none", + )), + } + } +} diff --git a/src/api/inclusion_proof_response.rs b/src/api/inclusion_proof_response.rs new file mode 100644 index 0000000..b6051c4 --- /dev/null +++ b/src/api/inclusion_proof_response.rs @@ -0,0 +1,116 @@ +//! What the aggregator answers when asked about a state. + +use alloc::vec::Vec; + +use super::bft::UnicityCertificate; +use super::inclusion_proof::{DecodedParts, INCLUSION_PROOF_TAG, VERSION}; +use super::InclusionProof; +use crate::cbor::{encode_array, encode_null, encode_tag, encode_uint, Decoder}; +use crate::error::Error; + +/// The aggregator's answer about a state: a certified leaf, or the absence of +/// one. +/// +/// This is the wire shape, and it has two forms. Keeping that distinction here +/// rather than inside [`InclusionProof`] is what lets the proof itself be +/// complete by construction: a verifier holding one never has to ask whether it +/// describes a leaf. +// `Certified` is larger than `NotCertified` by the certification data and the +// inclusion certificate (216 bytes). Boxing the proof would only invert the +// imbalance, since `NotCertified` still carries a whole unicity certificate, +// and it would cost an allocation on every proof lookup. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InclusionProofResponse { + /// The aggregator has certified this state. + /// + /// The round it was served against is the proof's own, so there is no + /// second certificate to supply and none that could disagree with it. + Certified { + /// Block number the answer was served at. + block_number: u64, + /// The certified leaf. + proof: InclusionProof, + }, + /// The aggregator has not certified this state yet, so only the round is + /// meaningful. + NotCertified { + /// Block number the answer was served at. + block_number: u64, + /// Certificate of the round the answer was served against. + unicity_certificate: UnicityCertificate, + }, +} + +impl InclusionProofResponse { + /// Decode the `[blockNumber, InclusionProof]` response payload. + pub fn from_cbor(d: Decoder<'_>) -> Result { + let items = d.array(Some(2))?; + let block_number = items[0].uint()?; + let parts = DecodedParts::from_cbor(items[1])?; + + if parts.certification_data.is_none() { + return Ok(InclusionProofResponse::NotCertified { + block_number, + unicity_certificate: parts.unicity_certificate, + }); + } + Ok(InclusionProofResponse::Certified { + block_number, + proof: parts.into_certified()?, + }) + } + + /// Encode to CBOR. + pub fn to_cbor(&self) -> Vec { + match self { + InclusionProofResponse::Certified { + block_number, + proof, + } => encode_array(&[&encode_uint(*block_number), &proof.to_cbor()]), + InclusionProofResponse::NotCertified { + block_number, + unicity_certificate, + } => encode_array(&[ + &encode_uint(*block_number), + &encode_tag( + INCLUSION_PROOF_TAG, + &encode_array(&[ + &encode_uint(VERSION), + &encode_null(), + &encode_null(), + &encode_null(), + &unicity_certificate.to_cbor(), + ]), + ), + ]), + } + } + + /// Block number the answer was served at. + pub fn block_number(&self) -> u64 { + match self { + InclusionProofResponse::Certified { block_number, .. } + | InclusionProofResponse::NotCertified { block_number, .. } => *block_number, + } + } + + /// The certified leaf, if there is one. + pub fn inclusion_proof(&self) -> Option<&InclusionProof> { + match self { + InclusionProofResponse::Certified { proof, .. } => Some(proof), + InclusionProofResponse::NotCertified { .. } => None, + } + } + + /// Certificate of the round the answer was served against. + pub fn unicity_certificate(&self) -> &UnicityCertificate { + match self { + InclusionProofResponse::Certified { proof, .. } => &proof.unicity_certificate, + InclusionProofResponse::NotCertified { + unicity_certificate, + .. + } => unicity_certificate, + } + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 650e632..b11b787 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod certification; pub mod certification_request; pub mod inclusion_certificate; pub mod inclusion_proof; +pub mod inclusion_proof_response; pub mod leaf_value; pub mod network_id; pub mod non_inclusion_certificate; @@ -22,6 +23,7 @@ pub use certification::CertificationData; pub use certification_request::CertificationRequest; pub use inclusion_certificate::InclusionCertificate; pub use inclusion_proof::InclusionProof; +pub use inclusion_proof_response::InclusionProofResponse; pub use leaf_value::calculate_leaf_value; pub use network_id::NetworkId; pub use non_inclusion_certificate::NonInclusionCertificate; diff --git a/src/client/http.rs b/src/client/http.rs index 959f2c3..874406e 100644 --- a/src/client/http.rs +++ b/src/client/http.rs @@ -18,7 +18,7 @@ use zeroize::Zeroize; use crate::api::certification_request::CertificationRequest; use crate::api::inclusion_proof::InclusionProof; -use crate::api::{CertificationData, NonInclusionProof, StateId}; +use crate::api::{CertificationData, InclusionProofResponse, NonInclusionProof, StateId}; use crate::cbor::Decoder; use super::{AggregatorClient, MembershipStatus, NonInclusionAggregatorClient}; @@ -347,16 +347,10 @@ fn decode_rpc_response(text: &str, expected_id: &str) -> Result Result { +fn decode_inclusion_proof_response(bytes: &[u8]) -> Result { let d = Decoder::new(bytes); d.finish().map_err(|e| HttpError::Decode(e.to_string()))?; - let items = d - .array(Some(2)) - .map_err(|e| HttpError::Decode(e.to_string()))?; - items[0] - .uint() - .map_err(|e| HttpError::Decode(e.to_string()))?; - InclusionProof::from_cbor(items[1]).map_err(|e| HttpError::Decode(e.to_string())) + InclusionProofResponse::from_cbor(d).map_err(|e| HttpError::Decode(e.to_string())) } /// Decode the `[blockNumber, NonInclusionProof]` response payload. @@ -425,22 +419,23 @@ impl AggregatorClient for HttpAggregatorClient { }); } let bytes = hex::decode(encoded).map_err(|e| HttpError::Decode(e.to_string()))?; - let proof = decode_inclusion_proof_response(&bytes)?; - - // An empty proof on this method means the leaf is not certified yet. - // `get_inclusion_proof.v2` never answers with a non-inclusion proof - // (that is `get_non_inclusion_proof.v1`), so the empty response is - // unambiguous and is aggregator-go's way of reporting pending. An - // aggregator that sends the explicit INCLUSION_PENDING code above - // never reaches this branch, and only that one lets the client tell - // "not yet" apart from "no such state". - if proof.certification_data.is_none() || proof.inclusion_certificate.is_none() { - if attempt + 1 < self.poll_attempts { - std::thread::sleep(self.poll_interval); + + // A response carrying no proof on this method means the leaf is not + // certified yet. `get_inclusion_proof.v2` never answers with a + // non-inclusion proof (that is `get_non_inclusion_proof.v1`), so the + // uncertified response is unambiguous and is aggregator-go's way of + // reporting pending. An aggregator that sends the explicit + // INCLUSION_PENDING code above never reaches this branch, and only + // that one lets the client tell "not yet" apart from "no such state". + match decode_inclusion_proof_response(&bytes)? { + InclusionProofResponse::Certified { proof, .. } => return Ok(proof), + InclusionProofResponse::NotCertified { .. } => { + if attempt + 1 < self.poll_attempts { + std::thread::sleep(self.poll_interval); + } + continue; } - continue; } - return Ok(proof); } Err(HttpError::Timeout) } diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 7b4e740..0f671b8 100644 --- a/src/payment/tests.rs +++ b/src/payment/tests.rs @@ -142,9 +142,9 @@ fn valid_proof( Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { - certification_data: Some(certification_data), - reference_time: Some(REFERENCE_TIME), - inclusion_certificate: Some(InclusionCertificate::decode(&[0u8; 32]).unwrap()), + certification_data, + reference_time: REFERENCE_TIME, + inclusion_certificate: InclusionCertificate::decode(&[0u8; 32]).unwrap(), unicity_certificate: signed_uc(node, root), } } diff --git a/src/transaction/certified.rs b/src/transaction/certified.rs index 5f38535..31fddde 100644 --- a/src/transaction/certified.rs +++ b/src/transaction/certified.rs @@ -12,7 +12,7 @@ use super::mint::MintTransaction; use super::transfer::TransferTransaction; use super::Transaction; use crate::api::inclusion_proof::InclusionProof; -use crate::cbor::{encode_array, encode_uint, Decoder}; +use crate::cbor::{encode_array, Decoder}; use crate::crypto::hash::DataHash; use crate::error::Error; use crate::predicate::EncodedPredicate; @@ -21,7 +21,6 @@ use crate::predicate::EncodedPredicate; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CertifiedMintTransaction { transaction: MintTransaction, - reference_time: u64, inclusion_proof: InclusionProof, } @@ -29,10 +28,8 @@ impl CertifiedMintTransaction { /// Bundle a transaction with a proof (no verification — see /// [`Token::verify`](super::token::Token::verify)). pub fn new(transaction: MintTransaction, inclusion_proof: InclusionProof) -> Self { - let reference_time = inclusion_proof.reference_time.unwrap_or(0); CertifiedMintTransaction { transaction, - reference_time, inclusion_proof, } } @@ -46,8 +43,12 @@ impl CertifiedMintTransaction { &self.inclusion_proof } /// The reference time this transition was validated under. + /// + /// Read off the inclusion proof rather than stored beside it: the proof is + /// the only thing consensus certified, so a second copy could only ever + /// disagree with it. pub fn reference_time(&self) -> u64 { - self.reference_time + self.inclusion_proof.reference_time } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { @@ -58,30 +59,18 @@ impl CertifiedMintTransaction { self.transaction.calculate_state_hash() } - /// Decode from CBOR (3-element array). + /// Decode from CBOR (2-element array). pub fn from_cbor(d: Decoder<'_>) -> Result { - let items = d.array(Some(3))?; - let reference_time = items[1].uint()?; - let inclusion_proof = InclusionProof::from_cbor(items[2])?; - if inclusion_proof.reference_time != Some(reference_time) { - return Err(Error::UnexpectedValue( - "certified mint reference time mismatch", - )); - } + let items = d.array(Some(2))?; Ok(CertifiedMintTransaction { transaction: MintTransaction::from_cbor(items[0])?, - reference_time, - inclusion_proof, + inclusion_proof: InclusionProof::from_cbor(items[1])?, }) } - /// Encode to CBOR (3-element array). + /// Encode to CBOR (2-element array). pub fn to_cbor(&self) -> alloc::vec::Vec { - encode_array(&[ - &self.transaction.to_cbor(), - &encode_uint(self.reference_time), - &self.inclusion_proof.to_cbor(), - ]) + encode_array(&[&self.transaction.to_cbor(), &self.inclusion_proof.to_cbor()]) } } @@ -89,17 +78,14 @@ impl CertifiedMintTransaction { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CertifiedTransferTransaction { transaction: TransferTransaction, - reference_time: u64, inclusion_proof: InclusionProof, } impl CertifiedTransferTransaction { /// Bundle a transaction with a proof (no verification). pub fn new(transaction: TransferTransaction, inclusion_proof: InclusionProof) -> Self { - let reference_time = inclusion_proof.reference_time.unwrap_or(0); CertifiedTransferTransaction { transaction, - reference_time, inclusion_proof, } } @@ -113,8 +99,10 @@ impl CertifiedTransferTransaction { &self.inclusion_proof } /// The reference time this transition was validated under. + /// + /// Read off the inclusion proof, for the same reason as on the genesis. pub fn reference_time(&self) -> u64 { - self.reference_time + self.inclusion_proof.reference_time } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { @@ -125,34 +113,22 @@ impl CertifiedTransferTransaction { self.transaction.calculate_state_hash() } - /// Decode from CBOR (3-element array), reconstructing the transfer's source + /// Decode from CBOR (2-element array), reconstructing the transfer's source /// state hash and lock script from the previous transaction. pub fn from_cbor( d: Decoder<'_>, source_state_hash: DataHash, lock_script: EncodedPredicate, ) -> Result { - let items = d.array(Some(3))?; - let reference_time = items[1].uint()?; - let inclusion_proof = InclusionProof::from_cbor(items[2])?; - if inclusion_proof.reference_time != Some(reference_time) { - return Err(Error::UnexpectedValue( - "certified transfer reference time mismatch", - )); - } + let items = d.array(Some(2))?; Ok(CertifiedTransferTransaction { transaction: TransferTransaction::from_cbor(items[0], source_state_hash, lock_script)?, - reference_time, - inclusion_proof, + inclusion_proof: InclusionProof::from_cbor(items[1])?, }) } - /// Encode to CBOR (3-element array). + /// Encode to CBOR (2-element array). pub fn to_cbor(&self) -> alloc::vec::Vec { - encode_array(&[ - &self.transaction.to_cbor(), - &encode_uint(self.reference_time), - &self.inclusion_proof.to_cbor(), - ]) + encode_array(&[&self.transaction.to_cbor(), &self.inclusion_proof.to_cbor()]) } } diff --git a/src/transaction/token.rs b/src/transaction/token.rs index dcce6fb..e7d0058 100644 --- a/src/transaction/token.rs +++ b/src/transaction/token.rs @@ -14,7 +14,7 @@ use crate::verify::VerificationError; /// CBOR tag for [`Token`]. pub const TOKEN_TAG: u64 = 39040; -const VERSION: u64 = 1; +const VERSION: u64 = 2; /// A token: its genesis mint and the chain of certified transfers. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/verify/error.rs b/src/verify/error.rs index 00cc24c..e9dfefd 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -58,16 +58,11 @@ pub enum VerificationError { SplitSourceAmountMismatch, /// The burned token was not locked to the split manifest's burn predicate. SplitBurnPredicateMismatch, - /// The inclusion proof had no inclusion certificate. - InclusionCertificateMissing, - /// The inclusion proof had no certification data. - CertificationDataMissing, /// Certification fields do not match the reconstructed transaction state. CertificationDataMismatch, /// The certified transaction hash does not match the recomputed one. TransactionHashMismatch, /// The inclusion proof's reference time differs from the one the transition carries. - /// An absent reference time also lands here, since it cannot match. ReferenceTimeMismatch, /// The round's reference time had already reached the request's timeout. RequestExpired, @@ -173,10 +168,6 @@ impl fmt::Display for VerificationError { "burned token not locked to split manifest burn predicate" ) } - VerificationError::InclusionCertificateMissing => { - write!(f, "inclusion certificate missing") - } - VerificationError::CertificationDataMissing => write!(f, "certification data missing"), VerificationError::CertificationDataMismatch => { write!(f, "certification data does not match transaction state") } diff --git a/src/verify/mod.rs b/src/verify/mod.rs index e5d9802..0d35e40 100644 --- a/src/verify/mod.rs +++ b/src/verify/mod.rs @@ -131,12 +131,8 @@ fn verify_genesis( ) .to_encoded(); - let certified_lock = genesis - .inclusion_proof() - .certification_data - .as_ref() - .map(|c| c.lock_script()); - if certified_lock != Some(&expected_lock) { + let certified_lock = genesis.inclusion_proof().certification_data.lock_script(); + if certified_lock != &expected_lock { return Err(VerificationError::InvalidMintLockScript); } @@ -165,14 +161,7 @@ fn verify_inclusion_proof( transaction: &impl Transaction, reference_time: u64, ) -> Result<(), VerificationError> { - proof - .inclusion_certificate - .as_ref() - .ok_or(VerificationError::InclusionCertificateMissing)?; - let certification_data = proof - .certification_data - .as_ref() - .ok_or(VerificationError::CertificationDataMissing)?; + let certification_data = &proof.certification_data; if certification_data.lock_script() != transaction.lock_script() || certification_data.source_state_hash() != transaction.source_state_hash() @@ -207,14 +196,8 @@ pub fn verify_inclusion_proof_for( trust_base .validate() .map_err(VerificationError::InvalidTrustBase)?; - let inclusion_certificate = proof - .inclusion_certificate - .as_ref() - .ok_or(VerificationError::InclusionCertificateMissing)?; - let certification_data = proof - .certification_data - .as_ref() - .ok_or(VerificationError::CertificationDataMissing)?; + let inclusion_certificate = &proof.inclusion_certificate; + let certification_data = &proof.certification_data; let certified_state_id = StateId::derive( certification_data.lock_script(), @@ -238,8 +221,7 @@ pub fn verify_inclusion_proof_for( proof.unicity_certificate.input_record.hash.clone(), ) .map_err(|_| VerificationError::PathInvalid)?; - // An absent reference time on the proof also fails this comparison. - if proof.reference_time != Some(reference_time) { + if proof.reference_time != reference_time { return Err(VerificationError::ReferenceTimeMismatch); } let leaf_value = calculate_leaf_value(certification_data.transaction_hash(), reference_time); @@ -547,9 +529,9 @@ mod tests { Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { - certification_data: Some(certification_data), - reference_time: Some(REFERENCE_TIME), - inclusion_certificate: Some(InclusionCertificate::decode(&[0u8; 32]).unwrap()), + certification_data, + reference_time: REFERENCE_TIME, + inclusion_certificate: InclusionCertificate::decode(&[0u8; 32]).unwrap(), unicity_certificate: signed_uc(node, root), } } @@ -599,7 +581,7 @@ mod tests { } fn cert(proof: &InclusionProof) -> &CertificationData { - proof.certification_data.as_ref().unwrap() + &proof.certification_data } // --- baseline ---------------------------------------------------------- @@ -716,38 +698,23 @@ mod tests { // --- one test per inclusion-proof rule --------------------------------- - #[test] - fn rule_inclusion_certificate_missing() { - let (tb, _n, _o, transfer, mut proof) = transfer_case(); - proof.inclusion_certificate = None; - assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), - Err(VerificationError::InclusionCertificateMissing) - ); - } - - #[test] - fn rule_certification_data_missing() { - let (tb, _n, _o, transfer, mut proof) = transfer_case(); - proof.certification_data = None; - assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), - Err(VerificationError::CertificationDataMissing) - ); - } + // A proof missing its leaf fields is no longer representable: the wire is + // rejected at the decoder, so verification never sees a partial proof. + // `api::inclusion_proof_response` covers the two admissible wire shapes and + // `api::inclusion_proof` the partial one. #[test] fn rule_certification_data_mismatch_lock_script() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let stranger = signer(0xAB); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( SignaturePredicate::new(stranger.public_key()).to_encoded(), // wrong lock c.source_state_hash().clone(), c.transaction_hash().clone(), c.unlock_script().to_vec(), Some(TIMEOUT), - )); + ); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) @@ -758,13 +725,13 @@ mod tests { fn rule_certification_data_mismatch_source_state() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( c.lock_script().clone(), sha256(b"a-different-source-state"), // wrong source c.transaction_hash().clone(), c.unlock_script().to_vec(), Some(TIMEOUT), - )); + ); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) @@ -775,13 +742,13 @@ mod tests { fn rule_certification_data_mismatch_timeout() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), c.unlock_script().to_vec(), Some(TIMEOUT + 1), - )); + ); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) @@ -791,35 +758,47 @@ mod tests { /// A proof either establishes a leaf or reports that there is none yet. The /// aggregators emit all three leaf fields together or none of them, so a /// partially present proof is a protocol violation and is rejected at - /// decode rather than surfacing as a None somewhere downstream. + /// decode rather than surfacing as a `None` somewhere downstream. + /// + /// The partial shapes are assembled by hand because [`InclusionProof`] can + /// no longer represent one: that is the point of the type. #[test] fn rejects_a_partially_present_proof() { - let (_tb, _n, _o, _transfer, proof) = transfer_case(); + use crate::cbor::{encode_array, encode_byte_string, encode_null, encode_tag}; - for partial in [ - InclusionProof { - inclusion_certificate: None, - ..proof.clone() - }, - InclusionProof { - reference_time: None, - ..proof.clone() - }, - InclusionProof { - certification_data: None, - ..proof.clone() - }, - InclusionProof { - certification_data: None, - reference_time: None, - ..proof.clone() - }, - ] { - let bytes = partial.to_cbor(); - assert!(matches!( - InclusionProof::from_cbor(Decoder::new(&bytes)), - Err(crate::error::Error::UnexpectedValue(_)) - )); + let (_tb, _n, _o, _transfer, proof) = transfer_case(); + let certification_data = proof.certification_data.to_cbor(); + let reference_time = crate::cbor::encode_uint(proof.reference_time); + let inclusion_certificate = encode_byte_string(&proof.inclusion_certificate.encode()); + let unicity_certificate = proof.unicity_certificate.to_cbor(); + + // Every combination of the three leaf fields except all-present and + // all-absent, which are the two the wire admits. + for present in [0b001, 0b010, 0b011, 0b100, 0b101, 0b110] { + let slot = |bit: u8, value: &[u8]| -> alloc::vec::Vec { + if present & bit != 0 { + value.to_vec() + } else { + encode_null() + } + }; + let bytes = encode_tag( + crate::api::inclusion_proof::INCLUSION_PROOF_TAG, + &encode_array(&[ + &crate::cbor::encode_uint(1), + &slot(0b100, &certification_data), + &slot(0b010, &reference_time), + &slot(0b001, &inclusion_certificate), + &unicity_certificate, + ]), + ); + assert!( + matches!( + InclusionProof::from_cbor(Decoder::new(&bytes)), + Err(crate::error::Error::UnexpectedValue(_)) + ), + "partial proof {present:#05b} was accepted" + ); } } @@ -836,7 +815,7 @@ mod tests { fn rule_transaction_hash_mismatch() { let (tb, _n, _o, transfer, mut proof) = transfer_case(); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), sha256(b"not-the-tx-hash"), @@ -845,7 +824,7 @@ mod tests { // wrong tx hash TIMEOUT, ), - )); + ); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::TransactionHashMismatch) @@ -925,13 +904,13 @@ mod tests { let c = cert(&proof); let mut unlock = c.unlock_script().to_vec(); unlock[0] ^= 0xff; // corrupt the signature (still 65 bytes) - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( c.lock_script().clone(), c.source_state_hash().clone(), c.transaction_hash().clone(), unlock, Some(TIMEOUT), - )); + ); assert_eq!( verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::NotAuthenticated) @@ -1008,13 +987,13 @@ mod tests { // Replace the certified lock script with one that is not the minter key. let stranger = signer(0x77); let c = cert(&proof); - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( SignaturePredicate::new(stranger.public_key()).to_encoded(), c.source_state_hash().clone(), c.transaction_hash().clone(), c.unlock_script().to_vec(), Some(TIMEOUT), - )); + ); let token = Token::new(CertifiedMintTransaction::new(mint, proof), Vec::new()); assert_eq!( token.verify(&tb), diff --git a/tests/http_transport.rs b/tests/http_transport.rs index a597e13..6a79a64 100644 --- a/tests/http_transport.rs +++ b/tests/http_transport.rs @@ -13,10 +13,12 @@ use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::time::Duration; +use unicity_token::api::inclusion_proof::INCLUSION_PROOF_TAG; use unicity_token::api::{ - CertificationData, InclusionProof, NonInclusionCertificate, NonInclusionProof, StateId, + CertificationData, InclusionProof, InclusionProofResponse, NonInclusionCertificate, + NonInclusionProof, StateId, }; -use unicity_token::cbor::{encode_array, encode_uint}; +use unicity_token::cbor::{encode_array, encode_null, encode_tag, encode_uint}; use unicity_token::client::{ AggregatorClient, HttpAggregatorClient, HttpError, MembershipStatus, NonInclusionAggregatorClient, @@ -42,33 +44,54 @@ fn fixture_proof_and_data() -> (InclusionProof, CertificationData) { let carol = hex::decode(field(FIXTURE, "carolToken")).unwrap(); let token = Token::from_cbor(&carol).unwrap(); let proof = token.transactions()[0].inclusion_proof().clone(); - let data = proof - .certification_data - .clone() - .expect("fixture has cert data"); + let data = proof.certification_data.clone(); (proof, data) } /// Wrap an inclusion proof as the `[blockNumber, InclusionProof]` response body /// the aggregator returns, hex-encoded. fn proof_response_hex(proof: &InclusionProof) -> String { - let block = encode_uint(7); - let body = encode_array(&[block.as_slice(), proof.to_cbor().as_slice()]); - hex::encode(body) + hex::encode( + InclusionProofResponse::Certified { + block_number: 7, + proof: proof.clone(), + } + .to_cbor(), + ) } /// The same wrapper for a leaf that is not certified yet: the three leaf fields /// are absent together, which is what aggregator-go returns while pending. fn empty_proof_response_hex(proof: &InclusionProof) -> String { - let pending = InclusionProof { - certification_data: None, - reference_time: None, - inclusion_certificate: None, - unicity_certificate: proof.unicity_certificate.clone(), + hex::encode( + InclusionProofResponse::NotCertified { + block_number: 7, + unicity_certificate: proof.unicity_certificate.clone(), + } + .to_cbor(), + ) +} + +/// A response whose leaf fields are only partly present, which no aggregator +/// may send. Assembled by hand: neither `InclusionProof` nor +/// `InclusionProofResponse` can represent it, which is what this asserts. +fn partial_proof_response_hex(proof: &InclusionProof, keep_reference_time: bool) -> String { + let reference_time = if keep_reference_time { + encode_uint(proof.reference_time) + } else { + encode_null() }; - let block = encode_uint(7); - let body = encode_array(&[block.as_slice(), pending.to_cbor().as_slice()]); - hex::encode(body) + let inner = encode_tag( + INCLUSION_PROOF_TAG, + &encode_array(&[ + &encode_uint(1), + &encode_null(), + &reference_time, + &encode_null(), + &proof.unicity_certificate.to_cbor(), + ]), + ); + hex::encode(encode_array(&[encode_uint(7).as_slice(), inner.as_slice()])) } fn fixture_non_inclusion_proof() -> NonInclusionProof { @@ -329,8 +352,8 @@ fn get_inclusion_proof_returns_complete_proof() { let got = client(&server.url) .get_inclusion_proof(&state_id) .expect("should return a complete proof"); - assert!(got.certification_data.is_some()); - assert!(got.inclusion_certificate.is_some()); + assert_eq!(got.certification_data, data); + assert_eq!(got.reference_time, proof.reference_time); assert_eq!(server.request_count(), 1, "should not poll once complete"); // The state-id header is not sent for proof lookups. @@ -343,13 +366,7 @@ fn get_inclusion_proof_returns_complete_proof() { #[test] fn get_inclusion_proof_rejects_incomplete_response_without_polling() { let (proof, data) = fixture_proof_and_data(); - let incomplete = InclusionProof { - reference_time: proof.reference_time, - certification_data: None, - inclusion_certificate: None, - unicity_certificate: proof.unicity_certificate.clone(), - }; - let response = ok_json(&format!("\"{}\"", proof_response_hex(&incomplete))); + let response = ok_json(&format!("\"{}\"", partial_proof_response_hex(&proof, true))); let server = MockServer::start(vec![response]); let state_id = StateId::derive(data.lock_script(), data.source_state_hash()); diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index 263ed17..4536c6b 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -17,7 +17,8 @@ use unicity_token::api::bft::root_trust_base::RootTrustBaseNodeInfo; use unicity_token::api::bft::RootTrustBase; -use unicity_token::api::{CertificationData, NetworkId}; +use unicity_token::api::{CertificationData, InclusionProofResponse, NetworkId}; +use unicity_token::cbor::Decoder; use unicity_token::crypto::hash::sha256; use unicity_token::crypto::signature::PublicKey; use unicity_token::transaction::{CertifiedTransferTransaction, Token, Transaction}; @@ -230,10 +231,10 @@ fn rejects_trailing_and_non_minimal_token_encodings() { let (canonical, _) = token("aliceToken"); // The token tag is three bytes and the following array header is one byte; - // replace canonical version 1 with its non-minimal two-byte form. - assert_eq!(canonical[4], 0x01); + // replace the canonical version with its non-minimal two-byte form. + assert_eq!(canonical[4], 0x02); let mut non_minimal = canonical[..4].to_vec(); - non_minimal.extend_from_slice(&[0x18, 0x01]); + non_minimal.extend_from_slice(&[0x18, 0x02]); non_minimal.extend_from_slice(&canonical[5..]); assert!(Token::from_cbor(&non_minimal).is_err()); } @@ -243,18 +244,15 @@ fn rejects_mismatched_transfer_certification_state() { let (_, token) = token("bobToken"); let certified = &token.transactions()[0]; let mut proof = certified.inclusion_proof().clone(); - let data = proof - .certification_data - .as_ref() - .expect("fixture has certification data"); + let data = proof.certification_data.clone(); // Rebuild with the same fields, so only the substituted source state differs. - proof.certification_data = Some(CertificationData::new( + proof.certification_data = CertificationData::new( data.lock_script().clone(), sha256(b"unrelated source state"), data.transaction_hash().clone(), data.unlock_script().to_vec(), data.expires_at(), - )); + ); let forged = Token::new( token.genesis().clone(), @@ -269,3 +267,38 @@ fn rejects_mismatched_transfer_certification_state() { if matches!(*source, VerificationError::CertificationDataMismatch) )); } + +/// The aggregator's answer round-trips in both of the shapes the wire admits, +/// and only the certified one yields a proof. +#[test] +fn inclusion_proof_response_round_trips_both_shapes() { + let (_, token) = token("carolToken"); + let proof = token.transactions()[0].inclusion_proof().clone(); + + let certified = InclusionProofResponse::Certified { + block_number: 7, + proof: proof.clone(), + }; + let bytes = certified.to_cbor(); + let decoded = InclusionProofResponse::from_cbor(Decoder::new(&bytes)).expect("certified"); + assert_eq!(decoded, certified); + assert_eq!(decoded.block_number(), 7); + assert_eq!(decoded.inclusion_proof(), Some(&proof)); + assert_eq!(decoded.unicity_certificate(), &proof.unicity_certificate); + + let not_certified = InclusionProofResponse::NotCertified { + block_number: 9, + unicity_certificate: proof.unicity_certificate.clone(), + }; + let bytes = not_certified.to_cbor(); + let decoded = InclusionProofResponse::from_cbor(Decoder::new(&bytes)).expect("not certified"); + assert_eq!(decoded, not_certified); + assert_eq!(decoded.block_number(), 9); + assert_eq!(decoded.inclusion_proof(), None); + assert_eq!(decoded.unicity_certificate(), &proof.unicity_certificate); + + // The uncertified body is not an InclusionProof, and says so rather than + // decoding into one with empty fields. + let items = Decoder::new(&bytes).array(Some(2)).unwrap(); + assert!(unicity_token::api::InclusionProof::from_cbor(items[1]).is_err()); +} diff --git a/tests/vectors/README.md b/tests/vectors/README.md new file mode 100644 index 0000000..efbb2e4 --- /dev/null +++ b/tests/vectors/README.md @@ -0,0 +1,40 @@ +# Cross-SDK vectors + +`transition_flow.json` is generated by the reference TypeScript SDK, not by this +crate. It is the fixture the cross-SDK test decodes, round-trips and verifies, so +it is the check that the Rust wire format still matches the one the TypeScript +and Java SDKs ship. + +## Regenerating + +`generate-vector.ts` is the generator. It is a Jest test rather than a script +because the TypeScript SDK's in-memory aggregator (`TestAggregatorClient`) and +token helpers live under its `tests/` tree. + +```sh +cd ../state-transition-sdk-js +git checkout v3.0.1 # or whichever release to pin against +npm ci +cp ../state-transition-sdk-rust/tests/vectors/generate-vector.ts \ + tests/functional/GenerateRustVectorTest.ts +RUST_VECTOR_OUT=../state-transition-sdk-rust/tests/vectors/transition_flow.json \ + npx jest --testPathPatterns=GenerateRustVectorTest --collectCoverage=false +rm tests/functional/GenerateRustVectorTest.ts +``` + +Then run `cargo test --all-features` here. + +The signing keys are fixed (private keys 1..4 for the token holders, 9 for the +aggregator) so a regeneration changes only what the wire format changed. The one +value that moves on every run is `explicitTimeout`, which is a wall-clock +deadline an hour ahead: the in-memory aggregator pins its round reference time to +the current clock and will not admit a request whose deadline has passed. + +## Why it is generated rather than hand-written + +Regenerating against a released TypeScript SDK is what catches a divergence. +Before 3.0.1 this file had been regenerated from an unreleased intermediate state +of the TypeScript SDK, which left the Rust SDK encoding tokens at wire version 1 +with a three-element certified transaction while the shipped SDKs had moved to +version 2 and two elements. Every Rust test passed, because the fixture and the +code had drifted together. Pin the checkout to a tag. diff --git a/tests/vectors/generate-vector.ts b/tests/vectors/generate-vector.ts new file mode 100644 index 0000000..c5871c3 --- /dev/null +++ b/tests/vectors/generate-vector.ts @@ -0,0 +1,123 @@ +import { writeFileSync } from 'node:fs'; + +import { TestAggregatorClient } from './TestAggregatorClient.js'; +import { NetworkId } from '../../src/api/NetworkId.js'; +import { SigningService } from '../../src/crypto/secp256k1/SigningService.js'; +import { SignaturePredicate } from '../../src/predicate/builtin/SignaturePredicate.js'; +import { PredicateVerifierService } from '../../src/predicate/verification/PredicateVerifierService.js'; +import { StateTransitionClient } from '../../src/StateTransitionClient.js'; +import { TokenSalt } from '../../src/transaction/TokenSalt.js'; +import { TokenType } from '../../src/transaction/TokenType.js'; +import { MintJustificationVerifierService } from '../../src/transaction/verification/MintJustificationVerifierService.js'; +import { TokenIssuanceVerifierService } from '../../src/transaction/verification/TokenIssuanceVerifierService.js'; +import { VerificationContext } from '../../src/transaction/verification/VerificationContext.js'; +import { HexConverter } from '../../src/util/HexConverter.js'; +import { VerificationStatus } from '../../src/verification/VerificationStatus.js'; +import { mintToken, transferToken } from '../utils/TokenUtils.js'; +import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; + +/** Deterministic keys so the vector is reproducible apart from the wall-clock deadline. */ +function key(n: number): Uint8Array { + const bytes = new Uint8Array(32); + bytes[31] = n; + return bytes; +} + +const OUT = process.env.RUST_VECTOR_OUT; + +describe('Generate Rust cross-SDK vector', () => { + it('mints and transfers Alice -> Bob -> Carol, then writes the vector', async () => { + const aggregator = TestAggregatorClient.create(key(9)); + const client = new StateTransitionClient(aggregator); + const trustBase = aggregator.rootTrustBase; + + const verificationContext = new VerificationContext( + trustBase, + PredicateVerifierService.create(), + createUnicityCertificateVerifier(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), + ); + + const alice = new SigningService(key(1)); + const bob = new SigningService(key(2)); + const carol = new SigningService(key(3)); + const dave = new SigningService(key(4)); + + const tokenType = new TokenType(new Uint8Array(32)); + const saltBytes = (n: number): Uint8Array => { + const bytes = new Uint8Array(32); + bytes[31] = n; + return bytes; + }; + + // The Alice/Bob/Carol flow leaves the deadline to the service. + const aliceToken = await mintToken( + client, + verificationContext, + SignaturePredicate.create(alice.publicKey), + null, + trustBase.networkId, + tokenType, + TokenSalt.fromBytes(saltBytes(0)), + null, + null, + ); + const bobToken = await transferToken( + client, + verificationContext, + aliceToken.toCBOR(), + SignaturePredicate.create(bob.publicKey), + alice, + null, + ); + const carolToken = await transferToken( + client, + verificationContext, + bobToken.toCBOR(), + SignaturePredicate.create(carol.publicKey), + bob, + null, + ); + + // A second token carries a sender-chosen deadline in the same wire slot. + const explicitTimeout = BigInt(Math.floor(Date.now() / 1000)) + 3600n; + const explicitTimeoutToken = await mintToken( + client, + verificationContext, + SignaturePredicate.create(dave.publicKey), + null, + trustBase.networkId, + tokenType, + TokenSalt.fromBytes(saltBytes(1)), + null, + explicitTimeout, + ); + + for (const token of [aliceToken, bobToken, carolToken, explicitTimeoutToken]) { + await expect(token.verify(verificationContext).then((r) => r.status)).resolves.toEqual(VerificationStatus.OK); + } + + expect(carolToken.genesis.expiresAt).toBeNull(); + expect(explicitTimeoutToken.genesis.expiresAt).toEqual(explicitTimeout); + + const vector = { + __comment: 'generated by state-transition-sdk-js: mint to Alice, transfer Alice -> Bob -> Carol', + trustBase: { + networkId: NetworkId.LOCAL.id, + nodeId: 'NODE', + aggregatorPublicKey: HexConverter.encode(new SigningService(key(9)).publicKey), + quorumThreshold: 1, + }, + aliceToken: HexConverter.encode(aliceToken.toCBOR()), + bobToken: HexConverter.encode(bobToken.toCBOR()), + carolToken: HexConverter.encode(carolToken.toCBOR()), + explicitTimeout: Number(explicitTimeout), + explicitTimeoutToken: HexConverter.encode(explicitTimeoutToken.toCBOR()), + }; + + if (OUT) { + writeFileSync(OUT, `${JSON.stringify(vector, null, 2)}\n`); + } + }, 120000); +}); diff --git a/tests/vectors/transition_flow.json b/tests/vectors/transition_flow.json index 7144bce..ad6345c 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,14 +1,14 @@ { - "__comment": "generated by state-transition-sdk-js: mint to Alice, transfer Alice -> Bob -> Carol", - "trustBase": { - "networkId": 3, - "nodeId": "NODE", - "aggregatorPublicKey": "03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe", - "quorumThreshold": "1" - }, - "aliceToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a50180", - "bobToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a5018183d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5582008ff4aa567f859f2c0242d40e5caaabb7356b2aa4017505d6516004436667f01f6f61a6a874c12d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582031fc774936ce7d8adeae738ccf2e67df68d7013220bf24864cb1eba644cb1649582035d703101e48e723e8619ae6db52b3374c4017864b28b908efed189704e18a6df65841f1d75480c387a6551476ff275bd9232955901ede90dd17fc51467be066f4091b43a6aabf0bc70d81d5e5a326d78cf692b7335808a7ddb292b3733d5ad913540a011a6a874c1258402000000000000000000000000000000000000000000000000000000000000000188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490d998598701d9985a8a010000f65820f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1eb401a6a874c13f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658205ddb10b938842278df153691f8d0f480a050b52ef7a24814b2de764384d426f8a1644e4f444558412b1b340c65dc1cbe6e0f8b5aeed8b1d49f88f66c4e49dd7c69289e89e72540671e86c82bc49607707e42792424a849a8b01e592858fde6fcdb9e666d1010d83000", - "carolToken": "d99880830183d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582063e9195939d04fc6c77b7aae2e95eaaba974fdc853e6681d750cbec73dae9f6d58206c91bfac37f30019ecb4378447f78cc2913f188536acc41e0436d2cb05f9dc79f6f6f61a6a874c11d998798501d998778602d99878830141015821039f2544e1f8bbc011234027d1fadf722f5760e20deff6c2d36afeab344e88a3575820fe9d3fc2c7b832fbf7002d3a71200e42bf1ce56303f965a49098d9f943ba4fab5820363262daecaf81efb0e48ed6b05b96c2acb3c2b8ba44b0c6bd4ca3fe8ba71977f6584191934fff64dbcdd1059e9dacfa58d1d9e4150b2221ea260c995e1bc270abfce23cfcc2222d6887244cf0b8bb5fda79b62b4e37aba388703dad5a56ddec0e891e001a6a874c1158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f65820188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490401a6a874c12f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582084a86a4b10b2a91fd0f0d48e8c4bc575c875889e5a11395228530db0a3b609aea1644e4f4445584156bb3c92021205a0757f68bcbff4467bc23ce2c63aa7ae50cb00f43dc763d28a5b41b76801d2d4c3bce9f58ea7dd0b1255e56a60e57568ac72dfca4803b482a5018283d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5582008ff4aa567f859f2c0242d40e5caaabb7356b2aa4017505d6516004436667f01f6f61a6a874c12d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798582031fc774936ce7d8adeae738ccf2e67df68d7013220bf24864cb1eba644cb1649582035d703101e48e723e8619ae6db52b3374c4017864b28b908efed189704e18a6df65841f1d75480c387a6551476ff275bd9232955901ede90dd17fc51467be066f4091b43a6aabf0bc70d81d5e5a326d78cf692b7335808a7ddb292b3733d5ad913540a011a6a874c1258402000000000000000000000000000000000000000000000000000000000000000188ed34b712e3f2f681f1c92efb1761759cf6bda49f1342e3fb78967548cd490d998598701d9985a8a010000f65820f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1eb401a6a874c13f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658205ddb10b938842278df153691f8d0f480a050b52ef7a24814b2de764384d426f8a1644e4f444558412b1b340c65dc1cbe6e0f8b5aeed8b1d49f88f66c4e49dd7c69289e89e72540671e86c82bc49607707e42792424a849a8b01e592858fde6fcdb9e666d1010d8300083d998858502d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f958207e02c5478e18cf299e4dcaf6996f42f887e2befecd19399ff692ef93fd7a4ed1f6f61a6a874c13d998798501d998778602d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820ad5c056d99670a25b4a719340223dca85e144652e49ad57ce3b88ce0b406c9db5820d92fdc569ccda1470e146df8d9b7afd7191f123aea2204797f462a8fb380e20ef65841736b6a5a3d22aeb35350c92662b59f7ce3048a41a7b04a811cf4aebfebebf9732cfa80933ad0fefa11f99391c3542d8e5e921b469188f593ab2e0cf1e3eca404011a6a874c1358408000000000000000000000000000000000000000000000000000000000000000f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1ebd998598701d9985a8a010000f658205f1abf5d8e804b71c23474d625ee1e7e9dad697581f6c779c257cbae8503b0d5401a6a874c14f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f6582082d3199f5cdb717f7599e7b46b99d0623a8d34e6e44357b92474ebc4610754c8a1644e4f44455841bc533d571265a3e49827093bd0da553c87018762b6b4ab6427562d9f90713d786a7bd3007be6415e8b29cf4694d4ce4f1dbce881de055306d14ea0feccb359ef00", - "explicitTimeout": 1787321358, - "explicitTimeoutToken": "d99880830183d99881880203d9987883014101582102e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd1358204736924cc521182952c19380bd6c5a36316fe193b2688d423659e9d3621d10e35820767eefce993635a4fa7086fc5feef46fc8426da9e1533b1f730947955776f5bef6f61a6a885c0e1a6a874c14d998798501d998778602d9987883014101582103d048a89e11fe7ee9397456d1e6b52a9daed9adf0984825b8c270afccb240c5cd5820b445368641a6dd1b9dece2ef5bb95777d877d5ac55de557f0bcfeb361f1be3ba58206f0d897868c4e2ec5538276232707c4ba4a0ad1bafd0f585889588e88685bf571a6a885c0e5841ab2722fe0d6b1cadabeb792bb6d4a8f0726ccb6cadceb6b0b3de95ce3e5f92b902abe73771d9a7e61ee53b3468c4c0158f45346bf836782109b26169156c33b3001a6a874c145860c000000000000000000000000000000000000000000000000000000000000000f23f0120288ed87441f07d67de2d650d5b445eb93c375200a8d0b6eca628d1ebbab2fc01cf1d44470a18527063e35423d760b9a35163ae88df8833625915fbe0d998598701d9985a8a010000f6582053fd2f64027b521e86b1c472f360c05f9493384c7ab4e3533f3907140c0d6eb4401a6a874c15f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820bcb60f1b6a4301f2607cefb75b845b917028a34393ee80747a960332fe11dec5a1644e4f44455841a24e201c7b1001a483891eb9677699cfaeb90d989d764783dbf6bb778b0486d55de46f895198687d2807a99697ddf9e9c9c24e19abc114975101e559aeee32780180" + "__comment": "generated by state-transition-sdk-js: mint to Alice, transfer Alice -> Bob -> Carol", + "trustBase": { + "networkId": 3, + "nodeId": "NODE", + "aggregatorPublicKey": "03acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe", + "quorumThreshold": 1 + }, + "aliceToken": "d99880830282d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820000000000000000000000000000000000000000000000000000000000000000058200000000000000000000000000000000000000000000000000000000000000000f6f6f6d998798501d998778602d99878830141015821037e78bdae6e7a957f3973aa187c9d29eda791458b470614afeacd4b5569271f9f5820ade8ddaf4e9b158d75037934350e57c4aec738f55dfb24058e2928462b0f801758200a40b7c61a6207b042d1e96892bda8a5356bf46cfdf8096ea85e24dbf2ffbe73f65841a1bdce84e3dce64e8025f4464621b212c849694cec99d64e650b3d842de62e4b54867926e461737adf922756353b21993c164bc13f5ac2e0a0806772f4b6b5bb011a6a9548f358200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f6582070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478401a6a9548f4f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c8c2d28703f4a9f9b343aa697cc9a1c698e9d7b124530c5459c48c1e3a8a7939a1644e4f444558418f4d610de48e6100ac9d8cfca3ee74cb55a3251a7259b842b80db33f8f4025d9294758d7b2982830f37d5c35de76fca7c914c28309a84d917637fac7c7c0313c0080", + "bobToken": "d99880830282d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820000000000000000000000000000000000000000000000000000000000000000058200000000000000000000000000000000000000000000000000000000000000000f6f6f6d998798501d998778602d99878830141015821037e78bdae6e7a957f3973aa187c9d29eda791458b470614afeacd4b5569271f9f5820ade8ddaf4e9b158d75037934350e57c4aec738f55dfb24058e2928462b0f801758200a40b7c61a6207b042d1e96892bda8a5356bf46cfdf8096ea85e24dbf2ffbe73f65841a1bdce84e3dce64e8025f4464621b212c849694cec99d64e650b3d842de62e4b54867926e461737adf922756353b21993c164bc13f5ac2e0a0806772f4b6b5bb011a6a9548f358200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f6582070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478401a6a9548f4f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c8c2d28703f4a9f9b343aa697cc9a1c698e9d7b124530c5459c48c1e3a8a7939a1644e4f444558418f4d610de48e6100ac9d8cfca3ee74cb55a3251a7259b842b80db33f8f4025d9294758d7b2982830f37d5c35de76fca7c914c28309a84d917637fac7c7c0313c008182d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558208099f60961ccf6f3970ba715d282c643a12f5dd1c0bbb5ce1eddeaa5c98c7761f6f6d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820d51d1a1190d95451c63a4066ea3b63ed2fe3cf4d921b664153e435c151997f8158204a7908dfc57ecb48ae836365a98f41ab2af1efad0122c95d6ed3fd883da07a62f65841f81ad0ff7a0e46a7c055409ce63790f1de1c799cb036b519e779865913a2a70656d2f3ba9aa1d015132298f097e29d7b981650e1d51abd3d57864f373747f625011a6a9548f45840800000000000000000000000000000000000000000000000000000000000000070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478d998598701d9985a8a010000f658200cfb8b40eca66ef0ad224500e5fd09596e62769f78c11eec86284f0bd5a91adc401a6a9548f5f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658207ba020d7077c0b3376a75b16cc0ce1870fd454c9641c166f45446a1de6c3dd5fa1644e4f44455841e4cf63b39d491b2875af6b3a3799e8284414ae9267bc819da706035687fb45e45b8c9aaed0cb0f33123b9df3c86fa4f6ae4f3d6006b8b3e2f818a013ee0e6ce500", + "carolToken": "d99880830282d99881880203d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820000000000000000000000000000000000000000000000000000000000000000058200000000000000000000000000000000000000000000000000000000000000000f6f6f6d998798501d998778602d99878830141015821037e78bdae6e7a957f3973aa187c9d29eda791458b470614afeacd4b5569271f9f5820ade8ddaf4e9b158d75037934350e57c4aec738f55dfb24058e2928462b0f801758200a40b7c61a6207b042d1e96892bda8a5356bf46cfdf8096ea85e24dbf2ffbe73f65841a1bdce84e3dce64e8025f4464621b212c849694cec99d64e650b3d842de62e4b54867926e461737adf922756353b21993c164bc13f5ac2e0a0806772f4b6b5bb011a6a9548f358200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f6582070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478401a6a9548f4f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820c8c2d28703f4a9f9b343aa697cc9a1c698e9d7b124530c5459c48c1e3a8a7939a1644e4f444558418f4d610de48e6100ac9d8cfca3ee74cb55a3251a7259b842b80db33f8f4025d9294758d7b2982830f37d5c35de76fca7c914c28309a84d917637fac7c7c0313c008282d998858502d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee558208099f60961ccf6f3970ba715d282c643a12f5dd1c0bbb5ce1eddeaa5c98c7761f6f6d998798501d998778602d998788301410158210279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817985820d51d1a1190d95451c63a4066ea3b63ed2fe3cf4d921b664153e435c151997f8158204a7908dfc57ecb48ae836365a98f41ab2af1efad0122c95d6ed3fd883da07a62f65841f81ad0ff7a0e46a7c055409ce63790f1de1c799cb036b519e779865913a2a70656d2f3ba9aa1d015132298f097e29d7b981650e1d51abd3d57864f373747f625011a6a9548f45840800000000000000000000000000000000000000000000000000000000000000070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478d998598701d9985a8a010000f658200cfb8b40eca66ef0ad224500e5fd09596e62769f78c11eec86284f0bd5a91adc401a6a9548f5f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658207ba020d7077c0b3376a75b16cc0ce1870fd454c9641c166f45446a1de6c3dd5fa1644e4f44455841e4cf63b39d491b2875af6b3a3799e8284414ae9267bc819da706035687fb45e45b8c9aaed0cb0f33123b9df3c86fa4f6ae4f3d6006b8b3e2f818a013ee0e6ce50082d998858502d9987883014101582102f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f958201b6798c856c6f4010d088b6b02b6d08fc7fcc142f9c382bd4d85adc324752136f6f6d998798501d998778602d9987883014101582102c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee55820e7ff13a9a326ba99024ba4010f584b23bc6b2a427a572633a45a2aecaad835805820088e1c17c4249defa84dd1c50b613bdeb1d48fc9c68ec7adec79bb30fffe1386f658419672104f61e5a9c5d7adad14a63fa0a47e88f460ceddbb460dae2c06fe9ada685bb328b93660d851176f9f44cb5ea9282d639586d2b2504a50d68524026d5ab5001a6a9548f55860a00000000000000000000000000000000000000000000000000000000000000070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b4782f2940741e7abda939b0a3fd68a221bb83875a56579c01e8b73c8659c8b211abd998598701d9985a8a010000f6582052055abd014996c6ff9179d5b937746b8a4cbcddd14f32dd6d81340727fecc34401a6a9548f6f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820ca8c7a6923ceda1c305072192244e67138e18dadc9702a44d7b9e1987ef2f6e1a1644e4f44455841c00ee27bbbb5bc72f634f586dcd54056d034be1d27b13eb6c8572b456475d2bf1c1852a482f8f2bf6e9f393f4164d81d9799dc6e25c1bb139ae079ba8dba9ffa01", + "explicitTimeout": 1788172035, + "explicitTimeoutToken": "d99880830282d99881880203d9987883014101582102e493dbf1c10d80f3581e4904930b1404cc6c13900ee0758474fa94abe8c4cd135820000000000000000000000000000000000000000000000000000000000000000158200000000000000000000000000000000000000000000000000000000000000000f6f61a6a955703d998798501d998778602d9987883014101582102b036c75fa07e27738876d8b1a9397721632d345544250d36c4f74bc9a00db7b65820a133422299e20bc706035bca1176fee46028233a92bd46053c1b69637cba83e858201827b339dc2626ff0618954b487a28267527fae13bd83933fc09c1aa67f58c2e1a6a95570358417ffc9ac3d7119e39bd35f7a816094c57324eb919fb21f56a31c79083306e45b63689e06178ed982b74e1a27d95093707085135ea2efc0e8e31bdd62943b9bcbc011a6a9548f65880b00000000000000000000000000000000000000000000000000000000000000070ddf8ef7272b797c65b0eba80d65ecb243a9e33f28083809e1cc1c147f8b478b260f25f5787c20b5ec49e690ff8351369d4eb52ff980955b1a5316d4f332cf22f2940741e7abda939b0a3fd68a221bb83875a56579c01e8b73c8659c8b211abd998598701d9985a8a010000f65820a10843d4d9807f6f5d09b304dae518b5faae62d1b771b9135385c11a7a38741a401a6a9548f7f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658209751c4afae373107a3d959dde2500da7dbd97f57002fc06493ef9cf08ce6df12a1644e4f44455841510ac9d1fd8bd744a3a23a5356f360eef517a9f717b643810e4cfc601a28703b0aa2cac7dd7d19d75b1a17871f3389f2729738284d57d72aef4aa7526a3c68820180" } From 653f1f4774d00aada25fa164a6e18356adeef92f Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Mon, 31 Aug 2026 12:36:46 +0300 Subject: [PATCH 12/12] Release as 3.0.1 Jumps from 0.1.0 to 3.0.1. There is no 1.x or 2.x: the version line is aligned with state-transition-sdk-js and state-transition-sdk-java, which this crate shares its wire formats with, so a version tells you which SDKs a Rust client interoperates with. 3.0.1 rather than 3.0.0 because the inclusion-proof API matches theirs at 3.0.1, not the shape 3.0.0 shipped. This is the first release that interoperates with either of them. Earlier builds encoded a version-1 token whose certified transactions carried a third element, and neither shape was readable by the shipped SDKs, so tokens this crate produced before now have to be re-minted regardless of which aggregator certified them. README gains an "Upgrading to 3.0" section covering the reference-time leaf value, the four wire versions that move, the two-element certified transaction, request deadlines and what they do not guarantee, and the inclusion-proof split. Cargo.toml's repository URL pointed at state-transition-sdk-rs, which does not exist. --- Cargo.lock | 2 +- Cargo.toml | 4 +- README.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63e6135..2f2c1c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -722,7 +722,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicity-token" -version = "0.1.0" +version = "3.0.1" dependencies = [ "dotenvy", "getrandom", diff --git a/Cargo.toml b/Cargo.toml index 7524bc6..31ee89c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "unicity-token" -version = "0.1.0" +version = "3.0.1" edition = "2021" rust-version = "1.81" description = "Clean-room Rust SDK for the Unicity token state-transition protocol, binary-compatible with the Java and TypeScript SDKs. no_std-first, zkVM/WASM friendly." license = "MIT OR Apache-2.0" -repository = "https://github.com/unicitynetwork/state-transition-sdk-rs" +repository = "https://github.com/unicitynetwork/state-transition-sdk-rust" categories = ["cryptography", "no-std"] keywords = ["unicity", "token", "zkvm", "cbor", "secp256k1"] # Internal design doc; not part of the published API surface. diff --git a/README.md b/README.md index 26456b0..a3140e1 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,14 @@ client for minting and transferring tokens. ```toml [dependencies] -unicity-token = "0.1" +unicity-token = "3.0" ``` +The version line is shared with the TypeScript and Java SDKs: a 3.0.1 client +interoperates with `state-transition-sdk-js` 3.0.1 and +`state-transition-sdk-java` 3.0.1, and with an aggregator at +`ghcr.io/unicitynetwork/aggregator-go:sha-ae08165` or later. + ## Security model Decoding a token proves its structural integrity only. Trust is established only by @@ -150,11 +155,15 @@ The zkVM/WASM guest build is `--no-default-features --features alloc`. ## Building & testing ```sh -cargo test # full suite (host) +cargo test # default features +cargo test --all-features # adds the http transport tests cargo test --no-default-features --features alloc # verification core only cargo build --no-default-features --features alloc --target wasm32-unknown-unknown ``` +The cross-SDK fixture under [`tests/vectors/`](./tests/vectors) is generated by +the TypeScript SDK; see the README there before changing anything on the wire. + Live end-to-end test against an aggregator (config in `e2e/`): ```sh @@ -174,6 +183,114 @@ cargo run --example split --features http # mint a coin, split it, verify o A self-contained demo application is provided under [`e2e/`](./e2e). +## Upgrading to 3.0 + +Tokens minted by earlier versions of this crate cannot be loaded, and this +release is the first that interoperates with the shipped TypeScript and Java +SDKs. Both changes are on the wire, so there is no migration path for tokens +already in circulation: they have to be re-minted. + +### The certified leaf value binds the reference time + +``` +v = SHA-256( CBOR([ transactionHash, referenceTime ]) ) +``` + +rather than the transaction hash alone, where `referenceTime` is the timestamp of +the consensus seal for the round the request was validated in. A 3.0 client +cannot verify proofs from an older service, and an older client cannot verify +proofs from a current one. + +### Four wire versions move + +| Structure | earlier | 3.0 | +|---|---|---| +| `Token` | 1 | **2** | +| `MintTransaction` | 1 | **2** | +| `TransferTransaction` | 1 | **2** | +| `CertificationData` | 1 | **2** | +| `InclusionProof` | 1 | 1 (unchanged) | + +`Token` at version 2 and the two-element certified transaction below are +corrections: earlier builds of this crate encoded a version-1 token whose +certified transactions carried a third element, and neither shape was ever +readable by the TypeScript or Java SDKs. Anything this crate produced before 3.0 +has to be re-minted regardless of which aggregator it was certified against. + +### A certified transaction is two elements + +`CertifiedMintTransaction` and `CertifiedTransferTransaction` encode +`[transaction, inclusionProof]`. The separate `referenceTime` slot is gone; +`reference_time()` reads it off the inclusion proof, which is the only copy +consensus certified. + +### Requests can carry a deadline + +`expires_at` is an exclusive request deadline in Unix seconds, taken as a +trailing `Option` by `client::mint`, `client::transfer`, `TokenSplit::split` +and the transaction constructors. The service admits a request only to a round +whose reference time is strictly below it, and answers a late one with +`REQUEST_EXPIRED`. + +Pass `None` and the service assigns a deadline from consensus time instead. That +branch is for a caller with no trustworthy clock: the assigned value governs +admission but never enters the leaf, never alters the transaction hash, and is +never re-checked by a later verifier. An explicit deadline is the opposite: the +transaction hash commits to it, so it travels with the token and every verifier +checks it. + +Both the deadline and a round's reference time are wall-clock Unix seconds, not +round numbers, and both are consensus time rather than any caller's clock. Leave +margin for the difference; hour-scale deadlines are unaffected, second-scale ones +are not. + +There are no `*_with_timeout` constructors. Rust has no overloading and no +default arguments, and `Option` is how it spells optional, so the deadline is a +trailing parameter on the one constructor. + +### What a deadline does not guarantee + +Admission is enforced by the aggregator when it accepts the request. A later +verifier confirms that the leaf's recorded reference time is internally +consistent and precedes the deadline, but cannot establish *when* the leaf was +created: that value is chosen by the aggregator, and the inclusion proof +authenticates the value it chose rather than the moment it chose it. An +aggregator that accepted a request after its deadline and recorded an earlier +reference time produces a proof that verifies. + +So `expires_at` is an instruction to an honest service, and the guarantee that a +late request is dropped rests on the same consensus that secures the aggregator. +Verification does reject a leaf claiming to postdate the round that certified it, +which is an impossible pairing, but that bound is one-sided and does not cover +back-dating. Tracked as unicitynetwork/aggregator-go#186. + +### An inclusion proof describes a certified leaf, and nothing else + +`InclusionProof` requires every field: `certification_data`, `reference_time` and +`inclusion_certificate` are no longer `Option`. The aggregator's answer for a +state it has not certified yet is not a proof at all, and +[`InclusionProofResponse`] carries that case: + +```rust +pub enum InclusionProofResponse { + Certified { block_number: u64, proof: InclusionProof }, + NotCertified { block_number: u64, unicity_certificate: UnicityCertificate }, +} +``` + +The response owns the wire's two shapes: it decodes the tagged structure, decides +certified from not, rejects a partially present proof, and builds the +`InclusionProof` from the parts. `VerificationError::InclusionCertificateMissing` +and `VerificationError::CertificationDataMissing` are gone, because neither can +occur. + +`AggregatorClient::get_inclusion_proof` still returns an `InclusionProof` rather +than the response: the polling contract already guarantees a certified leaf, and +an implementor signals "not yet" through its own error type. Decode an +aggregator's raw answer with `InclusionProofResponse::from_cbor`. + +[`InclusionProofResponse`]: https://docs.rs/unicity-token/latest/unicity_token/api/inclusion_proof_response/enum.InclusionProofResponse.html + ## License MIT OR Apache-2.0.