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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
142 changes: 132 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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<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: 2 additions & 1 deletion e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
8 changes: 7 additions & 1 deletion e2e/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<u64, Box<dyn Error>> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3600)
}

fn main() -> Result<(), Box<dyn Error>> {
// Load local development configuration without overriding variables that
// were explicitly supplied by the process environment.
Expand Down Expand Up @@ -56,6 +60,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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");
Expand All @@ -73,6 +78,7 @@ fn main() -> Result<(), Box<dyn Error>> {
&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");
Expand Down
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions examples/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ fn main() {
TokenSalt::random().expect("salt"),
Some(encode_text_string("My custom data")),
None,
/* expires_at */ None,
)
.expect("mint");

Expand Down
5 changes: 4 additions & 1 deletion examples/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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(),
Expand Down Expand Up @@ -177,6 +177,7 @@ fn main() {
TokenSalt::random().expect("salt"),
Some(source_payment.to_cbor()),
None,
/* expires_at */ None,
)
.expect("mint source coin");

Expand Down Expand Up @@ -213,6 +214,7 @@ fn main() {
PaymentAssetCollection::from_cbor_bytes,
requests,
Some(BURN_STATE_MASK),
/* expires_at */ None,
)
.expect("build split");

Expand All @@ -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");

Expand Down
2 changes: 2 additions & 0 deletions examples/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ fn main() {
TokenSalt::random().expect("salt"),
Some(encode_text_string("My custom data")),
None,
/* expires_at */ None,
)
.expect("mint");

Expand All @@ -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");

Expand Down
Loading
Loading