Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 3 additions & 109 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<u64>` 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.
3 changes: 1 addition & 2 deletions e2e/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 28 additions & 22 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String>) {
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>, 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]
Expand All @@ -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 =
Expand Down
Loading