diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58da7c..fd90aa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,13 @@ jobs: run: cargo test --all-features - name: Test (no default features, verification core) run: cargo test --no-default-features --features alloc + - name: Demo crate (own workspace, so nothing above reaches it) + # e2e/ declares its own [workspace], so the root build never compiles it + # and its lockfile drifts from the SDK's silently. + run: | + cargo fmt --manifest-path e2e/Cargo.toml --check + cargo clippy --manifest-path e2e/Cargo.toml --all-targets -- -D warnings + cargo build --manifest-path e2e/Cargo.toml --locked no_std: name: no_std / wasm guest core diff --git a/README.md b/README.md index a3140e1..5419143 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,9 @@ cargo build --no-default-features --features alloc --target wasm32-unknown-unkno 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/`): +Live end-to-end test against an aggregator. It reads `e2e/.env` (copy +`e2e/.env.example` and set `UNICITY_API_KEY`), the same configuration the +examples and the `e2e/` demo crate use: ```sh cargo test --features http --test e2e -- --ignored --nocapture @@ -183,114 +185,6 @@ 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/Cargo.lock b/e2e/Cargo.lock index f2d5e4f..68820ac 100644 --- a/e2e/Cargo.lock +++ b/e2e/Cargo.lock @@ -716,13 +716,12 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicity-token" -version = "0.1.0" +version = "3.0.1" dependencies = [ "getrandom", "hex", "k256", "num-bigint", - "num-traits", "serde_json", "sha2", "ureq", diff --git a/tests/e2e.rs b/tests/e2e.rs index ba2ca88..647a67d 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -1,13 +1,14 @@ //! Live end-to-end test against the testnet2 gateway. //! -//! Reads connection parameters from `e2e/unicity-service` and the trust base -//! from `e2e/bft-trustbase.testnet2.json`, then mints and transfers a token -//! through the real aggregator and verifies the result. +//! Reads connection parameters from `e2e/.env` (see `e2e/.env.example`), the +//! same source the examples and the `e2e/` demo crate use, then mints and +//! transfers a token through the real aggregator and verifies the result. //! //! Ignored by default (it requires network access and live infra). Run with: //! cargo test --features http --test e2e -- --ignored --nocapture #![cfg(feature = "http")] +use std::path::Path; use std::time::Duration; use unicity_token::api::bft::RootTrustBase; @@ -16,22 +17,27 @@ use unicity_token::crypto::signer::{Secp256k1Signer, Signer}; use unicity_token::predicate::builtin::SignaturePredicate; use unicity_token::transaction::ids::{StateMask, TokenSalt, TokenType}; -/// Parse the `key: value` lines of `e2e/unicity-service`. -fn read_service() -> (String, Option) { - let text = std::fs::read_to_string("e2e/unicity-service").expect("e2e/unicity-service"); - let mut gateway = None; - let mut api_key = None; - for line in text.lines() { - if let Some((k, v)) = line.split_once(':') { - let value = v.trim().to_string(); - match k.trim() { - "gateway" => gateway = Some(value), - "api_key" => api_key = Some(value), - _ => {} - } - } - } - (gateway.expect("gateway in e2e/unicity-service"), api_key) +const DEFAULT_GATEWAY: &str = "https://gateway.testnet2.unicity.network/"; +const DEFAULT_TRUSTBASE: &str = "bft-trustbase.testnet2.json"; + +/// Read connection parameters from `e2e/.env`, matching the examples and the +/// `e2e/` demo crate. Values already in the process environment take +/// precedence, so CI can supply the key without writing a file. +fn read_service() -> (String, Option, String) { + dotenvy::from_path("e2e/.env").ok(); + + let gateway = std::env::var("UNICITY_GATEWAY").unwrap_or_else(|_| DEFAULT_GATEWAY.to_string()); + let api_key = std::env::var("UNICITY_API_KEY") + .ok() + .filter(|k| !k.is_empty()); + let trustbase = + std::env::var("UNICITY_TRUSTBASE").unwrap_or_else(|_| DEFAULT_TRUSTBASE.to_string()); + let trustbase_path = if Path::new(&trustbase).is_absolute() { + trustbase + } else { + format!("e2e/{trustbase}") + }; + (gateway, api_key, trustbase_path) } #[test] @@ -42,9 +48,9 @@ fn e2e_mint_transfer_verify() { .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"); + let (gateway, api_key, trustbase_path) = read_service(); + let trust_json = std::fs::read_to_string(&trustbase_path) + .unwrap_or_else(|e| panic!("read trust base {trustbase_path}: {e}")); let trust_base = RootTrustBase::from_json(&trust_json).expect("parse trust base"); let mut aggregator =