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/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 74641e6..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 @@ -49,23 +54,28 @@ 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 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)?; ``` +`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 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 -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; @@ -145,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 @@ -169,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. diff --git a/e2e/README.md b/e2e/README.md index 1fa969d..4f7d893 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 set an exclusive +`expiresAt` deadline one hour ahead of the current Unix time. Defaults: diff --git a/e2e/src/main.rs b/e2e/src/main.rs index c42e0a3..d957ea2 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. @@ -56,6 +60,7 @@ fn main() -> Result<(), Box> { 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"); @@ -73,6 +78,7 @@ fn main() -> Result<(), Box> { &alice, StateMask::random()?, Some(encode_text_string("Rust SDK live e2e transfer")), + Some(request_timeout()?), )?; let transferred_path = config.output_dir.join("token-transferred.cbor"); diff --git a/examples/README.md b/examples/README.md index 4fec293..3d03bff 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 +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/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 82de66f..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"); @@ -116,7 +117,6 @@ fn mint_split_output( let proof = aggregator .get_inclusion_proof(&state_id) .expect("split output inclusion proof"); - let token = Token::new( CertifiedMintTransaction::new(transaction, proof), Vec::new(), @@ -177,6 +177,7 @@ fn main() { TokenSalt::random().expect("salt"), Some(source_payment.to_cbor()), None, + /* expires_at */ None, ) .expect("mint source coin"); @@ -213,6 +214,7 @@ fn main() { PaymentAssetCollection::from_cbor_bytes, requests, Some(BURN_STATE_MASK), + /* expires_at */ None, ) .expect("build split"); @@ -227,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/api/certification.rs b/src/api/certification.rs index cef7b32..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,7 +14,9 @@ use crate::transaction::Transaction; /// CBOR tag for [`CertificationData`]. pub const CERTIFICATION_DATA_TAG: u64 = 39031; -const VERSION: u64 = 1; +/// 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. /// @@ -24,21 +28,26 @@ pub struct CertificationData { lock_script: EncodedPredicate, source_state_hash: DataHash, transaction_hash: DataHash, + 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, + expires_at, unlock_script, } } @@ -50,6 +59,7 @@ impl CertificationData { lock_script: transaction.lock_script().clone(), source_state_hash: transaction.source_state_hash().clone(), transaction_hash: transaction.calculate_transaction_hash(), + expires_at: transaction.expires_at(), unlock_script, } } @@ -66,6 +76,11 @@ impl CertificationData { pub fn transaction_hash(&self) -> &DataHash { &self.transaction_hash } + /// 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] { &self.unlock_script @@ -73,24 +88,22 @@ 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), - &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(Some(5))?; - let version = items[0].uint()?; - if version != VERSION { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != CERTIFICATION_DATA_VERSION { return Err(Error::UnexpectedValue( "unsupported CertificationData version", )); @@ -99,7 +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()?)?, - unlock_script: items[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(), }) } } @@ -107,6 +121,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; @@ -135,6 +152,7 @@ mod tests { TokenSalt::from_bytes([0u8; 32]), None, None, + Some(TIMEOUT), ) .unwrap(); @@ -147,7 +165,7 @@ mod tests { assert_eq!( cert.to_cbor(), hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" ) ); @@ -158,4 +176,51 @@ 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, + 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.expires_at(), None); + assert_eq!(cert.expires_at(), None); + assert_eq!(cert.to_cbor(), hex!( + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820c034e096d7bdf71ba759558663b5cafb7279ecb7e284443e5e6cbce0461aceeef6584154ca6b19a7dbcae7a6adc38af5c8672f81943ecaf51345436684299b4b7ac81a57db2653f32048981e37913db4749ca08d998d1fac4a52ab5579988bc2c50de900" + )); + } + + /// 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 6bee25a..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!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "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!( - "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e70000" + "d9987684015820ffb36b55de9bfaf48b766d1f4e041a6c5d35ba23b402ea2a56a6c7692cb8f81ad998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e0000" ) ); } diff --git a/src/api/inclusion_proof.rs b/src/api/inclusion_proof.rs index 56580c9..926761d 100644 --- a/src/api/inclusion_proof.rs +++ b/src/api/inclusion_proof.rs @@ -7,44 +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, - /// The SMT path (present for an inclusion proof). - pub inclusion_certificate: Option, + /// 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: 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(4))?; - if items[0].uint()? != VERSION { - return Err(Error::UnexpectedValue("unsupported InclusionProof version")); - } - let certification_data = items[1].nullable(CertificationData::from_cbor)?; - let inclusion_certificate = - items[2].nullable(|x| InclusionCertificate::decode(x.bytes_value()?))?; - Ok(InclusionProof { - certification_data, - inclusion_certificate, - unicity_certificate: UnicityCertificate::from_cbor(items[3])?, - }) + let parts = DecodedParts::from_cbor(d)?; + parts.into_certified() } /// Encode to CBOR (tagged). @@ -53,10 +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.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(), ]), ) @@ -67,11 +73,83 @@ 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) + } +} + +/// 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/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..b11b787 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,8 @@ 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; pub mod non_inclusion_proof; @@ -14,13 +16,15 @@ 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 inclusion_proof_response::InclusionProofResponse; +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/http.rs b/src/client/http.rs index 69ea22d..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}; @@ -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)] @@ -341,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. @@ -402,6 +402,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); } @@ -416,14 +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)?; - if proof.certification_data.is_none() || proof.inclusion_certificate.is_none() { - return Err(HttpError::Decode( - "inclusion proof is missing required relation data".to_string(), - )); + // 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; + } } - return Ok(proof); } Err(HttpError::Timeout) } diff --git a/src/client/mod.rs b/src/client/mod.rs index 6d145dd..94f38d8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -116,7 +116,7 @@ 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`]. #[allow(clippy::too_many_arguments)] pub fn mint( aggregator: &A, @@ -127,6 +127,7 @@ pub fn mint( salt: TokenSalt, data: Option>, justification: Option>, + expires_at: Option, ) -> Result> { trust_base .validate() @@ -141,6 +142,7 @@ pub fn mint( salt, data, justification, + expires_at, )?; // The genesis is unlocked by the deterministic minter key for the token id. @@ -154,7 +156,6 @@ pub fn mint( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; - let token = Token::new( CertifiedMintTransaction::new(transaction, proof), Vec::new(), @@ -165,6 +166,7 @@ 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, @@ -173,6 +175,7 @@ pub fn transfer( signer: &impl Signer, 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. @@ -184,6 +187,7 @@ pub fn transfer( EncodedPredicate::from_predicate(recipient), state_mask.bytes().to_vec(), data, + expires_at, ); let certification_data = certification_data_for(&transaction, signer); @@ -195,7 +199,6 @@ pub fn transfer( let proof = aggregator .get_inclusion_proof(&state_id) .map_err(ClientError::Aggregator)?; - let mut transactions = token.transactions().to_vec(); transactions.push(CertifiedTransferTransaction::new(transaction, proof)); let next = Token::new(token.genesis().clone(), transactions); @@ -206,6 +209,9 @@ pub fn transfer( #[cfg(test)] mod tests { use super::*; + + /// Exclusive certification request deadline used by these tests. + const TIMEOUT: u64 = 1755000000; use crate::crypto::signature::PublicKey; use crate::predicate::builtin::SignaturePredicate; use core::cell::RefCell; @@ -270,6 +276,7 @@ mod tests { TokenSalt::from_bytes([0u8; 32]), None, None, + Some(TIMEOUT), ) .unwrap_err(); assert_eq!(err, ClientError::Aggregator("no proof in mock")); @@ -278,7 +285,7 @@ mod tests { assert_eq!( captured, hex!( - "d998778501d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820df524cffc08a1dc30579a8a51f440a97b30630988084f8d12a4d8bd741c7791258419efb637f14dbdaada6e293e2182932d82265b04b1abf4f28bc4c285b32b5e2325140fe7f94bc9b705c568b4fcb7f9ea90cf0fadcacc1b4504275f81558aad1e700" + "d998778602d9987883014101582103a19eef04b8856f50bf2d688b0d8804575115e53d2a7780da363628343f9635075820e4b183ff6b7a399983cee26e4feea85d517dede0142def5c838e593a9e6152415820ed275ff0a0694d1b61ec22f13914a431569220ba7f2f043d7940aac78d02c2f91a689b2cc0584111f0f7929d70e0e32db9159b7e23b6e0043502bc36609728e9dc0353251c241a7b1adb047c9234cd77ed519c409048a6c8bc247f0262c1f161b03d6fee49426e00" ) ); } diff --git a/src/payment/split.rs b/src/payment/split.rs index 8824019..8fbf302 100644 --- a/src/payment/split.rs +++ b/src/payment/split.rs @@ -148,10 +148,12 @@ 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, burn_state_mask).map_err(SplitError::Build) + Self::build_split(token, assets, requests, burn_state_mask, burn_expires_at) + .map_err(SplitError::Build) } /// Split `token` **without verifying it first**. @@ -167,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() @@ -174,7 +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, 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 @@ -185,6 +188,7 @@ impl TokenSplit { assets: PaymentAssetCollection, requests: Vec, 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(); @@ -253,12 +257,14 @@ impl TokenSplit { None => random_mask()?, }; let (source_state_hash, lock_script) = token.latest_state(); + let recipient = burn_predicate.to_encoded(); let burn_transaction = TransferTransaction::new( source_state_hash, lock_script, - burn_predicate.to_encoded(), + recipient, mask.to_vec(), Some(manifest_bytes.clone()), + burn_expires_at, ); // Build each output with its per-asset proofs (canonical output order). diff --git a/src/payment/tests.rs b/src/payment/tests.rs index 79e1ac0..0f671b8 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,11 @@ use crate::verify::{ VerificationPolicy, }; +/// 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) ----------- fn signer(b: u8) -> Secp256k1Signer { @@ -126,17 +132,19 @@ 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(), transaction.source_state_hash().clone(), tx_hash, unlock, + Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { - certification_data: Some(certification_data), - 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), } } @@ -170,6 +178,7 @@ fn source_token(node: &Secp256k1Signer, owner: &Secp256k1Signer) -> Token { TokenSalt::from_bytes([0x01; 32]), Some(payment.to_cbor()), None, + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -208,6 +217,7 @@ fn mint_output( salt, Some(assets.to_cbor()), Some(justification.to_cbor()), + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -322,6 +332,7 @@ fn forged_output_with_type( burn_predicate.to_encoded(), 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(); @@ -362,6 +373,7 @@ fn split_outputs_verify_end_to_end() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); @@ -448,6 +460,7 @@ fn recursive_split_verification_honors_shared_depth_limit() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -511,6 +524,7 @@ fn rejects_tampered_output_amount() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -549,6 +563,7 @@ fn rejects_dropped_proof() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let burned = burned_token(&s.source, split.burn.transaction.clone(), &s.alice, &s.node); @@ -583,6 +598,7 @@ fn rejects_wrong_burn_predicate() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); let registry = registry(); @@ -596,6 +612,7 @@ fn rejects_wrong_burn_predicate() { BurnPredicate::new(b"not-the-manifest-hash".to_vec()).to_encoded(), vec![7u8; 32], Some(split.burn.manifest.clone()), + Some(TIMEOUT), ); let burned = burned_token(&s.source, wrong_burn, &s.alice, &s.node); assert_eq!( @@ -643,6 +660,7 @@ fn rejects_missing_manifest() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); // Burn with no auxiliary manifest data at all. @@ -653,6 +671,7 @@ fn rejects_missing_manifest() { BurnPredicate::new(b"x".to_vec()).to_encoded(), vec![3u8; 32], None, + Some(TIMEOUT), ); let burned = burned_token(&s.source, burn, &s.alice, &s.node); let out = &split.tokens[0]; @@ -682,6 +701,7 @@ fn rejects_manifest_length_mismatch() { PaymentAssetCollection::from_cbor_bytes, s.requests, Some([7u8; 32]), + Some(TIMEOUT), ) .unwrap(); // A self-consistent burn whose manifest has one root, although the source @@ -694,6 +714,7 @@ fn rejects_manifest_length_mismatch() { BurnPredicate::new(short.reason_hash().to_vec()).to_encoded(), 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]; @@ -733,6 +754,7 @@ fn rejects_wrong_output_token_type() { PaymentAssetCollection::from_cbor_bytes, bad, Some([7u8; 32]), + Some(TIMEOUT) ) .is_err()); } @@ -757,6 +779,7 @@ fn rejects_unbalanced_split_at_build_time() { PaymentAssetCollection::from_cbor_bytes, bad, Some([7u8; 32]), + Some(TIMEOUT) ) .is_err()); } diff --git a/src/transaction/certified.rs b/src/transaction/certified.rs index b4d5b81..31fddde 100644 --- a/src/transaction/certified.rs +++ b/src/transaction/certified.rs @@ -1,7 +1,12 @@ //! 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]`. +//! 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 +//! 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; @@ -37,6 +42,14 @@ impl CertifiedMintTransaction { pub fn inclusion_proof(&self) -> &InclusionProof { &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.inclusion_proof.reference_time + } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { self.transaction.recipient() @@ -85,6 +98,12 @@ impl CertifiedTransferTransaction { pub fn inclusion_proof(&self) -> &InclusionProof { &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.inclusion_proof.reference_time + } /// The recipient predicate (lock script of the next state). pub fn recipient(&self) -> &EncodedPredicate { self.transaction.recipient() diff --git a/src/transaction/mint.rs b/src/transaction/mint.rs index 6ad546d..d149902 100644 --- a/src/transaction/mint.rs +++ b/src/transaction/mint.rs @@ -16,7 +16,9 @@ use crate::predicate::EncodedPredicate; /// CBOR tag for [`MintTransaction`]. pub const MINT_TRANSACTION_TAG: u64 = 39041; -const VERSION: u64 = 1; +/// 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 @@ -25,6 +27,7 @@ const VERSION: u64 = 1; pub struct MintTransaction { network_id: NetworkId, recipient: EncodedPredicate, + expires_at: Option, salt: TokenSalt, token_type: TokenType, justification: Option>, @@ -38,6 +41,7 @@ pub struct MintTransaction { impl MintTransaction { /// Build a mint transaction, deriving the token id, lock script, and mint /// state. + #[allow(clippy::too_many_arguments)] pub fn create( network_id: NetworkId, recipient: EncodedPredicate, @@ -45,6 +49,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(); @@ -52,6 +57,7 @@ impl MintTransaction { Ok(MintTransaction { network_id, recipient, + expires_at, salt, token_type, justification, @@ -90,9 +96,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(Some(7))?; - let version = items[0].uint()?; - if version != VERSION { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != MINT_TRANSACTION_VERSION { return Err(Error::UnexpectedValue( "unsupported MintTransaction version", )); @@ -108,7 +113,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 expires_at = items[7].nullable(|d| d.uint().map_err(Into::into))?; + MintTransaction::create( + network_id, + recipient, + token_type, + salt, + data, + justification, + expires_at, + ) } } @@ -125,6 +139,10 @@ impl Transaction for MintTransaction { self.source_state.hash() } + fn expires_at(&self) -> Option { + self.expires_at + } + fn calculate_state_hash(&self) -> DataHash { // stateMask for a mint is the token id bytes. sha256(&encode_array(&[ @@ -134,17 +152,16 @@ 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)), - ]), - ) + 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)), + &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 7702abf..172a96e 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -29,6 +29,9 @@ pub trait Transaction { fn source_state_hash(&self) -> &DataHash; /// The hash of the state this transaction produces. fn calculate_state_hash(&self) -> DataHash; + /// 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/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/transaction/transfer.rs b/src/transaction/transfer.rs index 38b5353..e9fb73a 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; +pub const TRANSFER_TRANSACTION_VERSION: u64 = 2; +const FIELD_COUNT: usize = 5; /// A token transfer transaction. #[derive(Debug, Clone, PartialEq, Eq)] @@ -28,6 +29,7 @@ pub struct TransferTransaction { lock_script: EncodedPredicate, // On the wire: recipient: EncodedPredicate, + expires_at: Option, state_mask: Vec, data: Option>, } @@ -42,11 +44,13 @@ impl TransferTransaction { recipient: EncodedPredicate, state_mask: Vec, data: Option>, + expires_at: Option, ) -> Self { TransferTransaction { source_state_hash, lock_script, recipient, + expires_at, state_mask, data, } @@ -70,9 +74,8 @@ impl TransferTransaction { lock_script: EncodedPredicate, ) -> Result { let inner = d.expect_tag(TRANSFER_TRANSACTION_TAG)?; - let items = inner.array(Some(4))?; - let version = items[0].uint()?; - if version != VERSION { + let items = inner.array(Some(FIELD_COUNT))?; + if items[0].uint()? != TRANSFER_TRANSACTION_VERSION { return Err(Error::UnexpectedValue( "unsupported TransferTransaction version", )); @@ -81,12 +84,14 @@ 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 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, )) } } @@ -104,6 +109,10 @@ impl Transaction for TransferTransaction { &self.source_state_hash } + fn expires_at(&self) -> Option { + self.expires_at + } + fn calculate_state_hash(&self) -> DataHash { sha256(&encode_array(&[ &encode_byte_string(&self.source_state_hash.imprint()), @@ -112,14 +121,13 @@ impl Transaction for TransferTransaction { } fn to_cbor(&self) -> Vec { - encode_tag( - TRANSFER_TRANSACTION_TAG, - &encode_array(&[ - &encode_uint(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/error.rs b/src/verify/error.rs index 81994af..e9dfefd 100644 --- a/src/verify/error.rs +++ b/src/verify/error.rs @@ -58,14 +58,14 @@ 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. + 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. PathInvalid, /// The non-inclusion certificate did not authenticate against the certified root. @@ -168,14 +168,14 @@ 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") } VerificationError::TransactionHashMismatch => write!(f, "transaction hash mismatch"), + VerificationError::ReferenceTimeMismatch => { + write!(f, "inclusion proof reference time 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 503a3db..0d35e40 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, @@ -129,17 +131,18 @@ 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); } - 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,18 +159,13 @@ fn verify_inclusion_proof( trust_base: &RootTrustBase, proof: &InclusionProof, 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() + || certification_data.expires_at() != transaction.expires_at() { return Err(VerificationError::CertificationDataMismatch); } @@ -182,7 +180,7 @@ 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`. @@ -193,18 +191,13 @@ pub fn verify_inclusion_proof_for( trust_base: &RootTrustBase, proof: &InclusionProof, state_id: &StateId, + reference_time: u64, ) -> Result<(), VerificationError> { 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(), @@ -214,16 +207,25 @@ pub fn verify_inclusion_proof_for( return Err(VerificationError::CertificationDataMismatch); } + // 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); + } + } + let expected_root = DataHash::new( HashAlgorithm::Sha256, proof.unicity_certificate.input_record.hash.clone(), ) .map_err(|_| VerificationError::PathInvalid)?; - if !inclusion_certificate.verify( - state_id, - certification_data.transaction_hash(), - &expected_root, - ) { + if proof.reference_time != reference_time { + 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) { return Err(VerificationError::PathInvalid); } @@ -239,6 +241,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 +350,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 +380,11 @@ fn verify_predicate( mod tests { use super::*; + /// 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}; use crate::api::bft::{ @@ -510,17 +519,19 @@ 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(), transaction.source_state_hash().clone(), tx_hash, unlock, + Some(transaction.expires_at().expect("explicit timeout fixture")), ); InclusionProof { - certification_data: Some(certification_data), - 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), } } @@ -532,6 +543,7 @@ mod tests { SignaturePredicate::new(recipient.public_key()).to_encoded(), alloc::vec![7u8; 32], None, + Some(TIMEOUT), ) } @@ -569,7 +581,7 @@ mod tests { } fn cert(proof: &InclusionProof) -> &CertificationData { - proof.certification_data.as_ref().unwrap() + &proof.certification_data } // --- baseline ---------------------------------------------------------- @@ -577,11 +589,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) ); } @@ -683,39 +698,25 @@ 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), - 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), - 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), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) ); } @@ -724,30 +725,108 @@ 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) + ); + } + + #[test] + fn rule_certification_data_mismatch_timeout() { + let (tb, _n, _o, transfer, mut proof) = transfer_case(); + let c = cert(&proof); + 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), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::CertificationDataMismatch) ); } + /// 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. + /// + /// 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() { + use crate::cbor::{encode_array, encode_byte_string, encode_null, encode_tag}; + + 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" + ); + } + } + + #[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(); 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"), // wrong tx hash + sha256(b"not-the-tx-hash"), c.unlock_script().to_vec(), - )); + Some( + // wrong tx hash + TIMEOUT, + ), + ); assert_eq!( - verify_inclusion_proof(&tb, &proof, &transfer), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::TransactionHashMismatch) ); } @@ -759,7 +838,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 +851,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 +861,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 +871,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 +881,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 +893,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) ); } @@ -825,14 +904,15 @@ 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), + verify_inclusion_proof(&tb, &proof, &transfer, REFERENCE_TIME), Err(VerificationError::NotAuthenticated) ); } @@ -844,7 +924,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) ); } @@ -864,6 +944,7 @@ mod tests { TokenSalt::from_bytes([0x66; 32]), None, justification, + Some(TIMEOUT), ) .unwrap(); let minter = Minter::signer(mint.token_id()).unwrap(); @@ -906,12 +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), @@ -1015,6 +1097,7 @@ mod tests { SignaturePredicate::new(recipient.public_key()).to_encoded(), alloc::vec![9u8; 32], None, + Some(TIMEOUT), ); let mut transfer_proof = valid_proof(&transfer, &owner, &node); @@ -1062,6 +1145,7 @@ mod tests { SignaturePredicate::new(recipient.public_key()).to_encoded(), 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 46eb336..ba2ca88 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"); @@ -60,6 +65,7 @@ fn e2e_mint_transfer_verify() { TokenSalt::random().unwrap(), None, None, + Some(timeout), ) .expect("mint"); token.verify(&trust_base).expect("verify minted token"); @@ -72,6 +78,7 @@ fn e2e_mint_transfer_verify() { &alice, StateMask::random().unwrap(), None, + Some(timeout), ) .expect("transfer"); transferred diff --git a/tests/http_transport.rs b/tests/http_transport.rs index 9843b34..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,19 +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 { + 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 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 { @@ -315,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. @@ -329,12 +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 { - 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()); @@ -347,7 +379,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()); @@ -359,12 +391,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]); @@ -416,7 +469,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, @@ -435,7 +488,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), @@ -444,7 +497,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), diff --git a/tests/transition_flow.rs b/tests/transition_flow.rs index 0acb3f6..4536c6b 100644 --- a/tests/transition_flow.rs +++ b/tests/transition_flow.rs @@ -8,13 +8,20 @@ //! 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 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; -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}; +use unicity_token::transaction::{CertifiedTransferTransaction, Token, Transaction}; use unicity_token::verify::VerificationError; const FIXTURE: &str = include_str!("vectors/transition_flow.json"); @@ -58,16 +65,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 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_present_and_absent_deadlines() { + let tb = trust_base(); + + let (_, default_token) = token("aliceToken"); + 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() + .expires_at() + .expect("explicit deadline is present"); + assert!( + genesis.reference_time() < timeout, + "certified reference time {} must precede the request deadline {timeout}", + genesis.reference_time() + ); + explicit_token + .verify(&tb) + .expect("explicit deadline 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) @@ -187,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()); } @@ -200,16 +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"); - proof.certification_data = Some(CertificationData::new( + let data = proof.certification_data.clone(); + // Rebuild with the same fields, so only the substituted source state differs. + 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(), @@ -224,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 645f881..ad6345c 100644 --- a/tests/vectors/transition_flow.json +++ b/tests/vectors/transition_flow.json @@ -1,12 +1,14 @@ { - "__comment": "generated by state-transition-sdk-js f4cc9056375759844fc1217289d765948dbbd88b", - "trustBase": { - "networkId": 3, - "nodeId": "NODE", - "aggregatorPublicKey": "02d290e9edc006f4b0fbce2f77f5361dac479a47718ab68f6bc33b187139e00834", - "quorumThreshold": "1" - }, - "aliceToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd160180", - "bobToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd16018182d998858401d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba582094df64fc3f5168111b03ee30ff0c43e213d3320c92cfc290122ad2f78974a176f6d998798401d998778501d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c8875820787e7ace403ead5472c23caafce2655a7e3609a3825f591302e58c0031eb76175820b714555a7187a102a474abae499c21cd817b6fd62da674bbfd46786af0ef6e73584175fd30f6a3180e73a9b1699d548fb21cf6f367b256182507cff9a57b1433d27b11313869693c6ed73ce419ba7b31dfb2819d612b5f62ba3995a40c7c00df4b9501584020000000000000000000000000000000000000000000000000000000000000000aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb60d998598701d9985a8a010000f65820d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820dc5b452f911ce2372a539f7e28eaaafb7e6eabc07c3712a1d98ea57d356a5e8aa1644e4f444558411c3ea96557c571481fa82c68c164690fca34b77b937eb68ee4a235fde58c77435a2b1637be0b76325cc550c2e61badc077c6c74bc8078a68d6987f94d5e197d200", - "carolToken": "d99880830182d99881870103d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c88758203c7ab90b2172482fdcb4e85107c9e2b16ea81704ecec7d792e2736a934b4286b5820286864a8a3adc6315b46016622103f1c3fd5be0d504323cb924219a820f39da5f6f6d998798401d998778501d9987883014101582102794247f7bb8c2dea146d7e54859c3579b716aeafec47bd4a7dc9c95828558b3d5820ea83627c6dc2d763ba5f07e58efcff360674810483800f6fcffad606d68a8499582084e1e2b80b7e46247830aa98d9e26f2e46eccb244189ffcf11dc71fd7cea75d95841aa13bc22f2779cee6f53d78012a3b8170db0a183d5100008e8aef50b3dc2071865d12e90f6be06ec5c590bca7a71d8bd9d0f82be7b15cb0f5e9499a3c5cf58d60158200000000000000000000000000000000000000000000000000000000000000000d998598701d9985a8a010000f658200aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb604000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f658208d7a85069732fa3378339ca2694b40642d63f7d268ab9938815e021ac077b8cba1644e4f44455841001a9a2d110e1cf2feae864979ef26744b857aee8247f1b09d6b7cd6c1c8656d1df17a240faad11b1f573fae6214d150335e0b2bbe5ec8ac22a52cbc5057fd16018282d998858401d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba582094df64fc3f5168111b03ee30ff0c43e213d3320c92cfc290122ad2f78974a176f6d998798401d998778501d998788301410158210201094a1e421e3a02c1af74946867d89fd801972e84420244e6b199fa84d1c8875820787e7ace403ead5472c23caafce2655a7e3609a3825f591302e58c0031eb76175820b714555a7187a102a474abae499c21cd817b6fd62da674bbfd46786af0ef6e73584175fd30f6a3180e73a9b1699d548fb21cf6f367b256182507cff9a57b1433d27b11313869693c6ed73ce419ba7b31dfb2819d612b5f62ba3995a40c7c00df4b9501584020000000000000000000000000000000000000000000000000000000000000000aebb240e2c73397bda5a00adfba2cd97257cef004907672fdd2a0850c8adb60d998598701d9985a8a010000f65820d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820dc5b452f911ce2372a539f7e28eaaafb7e6eabc07c3712a1d98ea57d356a5e8aa1644e4f444558411c3ea96557c571481fa82c68c164690fca34b77b937eb68ee4a235fde58c77435a2b1637be0b76325cc550c2e61badc077c6c74bc8078a68d6987f94d5e197d20082d998858401d998788301410158210208344650f76b0a1c79b1b735f1d949031bc5f3a510155201cb2e9529a6cd919b5820d1a661ba4a72508795367cec99d720597deb9d5bdb5ac856dcf41519bdb32500f6d998798401d998778501d998788301410158210394be5745a8d545e8c3fd7e59dcd39da0f8edcd29ff1f6d98e8f0136b721be2ba58202d65214ce5470ff9615b370325562f8f38387fc30b54f433584d5221b6c825f0582012e9847ca74d6843679b1d41f3904f7e3bd5f8c756173e25833892047e56d417584114495a5b6fbfe68a99f34dcf7bc45b913e270f467c32ff465f23973a6fad0eb007afd15fbb01414e77ff096fec93ad2c54bc30f3286e0875b7f1ab83d85619f20158408000000000000000000000000000000000000000000000000000000000000000d6c8d918237d3a7e528de669ce22974a7600656b74ee770bfe82179ace16c97ed998598701d9985a8a010000f65820a03babab0dc839cd821fdaf51aaedd502f2f9a2693c8a49b5fbee9044cee5f4e4000f600f6f658200000000000000000000000000000000000000000000000000000000000000000d9985b8301418080d9985c83010080d9985d880103000000f65820ab26a407db11641b7edd928c5ac36394c54a49e7bb5ae0dbc930867daf156b91a1644e4f444558418a912f4d6d1f93fcf9404e3e94884bcc4458cf2c5573fdcb971c1a72ad1253b508a23f9909cf829afee63bf08d66527ad5000b00e6e89cc8120210dddfb0d36a01" + "__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" }