@unicitylabs/state-transition-sdk
v3.0.1
Published
Generic State Transition Flow engine for value-carrier agents
Readme
State Transition SDK
Overview
The State Transition SDK is a TypeScript library that provides an off-chain token transaction framework. Tokens are managed, stored, and transferred off-chain with only cryptographic commitments published on-chain, ensuring privacy while preventing double-spending through single-spend proofs. This is a low-level SDK, that supports transferring tokens, making payments, and splitting tokens. In this system, tokens are self-contained entities containing complete transaction history and cryptographic proofs attesting to their current state (ownership, value, etc.). State transitions are verified through consultation with blockchain infrastructure (Unicity) to produce proof of single spend.
Key Features
- Off-chain Privacy: Cryptographic commitments contain no information about tokens, their state, or transaction nature
- Horizontal Scalability: Millions of transaction commitments per block capability
- Zero-Knowledge Transactions: Observers cannot determine if commitments refer to token transactions or other processes
- Offline Transaction Support: Create and serialize transactions without network connectivity
- TypeScript Support: Full type safety and modern development experience
- Modular Architecture: Pluggable address schemes, predicates, and token types
Installation
npm install @unicitylabs/state-transition-sdkUpgrading to 3.0
3.0.1 changes API that 3.0.0 shipped
3.0.1 is a corrective release: 3.0.0 was published but not adopted, and rather than carry its rough edges forward the release fixes them in place. It is not a drop-in patch.
| Change | What breaks |
|---|---|
| TransferTransaction.fromCBOR(bytes, token) | now (bytes, sourceStateHash, lockScript), and synchronous |
| CertifiedTransferTransaction.fromCBOR(bytes, token) | the same |
| TransferTransaction.expiresAtFromCBOR | removed; decode the transaction and read expiresAt |
| new InclusionProofResponse(...) | replaced by InclusionProofResponse.certified / .notCertified |
| new InclusionProof(...) | every field is now required; a proof describes a certified leaf |
| InclusionProof.getCertificationData / getReferenceTime | no longer nullable |
| InclusionProofVerificationStatus | MISSING_CERTIFICATION_DATA, MISSING_REFERENCE_TIME, INCOMPLETE_INCLUSION_PROOF and INCLUSION_CERTIFICATE_MISSING removed — none can occur now that a proof is complete by construction |
The wire formats are unchanged from 3.0.0, so tokens and proofs move between the two versions; only the API moved.
3.0 changes the formats the SDK shares with the Unicity Service, so it is not interoperable with 2.x in either direction. There is no migration path for tokens already in circulation.
Tokens minted by 2.x cannot be loaded. Token.VERSION is now 2, and
Token.fromCBOR rejects an older token with Unsupported Token version: 1.
MintTransaction, TransferTransaction and CertificationData moved to
version 2 with it. Affected tokens have to be re-minted.
A 3.0 client needs an aggregator that speaks the new protocol, at
ghcr.io/unicitynetwork/aggregator-go:sha-ae08165 or later. The certified leaf
value is now SHA-256(CBOR([transactionHash, referenceTime])) rather than the
transaction hash alone, so proofs from a 2.x-era service do not verify here, and
a 2.x client cannot verify proofs from a current one.
Requests carry a deadline. MintTransaction.create, TransferTransaction.create
and TokenSplit.split take an optional expiresAt; see
Request deadlines below.
Compile-time breaks for anyone building on the verification internals:
| Change | What breaks |
|---|---|
| InclusionProofVerificationRule.verify no longer takes referenceTime | it is read from the inclusion proof instead |
| InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH removed | replaced by REFERENCE_TIME_AFTER_ROUND and INCOMPLETE_INCLUSION_PROOF |
| Certified mint and transfer CBOR is 2 elements, was 3 | the reference time is no longer stored beside the proof that carries it |
| expiresAt is validated at the factories | a negative, zero or over-wide deadline now throws instead of failing later inside CBOR encoding |
Quick Start
End-to-end runnable examples live under tests/examples/:
- Mint:
tests/examples/mint/ExampleTest.ts - Transfer:
tests/examples/transfer/ExampleTest.ts - Split:
tests/examples/split/ExampleTest.ts
Tokens are shipped between parties as CBOR — use token.toCBOR() on the sender side and Token.fromCBOR(bytes) on the receiver side.
Core Components
StateTransitionClient
A thin client over the aggregator. As a consumer you'll typically:
- Build a
MintTransactionorTransferTransaction. - Submit its
CertificationDataand wait for an inclusion proof. - Turn it into a certified transaction and apply it to a
Token(Token.mint/token.transfer). - Call
token.verify(...)on the receiving side.
StateTransitionClient covers step 2 only:
submitCertificationRequest()- Submit a commitment to the aggregatorgetInclusionProof()- Retrieve an inclusion proof for a state id
Request deadlines
Every certification request carries an exclusive deadline. Supply one as
expiresAt, in Unix seconds, and the Unicity Service admits the request only to
a round whose reference time is strictly below it:
const transaction = await MintTransaction.create(networkId, recipient, {
expiresAt: BigInt(Math.floor(Date.now() / 1000)) + 3600n,
});The value is a wall-clock instant in Unix seconds, not a round number or block height, and it is compared against the round's reference time — which is the timestamp of the consensus seal, i.e. the root chain's clock, not yours. The two can differ by seconds, so leave enough margin to absorb the skew and the time a request spends queued. Hour-scale deadlines are unaffected; second-scale ones are not.
Omit it, or pass null, and the service derives a deadline from consensus time
instead. That suits a caller with no trustworthy clock: the assigned value is
service metadata, never recorded in the leaf and never re-checked by a later
verifier, so it does not have to be agreed on in advance.
An explicit deadline is different — the transaction hash commits to it, so it
travels with the token and every verifier re-checks it against the reference
time the leaf was created under. Submitting after it has passed is answered with
CertificationStatus.REQUEST_EXPIRED; a service that has not yet been given a
consensus reference time answers SERVICE_NOT_READY.
See Security Features for what a deadline does and does not guarantee.
Transaction Flow
- Minting: Create new tokens
- Transfer: Submit state transitions between owners
Transfer flow
Prerequisites Recipient knows some info about token, like token type for generating address.
A[Start]
A --> B[Recipient Generates Predicate]
B --> C[Recipient Shares Predicate with Sender]
C --> D[Sender Creates Transaction]
D --> E[Sender Submits Transaction]
E --> F[Sender Retrieves Inclusion Proof]
F --> G[Sender Creates Certified Transaction]
G --> H[Sender Updates Token with Certified Transaction]
H --> I[Sender Sends Token to Recipient]
I --> J[End]Architecture
Token Structure
A Token is a self-contained, CBOR-serializable record that bundles its genesis with an ordered transfer history:
genesis: aCertifiedMintTransaction(aMintTransactionplus itsInclusionProof). The mint transaction carriesnetworkId,tokenId,tokenType,salt,recipient, optionaljustification, optionaldata, andexpiresAt, an exclusive request deadline in Unix seconds that isnullwhen the Unicity Service assigns the deadline instead.transactions: an ordered list ofCertifiedTransferTransactionentries, each wrapping aTransferTransaction(recipient, state mask, optional data, andexpiresAt) with itsInclusionProof.
See src/transaction/Token.ts for the authoritative shape.
Privacy Model
- Commitment-based: Only cryptographic commitments published on-chain
- Self-contained: Tokens include complete transaction history
- Zero-knowledge: No information leaked about token or transaction details
- Minimal footprint: Blockchain only stores commitment hashes
Security Features
- Double-spend prevention: Enforced through inclusion proofs
- Cryptographic verification: All state transitions cryptographically verified
- Predicate flexibility: Multiple ownership models supported
- Provenance tracking: Complete audit trail in token history
Request deadlines are enforced by the service, not by verification
A request may carry an exclusive deadline (expiresAt), and the Unicity Service
only admits it to a round whose reference time is strictly below that deadline.
Verification re-checks the deadline against the reference time the leaf reports,
and rejects a leaf claiming to postdate the round that certified it.
Neither check establishes when the leaf was created. The reference time is chosen by the service, and the inclusion proof authenticates the value it chose rather than the moment it chose it: a service that receives a request after its deadline can insert the leaf later and record a pre-deadline reference time in it, and every client-side check still passes. Closing that would need signed evidence of the creation round, which an inclusion proof does not currently carry.
So treat expiresAt as an instruction to an honest service — the guarantee that
a late request is dropped rather than executed — and not as something a verifier
can prove after the fact. It is not a defence against a service that is itself
dishonest; that case is covered by consensus over the aggregator, not by this
field.
Development
Building
npm run buildTesting
Run the default suite (unit + functional tests):
npm testRun the example flows (requires a reachable aggregator; URL is read from each example's config.json):
npm run test:examplesRun the integration suite. It owns the aggregator it talks to: Testcontainers
starts the stack in tests/integration/docker — a
BFT root node, mongodb, redis and a pinned aggregator build — waits for
consensus to certify a round, and tears it down when the run ends. Nothing
external is involved and there is nothing to set up:
npm run test:integrationThe chain starts empty every run, and the aggregator is published on an ephemeral port, so concurrent runs and CI jobs cannot collide. There is deliberately no way to point this suite at an aggregator it did not start — a run that could be aimed elsewhere would not be exercising the compose file it exists to test. Pointing the SDK at a service someone else is running is what the e2e suite below is for.
This is where the wire formats get checked. Certification data, the transaction
encodings, the inclusion proof and the reference-time-bound leaf value are all
shared with the service, and the fake aggregator in tests/functional derives
them with the very code under test — only a real service can tell whether the
two still agree.
Run the end-to-end suite against a deployed network. Unlike the integration suite this one has no service of its own, so point it at an endpoint and supply the matching trust base:
AGGREGATOR_URL=https://gateway.testnet2.unicity.network \
TRUST_BASE_PATH=/path/to/trust-base.json \
AGGREGATOR_API_KEY=<key, if the endpoint requires one> \
npm run test:e2eThe integration suite runs in CI; the e2e suite does not, since it needs a live network to be pointed at.
Linting
Lint all code (source and tests):
npm run lintLint with auto-fix:
npm run lint:fixNetwork Configuration
- Test gateway:
https://gateway.testnet2.unicity.network. It fronts a sharded aggregator, so a plainAggregatorClientpointed at that one URL is enough.certification_requestrequires an API key (AggregatorClient's second argument); reading inclusion proofs does not. - Trust base: network-specific, and the network the SDK mints on is taken from it
(
trustBase.networkId), so the trust base and the gateway must belong to the same network. - Network identifiers:
NetworkId.MAINNET,NetworkId.TESTNETandNetworkId.LOCALare the named constants; any other id a trust base carries resolves throughNetworkId.fromId(). - Token type: caller-supplied; use
TokenType.generate()or construct from explicit bytes
Unicity Signature Standard
The Unicity Network uses a standardized signature format to ensure data integrity and cryptographic proof of ownership. All cryptographic operations use the secp256k1 elliptic curve, SHA-256 hashing, and 33-byte compressed public keys.
The standard is designed for efficiency and broad compatibility across different programming environments, including Node.js, browsers, and Go.
Signature Format
A Unicity signature is a 65-byte array, structured as the concatenation of three components: [R || S || V].
| Component | Size (bytes) | Offset | Description |
| :----------- | :------------- | :----- | :------------------------------------------------------------------------------------------------------------ |
| R | 32 | 0 | The R value of the ECDSA signature. |
| S | 32 | 32 | The S value of the ECDSA signature. |
| V | 1 | 64 | The recovery ID (0 or 1). This value allows for the recovery of the public key directly from the signature. |
Process Overview
1. Signing
The raw message data is first hashed using SHA-256. The resulting 32-byte hash is then signed using the signer's 32-byte secp256k1 private key to produce the 65-byte signature.
2. Verification The verifier hashes the original message using SHA-256. Using this hash and the signature, the verifier recovers the public key. The recovered key is then serialized into the compressed format and compared byte-for-byte against the expected 33-byte compressed public key to confirm validity.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Support
- Repository: GitHub
- Issues: GitHub Issues
- Gateway API:
https://gateway.testnet2.unicity.network
Note: This SDK is part of the Unicity ecosystem. For production use, ensure you understand the security implications and test thoroughly in the testnet environment.
