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
69 changes: 62 additions & 7 deletions .github/workflows/npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ on:
types: [published]

jobs:
# Keeps its bare `build` name: that is the check name this repo's history and
# any future branch protection would refer to.
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -21,10 +22,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'

- name: Install latest npm (trusted publishing requires >=11.5.1)
run: npm install -g npm@latest

- name: Install dependencies
run: npm ci
Expand All @@ -41,8 +38,66 @@ jobs:
- name: Run tests
run: npm run test:ci

# Only build and publish on push to main and release events
- name: Build and publish (main branch only)
integration:
name: Integration against a real aggregator
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate publishing on the integration job

When the real-aggregator integration suite fails but the unit/build job succeeds, this sibling job does not prevent the build job's main-branch or release steps from publishing to npm; GitHub Actions runs jobs without needs independently, so an incompatible package can be published before the workflow ultimately turns red. Make publication depend on successful completion of this integration job, or move the publish steps into a downstream job.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 50e0c16. You're right, and this one is on me for making it worse: before this PR there was no integration job at all, so adding one that looks like a gate without being one is arguably worse than not having it — a reviewer reads the workflow and assumes releases are covered.

Publishing now lives in its own job:

publish:
  needs: [build, integration]
  if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')

needs is the gate; the event conditions only choose which kind of release it is. I took the second of your two options — a downstream job rather than needs: integration on build — so that PR feedback stays parallel: making build wait on integration would serialise every PR for a constraint that only matters on main and on release.

Two consequences of the split worth noting for review:

  • id-token: write is only needed to publish, so build and integration no longer request it.
  • The publish job re-runs npm ci and npm run build, since jobs don't share a filesystem and files in package.json ships lib/. That's ~45s of duplicated work, which seemed the right trade against publishing an unbuilt package.

One thing I checked while making the change: I had initially renamed the build job to "Build and test", which would have changed the reported check name. There is no branch protection on this repo today, so nothing would have broken — but a future required check configured as build would have silently never matched. Reverted; the job keeps its bare name, and only the job body changed.

runs-on: ubuntu-latest
timeout-minutes: 25
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'

- name: Install dependencies
run: npm ci

# 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 afterwards. Nothing external is
# involved, and the aggregator is published on an ephemeral port, so
# concurrent jobs on a runner cannot collide.
- name: Run integration tests
run: npm run test:integration

# Publishing is its own job so that it can depend on both of the above.
# Sibling jobs run independently, so while these steps lived in `build` a
# green unit run could publish a package the integration suite had already
# found incompatible with a real aggregator — and an npm release cannot be
# taken back. `needs` is the gate; the conditions below only choose which
# kind of release this is.
publish:
name: Publish to npm
needs: [build, integration]
if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
registry-url: 'https://registry.npmjs.org'

- name: Install latest npm (trusted publishing requires >=11.5.1)
run: npm install -g npm@latest

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Publish dev build (main branch only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
NPM_PACKAGE_VERSION=$(node -e "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync('package.json')).version);")
Expand Down
114 changes: 110 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,36 @@ In this system, tokens are self-contained entities containing complete transacti
npm install @unicitylabs/state-transition-sdk
```

## Upgrading to 3.0

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](#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/`](./tests/examples):
Expand All @@ -47,6 +77,39 @@ A thin client over the aggregator. As a consumer you'll typically:
- `submitCertificationRequest()` - Submit a commitment to the aggregator
- `getInclusionProof()` - 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:

```ts
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](#security-features) for what a deadline does and does
not guarantee.

### Transaction Flow

1. **Minting**: Create new tokens
Expand Down Expand Up @@ -93,6 +156,27 @@ See [`src/transaction/Token.ts`](./src/transaction/Token.ts) for the authoritati
- **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
Expand All @@ -115,21 +199,43 @@ Run the example flows (requires a reachable aggregator; URL is read from each ex
npm run test:examples
```

Run the end-to-end suite (defaults to a local aggregator at `http://localhost:3000`):
Run the integration suite. It owns the aggregator it talks to: Testcontainers
starts the stack in [`tests/integration/docker`](./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:

```bash
npm run test:e2e
npm run test:integration
```

To run it against another network, point it at that endpoint and supply the matching trust base:
The 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:

```bash
AGGREGATOR_URL=https://gateway.example.unicity.network \
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:e2e
```

The 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):
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default defineConfig(
tsEslint.configs.recommendedTypeChecked,
eslintConfigPrettier,
eslintImport.flatConfigs.recommended,
globalIgnores(['tests/integration/docker/**', 'tests/utils/*.mjs']),
globalIgnores(['tests/integration/docker/**', 'tests/integration/support/*.mjs', 'tests/utils/*.mjs']),
{
languageOptions: {
ecmaVersion: 2018,
Expand Down
19 changes: 19 additions & 0 deletions jest.integration.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import base from './jest.config.js';

/**
* Integration suite: the same transforms as the default config, plus the
* aggregator stack the tests run against.
*
* It lives in its own config because globalSetup is per-run, and starting an
* aggregator for the unit and functional suites — which have no service to talk
* to — would put a docker dependency on the tests that are meant not to have
* one. Coverage is off: these exercise wire compatibility, and the unit and
* functional suites are what measure reach into src/.
*/
export default {
...base,
collectCoverage: false,
globalSetup: '<rootDir>/tests/integration/support/globalSetup.mjs',
globalTeardown: '<rootDir>/tests/integration/support/globalTeardown.mjs',
testMatch: ['<rootDir>/tests/integration/**/*Test.ts'],
};
Loading