From f380cf5fc6f3ec3fdf4b5a7e32fa2fe513490d12 Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Mon, 31 Aug 2026 13:41:57 +0300 Subject: [PATCH 1/2] Bring e2e/ back in sync, and put it under CI `e2e/` declares its own `[workspace]`, so the root build, clippy and test runs never reach it and CI never compiled it. It had drifted on two counts. Its lockfile still recorded `unicity-token` at 0.1.0 and carried a `num-traits` dependency the SDK dropped some time ago. Building with `--locked` now fails if that happens again. The live test read `e2e/unicity-service`, a `key: value` file that exists nowhere in the tree, is not tracked, is not covered by `e2e/.gitignore`, and is mentioned in no README. Anyone following the root README's run instruction got a panic naming a file they had no way to know the format of, and anyone who guessed and created one would have had an API key sitting untracked but un-ignored. It now reads `e2e/.env` through `dotenvy` like the three examples and the demo crate do, with the same `UNICITY_GATEWAY` / `UNICITY_API_KEY` / `UNICITY_TRUSTBASE` names and the same "process environment wins" precedence, so CI can supply a key without writing a file. The trust base path follows `UNICITY_TRUSTBASE` rather than being hardcoded, matching `examples/mint.rs`, and a missing file now says which path it tried. CI gains an fmt, clippy and `--locked` build of the demo crate. --- .github/workflows/ci.yml | 7 ++++++ README.md | 4 +++- e2e/Cargo.lock | 3 +-- tests/e2e.rs | 50 ++++++++++++++++++++++------------------ 4 files changed, 39 insertions(+), 25 deletions(-) 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..6dd70d3 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 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 = From 0f4c11d241ecc627ace29790bc2b9f547230eb4d Mon Sep 17 00:00:00 2001 From: Risto Laanoja Date: Mon, 31 Aug 2026 13:51:11 +0300 Subject: [PATCH 2/2] Drop the "Upgrading to 3.0" section from the README There is nothing to upgrade from. This crate had never been released: it sat at 0.1.0, unpublished, and 3.0.1 is its first release. A section walking through migration from wire versions no consumer ever received is noise in the README, and the parts of it that were corrections rather than protocol changes read as if they had once been shipped behaviour. The migration story belongs in the v3.0.1 release notes, where it is addressed to someone comparing this crate against the TypeScript and Java SDKs rather than against an earlier Rust release. The interop line near the top stays, because which SDK and aggregator versions a 3.0.1 client talks to is a current fact rather than a historical one. --- README.md | 108 ------------------------------------------------------ 1 file changed, 108 deletions(-) diff --git a/README.md b/README.md index 6dd70d3..5419143 100644 --- a/README.md +++ b/README.md @@ -185,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.