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
1 change: 0 additions & 1 deletion Cargo.lock

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

5 changes: 2 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,9 @@ http = ["std", "client", "dep:ureq", "dep:url", "dep:zeroize"]
sha2 = { version = "0.10", default-features = false }
k256 = { version = "0.13", default-features = false, features = ["ecdsa", "alloc"] }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
# Arbitrary-precision unsigned integers for sparse-Merkle-tree path routing
# (the plain/sum trees used by token split). no_std + alloc, no host deps.
# Arbitrary-precision unsigned integers for 256-bit asset amounts and the radix
# sparse Merkle sum tree sums used by token split. no_std + alloc, no host deps.
num-bigint = { version = "0.4", default-features = false }
num-traits = { version = "0.2", default-features = false }
getrandom = { version = "0.2", optional = true }
serde_json = { version = "1", optional = true }
ureq = { version = "2", optional = true }
Expand Down
177 changes: 87 additions & 90 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,141 +1,138 @@
# Unicity State Transition SDK - Rust
# Unicity State Transition SDK -- Rust

A Rust SDK for the Unicity token state-transition protocol.

The crate is ready to **cryptographically verify token histories inside a
zkVM guest** (SP1 / RISC0) or `wasm32`, so the core is `no_std`, allocation-light,
and free of C dependencies (RustCrypto `sha2` + `k256`).
The crate is `no_std`-first: the verification core has no C dependencies
(RustCrypto `sha2` + `k256`), is allocation-light, and runs inside a **zkVM
guest** (SP1 / RISC0) or on `wasm32`. The default build adds the necessary
client for minting and transferring tokens.

Token creation expects `client` and `http` features. Async `AggregatorClient`
has to be provided by the user.
```toml
[dependencies]
unicity-token = "0.1"
```

## Security model

Decoding a token proves its structural integrity only. Trust is established only by
`Token::verify`, which walks an unbroken chain of cryptographic checks from a
caller-supplied root of trust down to every state in the token's history.

## Usage
`Token::verify` treats application `data` and any mint *justification* as
**opaque** and rejects a token that carries either — unless you explicitly
register a verifier for it. There is no implicit trust in the API.

The root of trust is the Unicity Trust Base json. An authentic trust base must
be bundled with the application or left user-configurable.

## Verify a token (`no_std` core, no features needed)

```rust
use unicity_token::Token;
use unicity_token::api::bft::RootTrustBase;

// The root of trust (validator set + quorum), supplied out-of-band.
let trust_base = RootTrustBase::from_json(trust_base_json)?; // host; or ::new(..) in no_std
// Root of trust (validator set + quorum), supplied out-of-band.
let trust_base = RootTrustBase::from_json(trust_base_json)?; // ::new(..) in no_std

let token = Token::from_cbor(&token_bytes)?; // decoding confers NO trust
token.verify(&trust_base)?; // verifies the cryptographic history
```

## Mint & transfer against a live aggregator (`http` feature)

```rust
use std::time::Duration;
use unicity_token::api::bft::RootTrustBase;
use unicity_token::client::{self, HttpAggregatorClient};

let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.json")?)?;
let aggregator = HttpAggregatorClient::new("https://gateway.testnet2.unicity.network/")
.with_api_key("sk_…")
.with_polling(Duration::from_secs(2), 90);

let token = Token::from_cbor(&token_bytes)?; // decoding confers NO trust
token.verify(&trust_base)?; // verifies cryptographic history
let token = client::mint(&aggregator, &trust_base, trust_base.network_id,
&recipient, token_type, salt, /* data */ None, /* justification */ None)?;
```

`Token::verify` establishes the cryptographic chain of custody but deliberately
treats application `data` (and any mint *justification*) as opaque — a mint that
carries either is rejected unless you opt in to a verifier for it.
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.

## Payment, assets & token split
## Payment tokens & splits

A token can carry a fungible **payment payload** — a `PaymentAssetCollection`
(asset id → amount) — in its mint `data`, and a token can be **split** into
several new tokens whose assets sum to the original. The source token is burned
to an aggregation-tree root, and each output's genesis carries a
`SplitMintJustification` (CBOR tag 39044) proving its share.
A token can carry a fungible **payment payload** (a canonical set of asset id →
amount entries) in its mint `data`, and can be **split** into new tokens whose
per-asset allocations sum to the original. Splitting burns the source and proves
each output's share with a radix sparse Merkle sum tree (RSMST) inclusion proof.

Verifying payment tokens is **fail-closed and policy-gated**: cryptographic
certification alone never authorizes an asset issuer, so you must register the
verifiers you trust and call `payment::verify_payment_token` (not bare
`Token::verify`):
Payment verification is **fail-closed and policy-gated** cryptographic
validity never authorizes an asset issuer on its own. You register the verifiers
and issuance policy you trust, then call `payment::verify_payment_token` instead
of bare `Token::verify`:

```rust
use unicity_token::payment::{
verify_payment_token, PaymentAssetCollection, PaymentDataVerifier,
SplitMintJustificationVerifier,
};
use unicity_token::verify::{MintJustificationRegistry, VerificationError};
use unicity_token::transaction::CertifiedMintTransaction;

// Your application's issuance policy: is this genesis allowed to mint this
// payload? (Cryptographic validity is necessary but not sufficient.)
fn authorize(genesis: &CertifiedMintTransaction, assets: &PaymentAssetCollection)
-> Result<(), VerificationError> { /* check issuer key, supply, … */ Ok(()) }
use unicity_token::verify::MintJustificationRegistry;

// `authorize` is your closure: given the genesis + decoded assets, decide
// whether this issuance is allowed (issuer key, supply caps, …).
let mut registry = MintJustificationRegistry::new();
registry
// accept tokens minted as outputs of a split:
.register(Box::new(SplitMintJustificationVerifier::new()))?
// validate the fungible payload for your token type + run your policy:
.register_token_data(Box::new(PaymentDataVerifier::new(my_token_type, authorize)))?;
.register(Box::new(SplitMintJustificationVerifier::new()))? // accept split outputs
.register_token_data(Box::new(PaymentDataVerifier::new( // validate the payload…
my_token_type, authorize)))?; // …then run *your* policy

// Returns the validated assets; errors fail-closed if anything is off.
let assets = verify_payment_token(
&token, &trust_base, &registry, PaymentAssetCollection::from_cbor_bytes,
)?;
)?; // returns the validated assets; fails closed if anything is off
```

Constructing a split (mint → split → mint outputs) is a `client`-feature
operation via `payment::TokenSplit::split`. The verifier independently re-checks
value conservation, so client-side construction is untrusted. The split proofs
ride on bigint-routed sparse Merkle trees in the `smt` module; their decoding is
bounded by the `MAX_SMT_*` limits and the cumulative `VerificationPolicy` /
`VerificationLimits` (embedded-token depth and byte budgets) to stay safe on
attacker-controlled input. See `src/payment/tests.rs` for an end-to-end example.

## Feature Flags
Constructing a split is a `client`-feature operation
(`payment::TokenSplit::split`) that verifies the source token fully before
building the irreversible burn. See `src/payment/tests.rs` for the end-to-end
split → verify flow.

- `std` *(default)* — std error integration, host RNG, JSON trust-base parsing.
- `client` *(default)* — transaction construction, signing, the mint/transfer
flow over a caller-supplied [`AggregatorClient`] transport, and token-split
construction (`payment::TokenSplit`). Payment/asset *verification* is in the
`no_std` core and needs no feature.
- `http` — a blocking JSON-RPC [`HttpAggregatorClient`] for talking to a live
aggregator/gateway (pulls in a TLS HTTP stack; host only).
- `alloc` — required by the core; enabled transitively.
## Feature flags

### Connecting to a live aggregator
| Flag | Default | Purpose |
|------|:-------:|---------|
| `alloc` | (transitive) | Required by the core (`Vec`/`BTreeMap` for CBOR + structures). |
| `std` | y | std error integration, host RNG, JSON trust-base parsing. |
| `client` | y | Transaction construction, signing, mint/transfer flow, split construction. |
| `http` | | Blocking JSON-RPC `HttpAggregatorClient` (host only; pulls in a TLS stack). |

```rust
use std::time::Duration;
use unicity_token::api::bft::RootTrustBase;
use unicity_token::client::{self, HttpAggregatorClient};

let trust_base = RootTrustBase::from_json(&std::fs::read_to_string("trust-base.json")?)?;
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)?;
```
Payment/asset **verification** is provided by `no_std` core and needs no feature.
The zkVM/WASM guest build is `--no-default-features --features alloc`.

A live end-to-end test is in `tests/e2e.rs` (reads `e2e/`):
## Building & testing

```sh
cargo test --features http --test e2e -- --ignored --nocapture
cargo test # full suite (host)
cargo test --no-default-features --features alloc # verification core only
cargo build --no-default-features --features alloc --target wasm32-unknown-unknown
```

Independent demo application is under `./e2e/`.

The zkVM/WASM guest build is `--no-default-features --features alloc`: pure
`no_std` decode + verify.

## Building
Live end-to-end test against an aggregator (config in `e2e/`):

```sh
cargo test # full suite (host)
cargo test --no-default-features --features alloc # verification core
cargo build --no-default-features --features alloc --target wasm32-unknown-unknown
cargo test --features http --test e2e -- --ignored --nocapture
```

## Examples

Runnable example flows (see [`examples/`](./examples)):
Runnable flows in [`examples/`](./examples), talking to a live aggregator via
`HttpAggregatorClient` (config from `e2e/.env`):

```sh
cargo run --example mint --features http # mint a token, print its CBOR
cargo run --example transfer --features http # mint then transfer, print the CBOR
cargo run --example split --features http # mint a coin, split it, verify outputs
cargo run --example mint --features http # mint a token, print its CBOR
cargo run --example transfer --features http # mint then transfer
cargo run --example split --features http # mint a coin, split it, verify outputs
```

The `mint`/`transfer`/`split` examples talk to a live aggregator via
[`HttpAggregatorClient`] (config from `e2e/.env`). For a transport-agnostic
in-memory flow, the SDK is generic over the `AggregatorClient` trait. The
[Payment, assets & token split](#payment-assets--token-split) section and
`src/payment/tests.rs` cover the split → verify flow end-to-end.

There is a self-contained application under `e2e/`.
A self-contained demo application is provided under [`e2e/`](./e2e).

## Regenerating cross-SDK fixtures

Expand Down
18 changes: 12 additions & 6 deletions examples/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
//!
//! Mints a coin token carrying a fungible payment payload (300 EUR + 500 USD),
//! splits it into three new coins (150 EUR, 150 EUR, 500 USD), burns the
//! original, mints each output with a `SplitMintJustification`, and verifies the
//! outputs **fail-closed** via [`payment::verify_payment_token`]. Each minted
//! output's CBOR is printed as hex, like the model example.
//! original to its split-manifest burn predicate, mints each output with a
//! `SplitMintJustification`, and verifies the outputs **fail-closed** via
//! [`payment::verify_payment_token`]. Each minted output's CBOR is printed as
//! hex, like the model example.
//!
//! Connection parameters come from `e2e/.env` (see `e2e/.env.example`).
//!
Expand Down Expand Up @@ -203,24 +204,29 @@ fn main() {
),
];

// `split` verifies the source token (history, embedded chain, issuance
// policy) before constructing the irreversible burn.
let split = TokenSplit::split(
&source,
&trust_base,
&registry,
PaymentAssetCollection::from_cbor_bytes,
requests,
Some(BURN_STATE_MASK),
)
.expect("build split");

// Burn the source coin to the split's aggregation-root burn predicate, using
// the same state mask so the certified burn matches the split proofs.
// Burn the source coin to the split's manifest burn predicate, storing the
// exact manifest bytes as the burn transfer's auxiliary data and reusing the
// same state mask so the certified burn matches the split proofs.
let burnt = client::transfer(
&aggregator,
&trust_base,
&source,
&split.burn.owner_predicate,
&alice,
StateMask::from_bytes(BURN_STATE_MASK),
None,
Some(split.burn.manifest.clone()),
)
.expect("burn source coin");

Expand Down
11 changes: 4 additions & 7 deletions src/api/inclusion_certificate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,15 @@ const BITMAP_SIZE: usize = 32;
const HASH_SIZE: usize = 32;
const MAX_DEPTH: usize = 255;

use crate::radix::bit_at;

/// A sparse-Merkle-tree inclusion path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InclusionCertificate {
bitmap: [u8; BITMAP_SIZE],
siblings: Vec<[u8; HASH_SIZE]>,
}

/// LSB-first bit at `depth` of `data`.
fn bit_at(data: &[u8], depth: usize) -> u8 {
(data[depth / 8] >> (depth % 8)) & 1
}

impl InclusionCertificate {
/// Decode from the raw byte form, validating bitmap/sibling alignment and
/// that the sibling count matches the bitmap population count.
Expand Down Expand Up @@ -89,7 +86,7 @@ impl InclusionCertificate {

let mut position = self.siblings.len();
for depth in (0..=MAX_DEPTH).rev() {
if bit_at(&self.bitmap, depth) == 0 {
if !bit_at(&self.bitmap, depth) {
continue;
}
if position == 0 {
Expand All @@ -101,7 +98,7 @@ impl InclusionCertificate {
let h = DataHasher::new(HashAlgorithm::Sha256)
.expect("sha256")
.update(&[0x01, depth as u8]);
hash = if bit_at(key, depth) == 1 {
hash = if bit_at(key, depth) {
h.update(sibling).update(hash.data())
} else {
h.update(hash.data()).update(sibling)
Expand Down
7 changes: 4 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
//! [`payment`] module adds the fungible-asset payload and token-split subsystem:
//! verify such tokens fail-closed (policy-gated) with
//! [`payment::verify_payment_token`], and construct splits (under the `client`
//! feature) with `payment::TokenSplit`. The split inclusion proofs use the
//! bigint-routed sparse Merkle trees in [`smt`].
//! feature) with `payment::TokenSplit`. The split inclusion proofs use the radix
//! sparse Merkle sum trees in [`rsmst`].
//!
//! [`Token::verify`]: crate::transaction::Token::verify
//! [`RootTrustBase`]: crate::api::bft::RootTrustBase
Expand All @@ -42,7 +42,8 @@ pub mod crypto;
pub mod error;
pub mod payment;
pub mod predicate;
pub mod smt;
mod radix;
pub mod rsmst;
pub mod transaction;
pub mod verify;

Expand Down
Loading
Loading