diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 0391569a..eae0c744 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -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 @@ -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 @@ -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 + 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);") diff --git a/README.md b/README.md index ce0703e1..fbe8ed10 100644 --- a/README.md +++ b/README.md @@ -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): @@ -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 @@ -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 @@ -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= \ 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): diff --git a/eslint.config.js b/eslint.config.js index e5b4d8c8..5d13b72b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, diff --git a/jest.integration.config.js b/jest.integration.config.js new file mode 100644 index 00000000..84dfbef7 --- /dev/null +++ b/jest.integration.config.js @@ -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: '/tests/integration/support/globalSetup.mjs', + globalTeardown: '/tests/integration/support/globalTeardown.mjs', + testMatch: ['/tests/integration/**/*Test.ts'], +}; diff --git a/package-lock.json b/package-lock.json index 2107958c..463f1b18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@unicitylabs/state-transition-sdk", - "version": "2.1.0", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@unicitylabs/state-transition-sdk", - "version": "2.1.0", + "version": "3.0.0", "license": "ISC", "dependencies": { "@noble/curves": "2.2.0", @@ -14,6 +14,7 @@ "uuid": "14.0.0" }, "devDependencies": { + "@babel/core": "8.0.1", "@babel/preset-env": "8.0.2", "@babel/preset-typescript": "8.0.1", "@eslint/js": "9.39.5", @@ -26,6 +27,7 @@ "eslint-plugin-prettier": "5.5.6", "globals": "17.7.0", "jest": "30.4.2", + "testcontainers": "^12.1.0", "typescript": "6.0.3", "typescript-eslint": "8.65.0" }, @@ -63,7 +65,6 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -379,7 +380,6 @@ "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^8.0.0", "@babel/types": "^8.0.0" @@ -1554,6 +1554,13 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", @@ -1782,6 +1789,58 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2551,6 +2610,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -2621,6 +2701,72 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2879,6 +3025,29 @@ "node": ">=6.9.0" } }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-4.0.1.tgz", + "integrity": "sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2891,8 +3060,7 @@ "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2963,6 +3131,43 @@ "undici-types": "~6.20.0" } }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3589,6 +3794,19 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3701,6 +3919,44 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3833,6 +4089,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3843,6 +4116,13 @@ "node": ">= 0.4" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3859,6 +4139,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -4209,6 +4504,112 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz", + "integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.2.tgz", + "integrity": "sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.44", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", @@ -4222,6 +4623,68 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/brace-expansion": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", @@ -4277,6 +4740,41 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4284,6 +4782,26 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -4402,6 +4920,13 @@ "node": ">=10" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -4541,6 +5066,23 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4569,6 +5111,55 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4734,6 +5325,113 @@ "node": ">=8" } }, + "node_modules/docker-compose": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.4.2.tgz", + "integrity": "sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/docker-modem/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-5.0.1.tgz", + "integrity": "sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4" + }, + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/dockerode/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/dockerode/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/dockerode/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4802,11 +5500,20 @@ "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5379,6 +6086,36 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -5452,6 +6189,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5575,6 +6319,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5706,6 +6457,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -5973,6 +6737,27 @@ "node": ">=10.17.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6036,7 +6821,6 @@ "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -8185,6 +8969,59 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8229,6 +9066,20 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8243,6 +9094,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -8339,6 +9197,29 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8346,6 +9227,14 @@ "dev": true, "license": "MIT" }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -8936,6 +9825,95 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/properties-reader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-3.0.1.tgz", + "integrity": "sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "mkdirp": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8979,6 +9957,56 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9138,6 +10166,16 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -9158,6 +10196,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -9193,6 +10252,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -9398,6 +10464,13 @@ "source-map": "^0.6.0" } }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9405,6 +10478,46 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -9442,6 +10555,28 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9718,6 +10853,44 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz", + "integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9755,6 +10928,43 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/testcontainers": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-12.1.0.tgz", + "integrity": "sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^4.0.1", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.4.2", + "dockerode": "^5.0.1", + "get-port": "^5.1.1", + "proper-lockfile": "^4.1.2", + "properties-reader": "^3.0.1", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.3", + "tmp": "^0.2.7", + "undici": "^8.9.0" + }, + "engines": { + "node": ">= 22.22" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9772,6 +10982,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9836,6 +11056,13 @@ "license": "0BSD", "optional": true }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10007,6 +11234,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -10137,6 +11374,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -10423,6 +11667,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -10509,6 +11769,21 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/package.json b/package.json index dbe2fa2c..f531b5b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@unicitylabs/state-transition-sdk", - "version": "2.1.0", + "version": "3.0.0", "description": "Generic State Transition Flow engine for value-carrier agents", "type": "module", "exports": { @@ -15,8 +15,9 @@ "test": "npm run-script test:unit", "test:unit": "jest --testPathPatterns=tests/unit --testPathPatterns=tests/functional", "test:e2e": "jest --testPathPatterns=tests/e2e", + "test:integration": "jest --config jest.integration.config.js", "test:examples": "jest --testPathPatterns=tests/examples", - "test:ci": "jest --testPathPatterns=tests --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/examples --ci --reporters=default" + "test:ci": "jest --testPathPatterns=tests --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integration --testPathIgnorePatterns=tests/examples --ci --reporters=default" }, "repository": { "type": "git", @@ -52,6 +53,7 @@ "eslint-plugin-prettier": "5.5.6", "globals": "17.7.0", "jest": "30.4.2", + "testcontainers": "^12.1.0", "typescript": "6.0.3", "typescript-eslint": "8.65.0" } diff --git a/src/api/CertificationData.ts b/src/api/CertificationData.ts index 81f69e85..e4433879 100644 --- a/src/api/CertificationData.ts +++ b/src/api/CertificationData.ts @@ -46,6 +46,13 @@ export class CertificationData { return new Uint8Array(this._unlockScript); } + /** + * @returns {bigint} Wire-format version of this certification data. + */ + public get version(): bigint { + return CertificationData.VERSION; + } + /** * Create CertificationData from CBOR bytes. * @param {Uint8Array} bytes CBOR bytes diff --git a/src/api/InclusionProof.ts b/src/api/InclusionProof.ts index 23b0bc04..080ed897 100644 --- a/src/api/InclusionProof.ts +++ b/src/api/InclusionProof.ts @@ -23,7 +23,7 @@ export class InclusionProof { * decoded proofs always satisfy that invariant. * * @param certificationData Certification data. - * @param referenceTime Reference time of the round the leaf was created in. + * @param referenceTime Reference time of the round the leaf was created in, in Unix seconds. * @param inclusionCertificate Inclusion certificate. * @param unicityCertificate Unicity certificate. */ diff --git a/src/api/LeafValue.ts b/src/api/LeafValue.ts index 4cb3b771..3dbfb951 100644 --- a/src/api/LeafValue.ts +++ b/src/api/LeafValue.ts @@ -14,7 +14,7 @@ import { CborSerializer } from '../serialization/cbor/CborSerializer.js'; * the value the transition was validated under, for any proof of that leaf. * * @param {DataHash} transactionHash Transaction hash of the certified request. - * @param {bigint} referenceTime Reference time of the round the request was validated in. + * @param {bigint} referenceTime Reference time of the round the request was validated in, in Unix seconds. * @returns {Promise} Leaf value. */ export function calculateLeafValue(transactionHash: DataHash, referenceTime: bigint): Promise { diff --git a/src/payment/TokenSplit.ts b/src/payment/TokenSplit.ts index 9a741f25..cf07c78a 100644 --- a/src/payment/TokenSplit.ts +++ b/src/payment/TokenSplit.ts @@ -1,3 +1,4 @@ +import { validateExpiresAt } from '../transaction/ExpiresAt.js'; import { DuplicateSplitTokenIdError } from './error/DuplicateSplitTokenIdError.js'; import { TokenAssetCountMismatchError } from './error/TokenAssetCountMismatchError.js'; import { TokenAssetMissingError } from './error/TokenAssetMissingError.js'; @@ -55,7 +56,8 @@ export class TokenSplit { requests: SplitTokenRequest[], options: ISplitOptions = {}, ): Promise { - const { burnStateMask = StateMask.generate(), expiresAt = null } = options; + const { burnStateMask = StateMask.generate() } = options; + const expiresAt = validateExpiresAt(options.expiresAt ?? null); const factory = new DataHasherFactory(HashAlgorithm.SHA256, DataHasher); if (token.genesis.data == null) { diff --git a/src/predicate/builtin/verification/IBuiltInPredicateVerifier.ts b/src/predicate/builtin/verification/IBuiltInPredicateVerifier.ts index d67b5214..e724476e 100644 --- a/src/predicate/builtin/verification/IBuiltInPredicateVerifier.ts +++ b/src/predicate/builtin/verification/IBuiltInPredicateVerifier.ts @@ -18,7 +18,7 @@ export interface IBuiltInPredicateVerifier { * Verify an unlock script against the predicate. * * @param {EncodedPredicate} predicate Predicate being unlocked. - * @param {bigint} referenceTime Reference time the transition was validated under. + * @param {bigint} referenceTime Reference time the transition was validated under, in Unix seconds. * @param {DataHash} sourceStateHash Hash of the state being spent. * @param {DataHash} transactionHash Hash of the spending transaction. * @param {Uint8Array} unlockScript Witness bytes for the predicate. diff --git a/src/predicate/verification/PredicateVerifierService.ts b/src/predicate/verification/PredicateVerifierService.ts index 813c40da..e9548f45 100644 --- a/src/predicate/verification/PredicateVerifierService.ts +++ b/src/predicate/verification/PredicateVerifierService.ts @@ -48,7 +48,7 @@ export class PredicateVerifierService { * Verify given predicate with registered predicate verifiers. * * @param {EncodedPredicate} predicate Predicate being unlocked. - * @param {bigint} referenceTime Reference time the transition was validated under. + * @param {bigint} referenceTime Reference time the transition was validated under, in Unix seconds. * @param {DataHash} sourceStateHash Hash of the state being spent. * @param {DataHash} transactionHash Hash of the spending transaction. * @param {Uint8Array} unlockScript Witness bytes for the predicate. diff --git a/src/transaction/CertifiedMintTransaction.ts b/src/transaction/CertifiedMintTransaction.ts index 059308af..1252794f 100644 --- a/src/transaction/CertifiedMintTransaction.ts +++ b/src/transaction/CertifiedMintTransaction.ts @@ -16,7 +16,6 @@ import { CborError } from '../serialization/cbor/CborError.js'; import { CborSerializer } from '../serialization/cbor/CborSerializer.js'; import { dedent } from '../util/StringUtils.js'; import { VerificationError } from '../verification/VerificationError.js'; -import { VerificationResult } from '../verification/VerificationResult.js'; import { InclusionProofVerificationRule, InclusionProofVerificationStatus, @@ -30,7 +29,6 @@ export class CertifiedMintTransaction implements ITransaction { private constructor( private readonly transaction: MintTransaction, - public readonly referenceTime: bigint, public readonly inclusionProof: InclusionProof, ) {} @@ -42,7 +40,7 @@ export class CertifiedMintTransaction implements ITransaction { } /** - * @returns {bigint|null} Exclusive request deadline of the inner transaction. + * @returns {bigint|null} Exclusive request deadline of the inner transaction, in Unix seconds. */ public get expiresAt(): bigint | null { return this.transaction.expiresAt; @@ -76,6 +74,19 @@ export class CertifiedMintTransaction implements ITransaction { return this.transaction.recipient; } + /** + * @returns {bigint} Reference time of the round the leaf was created in, in Unix seconds. + * + * Read from the inclusion proof rather than stored beside it: the service + * records the leaf's creation time on the record itself and serves that same + * value for every proof of the leaf, and the leaf value binds it, so the + * proof is the authenticated source for it. + */ + public get referenceTime(): bigint { + // Non-null by construction: every factory below rejects a proof without one. + return this.inclusionProof.referenceTime as bigint; + } + /** * @returns {TokenSalt} Mint-transaction salt of the inner transaction. */ @@ -118,14 +129,15 @@ export class CertifiedMintTransaction implements ITransaction { * @returns {Promise} Decoded certified transaction. */ public static async fromCBOR(bytes: Uint8Array): Promise { - const data = CborDeserializer.decodeArray(bytes, 3); - const referenceTime = CborDeserializer.decodeUnsignedInteger(data[1]); - const proof = InclusionProof.fromCBOR(data[2]); - // A null reference time on the proof also fails this comparison. - if (referenceTime !== proof.referenceTime) { - throw new CborError('Certified mint transaction reference time does not match its inclusion proof.'); + const data = CborDeserializer.decodeArray(bytes, 2); + const proof = InclusionProof.fromCBOR(data[1]); + // A certified transaction is one bound to a leaf. A proof that reports no + // leaf cannot certify anything, and decoding it into one would hand every + // later verifier a transaction with no reference time. + if (proof.referenceTime == null) { + throw new CborError('Certified mint transaction carries an inclusion proof with no certified leaf.'); } - return new CertifiedMintTransaction(await MintTransaction.fromCBOR(data[0]), referenceTime, proof); + return new CertifiedMintTransaction(await MintTransaction.fromCBOR(data[0]), proof); } /** @@ -146,21 +158,6 @@ export class CertifiedMintTransaction implements ITransaction { transaction: MintTransaction, inclusionProof: InclusionProof, ): Promise { - // The reference time is fixed here, at the moment the transaction is bound - // to its first proof. Later verifiers use the carried value: a proof - // fetched later may be issued against a later root and would then carry a - // different input record time. - const referenceTime = inclusionProof.referenceTime; - if (referenceTime == null) { - throw new VerificationError( - 'Inclusion proof verification failed', - new VerificationResult( - 'InclusionProofVerificationRule', - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, - ), - ); - } - const result = await InclusionProofVerificationRule.verify( trustBase, predicateVerifier, @@ -168,7 +165,6 @@ export class CertifiedMintTransaction implements ITransaction { inclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - referenceTime, transaction.lockScript, transaction.sourceStateHash, ); @@ -176,7 +172,7 @@ export class CertifiedMintTransaction implements ITransaction { throw new VerificationError('Inclusion proof verification failed', result); } - return new CertifiedMintTransaction(transaction, referenceTime, inclusionProof); + return new CertifiedMintTransaction(transaction, inclusionProof); } /** @@ -197,11 +193,7 @@ export class CertifiedMintTransaction implements ITransaction { * @inheritDoc */ public toCBOR(): Uint8Array { - return CborSerializer.encodeArray( - this.transaction.toCBOR(), - CborSerializer.encodeUnsignedInteger(this.referenceTime), - this.inclusionProof.toCBOR(), - ); + return CborSerializer.encodeArray(this.transaction.toCBOR(), this.inclusionProof.toCBOR()); } /** diff --git a/src/transaction/CertifiedTransferTransaction.ts b/src/transaction/CertifiedTransferTransaction.ts index cced7dc7..55ce3c18 100644 --- a/src/transaction/CertifiedTransferTransaction.ts +++ b/src/transaction/CertifiedTransferTransaction.ts @@ -13,7 +13,6 @@ import { CborError } from '../serialization/cbor/CborError.js'; import { CborSerializer } from '../serialization/cbor/CborSerializer.js'; import { dedent } from '../util/StringUtils.js'; import { VerificationError } from '../verification/VerificationError.js'; -import { VerificationResult } from '../verification/VerificationResult.js'; import { InclusionProofVerificationRule, InclusionProofVerificationStatus, @@ -25,7 +24,6 @@ import { export class CertifiedTransferTransaction implements ITransaction { private constructor( private readonly transaction: TransferTransaction, - public readonly referenceTime: bigint, public readonly inclusionProof: InclusionProof, ) {} @@ -37,7 +35,7 @@ export class CertifiedTransferTransaction implements ITransaction { } /** - * @returns {bigint|null} Exclusive request deadline of the inner transaction. + * @returns {bigint|null} Exclusive request deadline of the inner transaction, in Unix seconds. */ public get expiresAt(): bigint | null { return this.transaction.expiresAt; @@ -57,6 +55,19 @@ export class CertifiedTransferTransaction implements ITransaction { return this.transaction.recipient; } + /** + * @returns {bigint} Reference time of the round the leaf was created in, in Unix seconds. + * + * Read from the inclusion proof rather than stored beside it: the service + * records the leaf's creation time on the record itself and serves that same + * value for every proof of the leaf, and the leaf value binds it, so the + * proof is the authenticated source for it. + */ + public get referenceTime(): bigint { + // Non-null by construction: every factory below rejects a proof without one. + return this.inclusionProof.referenceTime as bigint; + } + /** * @returns {DataHash} Source state hash of the inner transaction. */ @@ -79,14 +90,15 @@ export class CertifiedTransferTransaction implements ITransaction { * @returns {Promise} Decoded certified transaction. */ public static async fromCBOR(bytes: Uint8Array, token: Token): Promise { - const data = CborDeserializer.decodeArray(bytes, 3); - const referenceTime = CborDeserializer.decodeUnsignedInteger(data[1]); - const proof = InclusionProof.fromCBOR(data[2]); - // A null reference time on the proof also fails this comparison. - if (referenceTime !== proof.referenceTime) { - throw new CborError('Certified transfer transaction reference time does not match its inclusion proof.'); + const data = CborDeserializer.decodeArray(bytes, 2); + const proof = InclusionProof.fromCBOR(data[1]); + // A certified transaction is one bound to a leaf. A proof that reports no + // leaf cannot certify anything, and decoding it into one would hand every + // later verifier a transaction with no reference time. + if (proof.referenceTime == null) { + throw new CborError('Certified transfer transaction carries an inclusion proof with no certified leaf.'); } - return new CertifiedTransferTransaction(await TransferTransaction.fromCBOR(data[0], token), referenceTime, proof); + return new CertifiedTransferTransaction(await TransferTransaction.fromCBOR(data[0], token), proof); } /** @@ -107,21 +119,6 @@ export class CertifiedTransferTransaction implements ITransaction { transaction: TransferTransaction, inclusionProof: InclusionProof, ): Promise { - // The reference time is fixed here, at the moment the transaction is bound - // to its first proof. Later verifiers use the carried value: a proof - // fetched later may be issued against a later root and would then carry a - // different input record time. - const referenceTime = inclusionProof.referenceTime; - if (referenceTime == null) { - throw new VerificationError( - 'Inclusion proof verification failed', - new VerificationResult( - 'InclusionProofVerificationRule', - InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, - ), - ); - } - const result = await InclusionProofVerificationRule.verify( trustBase, predicateVerifier, @@ -129,7 +126,6 @@ export class CertifiedTransferTransaction implements ITransaction { inclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - referenceTime, transaction.lockScript, transaction.sourceStateHash, ); @@ -137,7 +133,7 @@ export class CertifiedTransferTransaction implements ITransaction { throw new VerificationError('Inclusion proof verification failed', result); } - return new CertifiedTransferTransaction(transaction, referenceTime, inclusionProof); + return new CertifiedTransferTransaction(transaction, inclusionProof); } /** @@ -158,11 +154,7 @@ export class CertifiedTransferTransaction implements ITransaction { * @inheritDoc */ public toCBOR(): Uint8Array { - return CborSerializer.encodeArray( - this.transaction.toCBOR(), - CborSerializer.encodeUnsignedInteger(this.referenceTime), - this.inclusionProof.toCBOR(), - ); + return CborSerializer.encodeArray(this.transaction.toCBOR(), this.inclusionProof.toCBOR()); } /** diff --git a/src/transaction/ExpiresAt.ts b/src/transaction/ExpiresAt.ts new file mode 100644 index 00000000..7163311f --- /dev/null +++ b/src/transaction/ExpiresAt.ts @@ -0,0 +1,36 @@ +import { CborError } from '../serialization/cbor/CborError.js'; + +/** + * Largest value CBOR can carry as an unsigned long, and therefore the largest + * deadline that can be encoded at all. + */ +const MAX_EXPIRES_AT = 2n ** 64n - 1n; + +/** + * Validate an exclusive request deadline at the boundary that accepts it. + * + * Without this the range errors surface much later and far from the mistake: + * a negative or oversized value encodes nowhere and fails inside + * {@link CborSerializer} while the transaction hash is being computed, and `0n` + * encodes fine but produces a request that is expired by construction, since + * every reference time is at or past it. + * + * @param {bigint|null} expiresAt Deadline in Unix seconds, or `null` to let the service assign one. + * @returns {bigint|null} The validated deadline, unchanged. + * @throws {CborError} If the deadline cannot be encoded or is already unusable. + */ +export function validateExpiresAt(expiresAt: bigint | null): bigint | null { + if (expiresAt === null) { + return null; + } + + if (expiresAt <= 0n) { + throw new CborError(`Request deadline must be a positive number of Unix seconds, got ${expiresAt}.`); + } + + if (expiresAt > MAX_EXPIRES_AT) { + throw new CborError(`Request deadline ${expiresAt} exceeds the largest encodable value ${MAX_EXPIRES_AT}.`); + } + + return expiresAt; +} diff --git a/src/transaction/MintTransaction.ts b/src/transaction/MintTransaction.ts index ea4aee2e..c8d4ef80 100644 --- a/src/transaction/MintTransaction.ts +++ b/src/transaction/MintTransaction.ts @@ -1,4 +1,5 @@ import { CertifiedMintTransaction } from './CertifiedMintTransaction.js'; +import { validateExpiresAt } from './ExpiresAt.js'; import { IMintOptions } from './IMintOptions.js'; import { ITransaction } from './ITransaction.js'; import { MintTransactionState } from './MintTransactionState.js'; @@ -69,6 +70,13 @@ export class MintTransaction implements ITransaction { return StateMask.fromBytes(this.tokenId.bytes); } + /** + * @returns {bigint} Wire-format version of this mint transaction. + */ + public get version(): bigint { + return MintTransaction.VERSION; + } + /** * Create a MintTransaction for a fresh token. * @@ -82,7 +90,8 @@ export class MintTransaction implements ITransaction { recipient: IPredicate, options: IMintOptions = {}, ): Promise { - const { tokenType = TokenType.generate(), salt = TokenSalt.generate(), expiresAt = null } = options; + const { tokenType = TokenType.generate(), salt = TokenSalt.generate() } = options; + const expiresAt = validateExpiresAt(options.expiresAt ?? null); const justification = options.justification ? new Uint8Array(options.justification) : null; const data = options.data ? new Uint8Array(options.data) : null; diff --git a/src/transaction/Token.ts b/src/transaction/Token.ts index fa742f2a..c6a2a6f9 100644 --- a/src/transaction/Token.ts +++ b/src/transaction/Token.ts @@ -20,7 +20,13 @@ import { CertifiedTransferTransactionVerificationRule } from './verification/rul */ export class Token { public static readonly CBOR_TAG = 39040n; - private static readonly VERSION = 1n; + /** + * The only accepted wire version. Bumped with the certified-transaction + * element counts and the transaction encodings below them: without it a token + * written by an older SDK passes the version check here and then dies deeper + * down on a CBOR array-length error that never mentions versioning. + */ + private static readonly VERSION = 2n; private constructor( public readonly genesis: CertifiedMintTransaction, diff --git a/src/transaction/TransferTransaction.ts b/src/transaction/TransferTransaction.ts index 3d29c1b5..486db9db 100644 --- a/src/transaction/TransferTransaction.ts +++ b/src/transaction/TransferTransaction.ts @@ -1,4 +1,5 @@ import { CertifiedTransferTransaction } from './CertifiedTransferTransaction.js'; +import { validateExpiresAt } from './ExpiresAt.js'; import { ITransaction } from './ITransaction.js'; import { ITransferOptions } from './ITransferOptions.js'; import { StateMask } from './StateMask.js'; @@ -50,6 +51,13 @@ export class TransferTransaction implements ITransaction { return this._stateMask; } + /** + * @returns {bigint} Wire-format version of this transfer transaction. + */ + public get version(): bigint { + return TransferTransaction.VERSION; + } + /** * Create a TransferTransaction for the given token. * @@ -65,7 +73,7 @@ export class TransferTransaction implements ITransaction { stateMask: StateMask, options: ITransferOptions = {}, ): Promise { - const { expiresAt = null } = options; + const expiresAt = validateExpiresAt(options.expiresAt ?? null); const data = options.data ? new Uint8Array(options.data) : null; const transaction = token.latestTransaction; @@ -79,6 +87,35 @@ export class TransferTransaction implements ITransaction { ); } + /** + * Read the request deadline out of encoded transfer bytes. + * + * A full decode needs the token the transfer belongs to, for the source state + * and lock script it derives from the chain. A consumer that holds only the + * transfer bytes — the worker wire format below is the one in this SDK — can + * still recover the deadline from them, and must, because these are the bytes + * the transaction hash commits to. Being told the deadline out of band + * instead leaves the value unauthenticated. + * + * @param {Uint8Array} bytes Encoded transfer transaction. + * @returns {bigint|null} Exclusive request deadline in Unix seconds, or `null` when the service assigned one. + * @throws {CborError} On wrong tag or unsupported version. + */ + public static expiresAtFromCBOR(bytes: Uint8Array): bigint | null { + const tag = CborDeserializer.decodeTag(bytes); + if (tag.tag !== TransferTransaction.CBOR_TAG) { + throw new CborError(`Invalid CBOR tag for TransferTransaction: ${tag.tag}`); + } + + const data = CborDeserializer.decodeArray(tag.data, TransferTransaction.FIELD_COUNT); + const version = CborDeserializer.decodeUnsignedInteger(data[0]); + if (version !== TransferTransaction.VERSION) { + throw new CborError(`Unsupported TransferTransaction version: ${version}`); + } + + return CborDeserializer.decodeNullable(data[4], CborDeserializer.decodeUnsignedInteger); + } + /** * Create TransferTransaction from CBOR bytes. * diff --git a/src/transaction/verification/rule/CertifiedMintTransactionVerificationRule.ts b/src/transaction/verification/rule/CertifiedMintTransactionVerificationRule.ts index d0c9a52f..4141d246 100644 --- a/src/transaction/verification/rule/CertifiedMintTransactionVerificationRule.ts +++ b/src/transaction/verification/rule/CertifiedMintTransactionVerificationRule.ts @@ -63,7 +63,6 @@ export class CertifiedMintTransactionVerificationRule { genesis.inclusionProof, await genesis.calculateTransactionHash(), genesis.expiresAt, - genesis.referenceTime, genesis.lockScript, genesis.sourceStateHash, ); diff --git a/src/transaction/verification/rule/CertifiedTransferTransactionVerificationRule.ts b/src/transaction/verification/rule/CertifiedTransferTransactionVerificationRule.ts index 04b027ae..33d084c9 100644 --- a/src/transaction/verification/rule/CertifiedTransferTransactionVerificationRule.ts +++ b/src/transaction/verification/rule/CertifiedTransferTransactionVerificationRule.ts @@ -27,7 +27,6 @@ export class CertifiedTransferTransactionVerificationRule { transaction.inclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - transaction.referenceTime, transaction.lockScript, transaction.sourceStateHash, ); diff --git a/src/transaction/verification/rule/InclusionProofVerificationRule.ts b/src/transaction/verification/rule/InclusionProofVerificationRule.ts index bce7ae40..9fd83a2f 100644 --- a/src/transaction/verification/rule/InclusionProofVerificationRule.ts +++ b/src/transaction/verification/rule/InclusionProofVerificationRule.ts @@ -17,10 +17,11 @@ import { VerificationStatus } from '../../../verification/VerificationStatus.js' export enum InclusionProofVerificationStatus { INVALID_TRUSTBASE = 'INVALID_TRUSTBASE', MISSING_CERTIFICATION_DATA = 'MISSING_CERTIFICATION_DATA', + INCOMPLETE_INCLUSION_PROOF = 'INCOMPLETE_INCLUSION_PROOF', CERTIFICATION_DATA_MISMATCH = 'CERTIFICATION_DATA_MISMATCH', TRANSACTION_HASH_MISMATCH = 'TRANSACTION_HASH_MISMATCH', MISSING_REFERENCE_TIME = 'MISSING_REFERENCE_TIME', - REFERENCE_TIME_MISMATCH = 'REFERENCE_TIME_MISMATCH', + REFERENCE_TIME_AFTER_ROUND = 'REFERENCE_TIME_AFTER_ROUND', REQUEST_EXPIRED = 'REQUEST_EXPIRED', NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', INCLUSION_CERTIFICATE_MISSING = 'INCLUSION_CERTIFICATE_MISSING', @@ -42,8 +43,7 @@ export class InclusionProofVerificationRule { * @param {PredicateVerifierService} predicateVerifierFactory Predicate verifier service. * @param {InclusionProof} inclusionProof Inclusion proof to verify. * @param {DataHash} transactionHash Canonical hash of the transaction. - * @param {bigint|null} expiresAt Exclusive request deadline, or `null` when the service assigned one. - * @param {bigint} referenceTime Reference time the transition was validated under. + * @param {bigint|null} expiresAt Exclusive request deadline in Unix seconds, or `null` when the service assigned one. * @param {EncodedPredicate} lockScript Lock script the transaction unlocks. * @param {DataHash} sourceStateHash Hash of the state the transaction spends. * @returns {Promise>} Verification outcome. @@ -55,25 +55,52 @@ export class InclusionProofVerificationRule { inclusionProof: InclusionProof, transactionHash: DataHash, expiresAt: bigint | null, - referenceTime: bigint, lockScript: EncodedPredicate, sourceStateHash: DataHash, ): Promise> { - if (!inclusionProof.inclusionCertificate) { + const certificationData = inclusionProof.certificationData; + // The reference time comes from the proof, which is the only party that can + // state it; the leaf value binds this exact value, so the SMT path below + // authenticates it. + const referenceTime = inclusionProof.referenceTime; + const inclusionCertificate = inclusionProof.inclusionCertificate; + + // A proof reporting no leaf at all is the aggregator's "not certified yet", + // and the only status {@link waitInclusionProof} polls through. + if (certificationData == null && referenceTime == null && inclusionCertificate == null) { return new VerificationResult( 'InclusionProofVerificationRule', InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING, ); } - const certificationData = inclusionProof.certificationData; - if (!certificationData) { + // Anything in between establishes neither a leaf nor its absence. + // {@link InclusionProof.fromCBOR} rejects such a proof outright, so this is + // reachable only from one built by hand — a non-conforming service behind a + // custom client, or a stripping proxy. Each case reports what is missing: + // folding them into the pending status would leave the caller polling to + // its own deadline and blame the timeout. + if (certificationData == null) { return new VerificationResult( 'InclusionProofVerificationRule', InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA, ); } + if (referenceTime == null) { + return new VerificationResult( + 'InclusionProofVerificationRule', + InclusionProofVerificationStatus.MISSING_REFERENCE_TIME, + ); + } + + if (inclusionCertificate == null) { + return new VerificationResult( + 'InclusionProofVerificationRule', + InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF, + ); + } + if (!certificationData.transactionHash.equals(transactionHash)) { return new VerificationResult( 'InclusionProofVerificationRule', @@ -95,24 +122,40 @@ export class InclusionProofVerificationRule { // The request was admissible only in a round strictly before its deadline. A // request that carried no deadline was admitted under a service-assigned one, // which is not recorded and is not re-checked here. + // + // Both sides are Unix seconds, and both are consensus time rather than any + // caller's clock: the reference time is the round's own timestamp, taken + // from the BFT seal. A deadline set from a local clock is therefore compared + // against the root chain's, and the two can differ by seconds. if (expiresAt != null && referenceTime >= expiresAt) { return new VerificationResult('InclusionProofVerificationRule', InclusionProofVerificationStatus.REQUEST_EXPIRED); } - const stateId = await StateId.fromCertificationData(certificationData); - // The leaf value binds the reference time the transition was validated - // under. It is taken from the caller, not from the proof's own unicity - // certificate: the tree is append-only, so the proof may have been issued - // against a later root whose input record carries a later reference time. - // A null reference time on the proof also fails this comparison. - if (inclusionProof.referenceTime !== referenceTime) { + // A leaf cannot postdate the round that certified it. Consensus signs the + // round's timestamp, which is that round's own reference time, so this is a + // free signed upper bound; the tree is append-only, so a proof re-fetched + // later is certified by a later round and the bound only loosens. + // + // It bounds the reference time in one direction only, and the useful + // direction is the other one. Nothing here establishes when the leaf was + // actually created: a service that receives a request after its deadline T + // can insert the leaf now and write referenceTime = T - 1 into it, and both + // that value and this round's later timestamp satisfy every check in this + // rule. Enforcing a deadline against a dishonest service needs signed + // evidence of the creation round, which an inclusion proof does not carry — + // see the note in README.md. What this rule can establish is that the leaf + // is internally consistent and that an honest service admitted the request + // before its deadline. + if (referenceTime > inclusionProof.unicityCertificate.inputRecord.timestamp) { return new VerificationResult( 'InclusionProofVerificationRule', - InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH, + InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND, ); } + + const stateId = await StateId.fromCertificationData(certificationData); const leafValue = await calculateLeafValue(certificationData.transactionHash, referenceTime); - const result = await inclusionProof.inclusionCertificate.verify( + const result = await inclusionCertificate.verify( stateId, leafValue, new DataHash(HashAlgorithm.SHA256, inclusionProof.unicityCertificate.inputRecord.hash), diff --git a/src/transaction/verification/worker/WorkerTokenVerifier.ts b/src/transaction/verification/worker/WorkerTokenVerifier.ts index bf3b9ead..6c4a1e8a 100644 --- a/src/transaction/verification/worker/WorkerTokenVerifier.ts +++ b/src/transaction/verification/worker/WorkerTokenVerifier.ts @@ -1,3 +1,4 @@ +import { TransferTransaction } from '../../TransferTransaction.js'; import { ITokenVerifier } from '../ITokenVerifier.js'; import { IVerificationContext } from '../IVerificationContext.js'; import { IWorker } from './IWorker.js'; @@ -140,7 +141,6 @@ class WorkerTransferTransaction { public readonly sourceStateHash: DataHash, public readonly lockScript: EncodedPredicate, public readonly expiresAt: bigint | null, - public readonly referenceTime: bigint, public readonly inclusionProof: InclusionProof, ) {} @@ -151,22 +151,18 @@ class WorkerTransferTransaction { * @returns {WorkerTransferTransaction} Decoded transfer. */ public static fromCBOR(bytes: Uint8Array): WorkerTransferTransaction { - const data = CborDeserializer.decodeArray(bytes, 4); - const certified = CborDeserializer.decodeArray(data[0], 3); - const proof = InclusionProof.fromCBOR(certified[2]); - const referenceTime = CborDeserializer.decodeUnsignedInteger(certified[1]); - // A null reference time on the proof also fails this comparison. - if (proof.referenceTime !== referenceTime) { - throw new Error('Certified transfer transaction reference time does not match its inclusion proof.'); - } + const data = CborDeserializer.decodeArray(bytes, 3); + const certified = CborDeserializer.decodeArray(data[0], 2); return new WorkerTransferTransaction( certified[0], DataHash.fromImprint(CborDeserializer.decodeByteString(data[1])), EncodedPredicate.fromCBOR(data[2]), - CborDeserializer.decodeNullable(data[3], CborDeserializer.decodeUnsignedInteger), - referenceTime, - proof, + // Recovered from the transfer bytes the transaction hash commits to, not + // carried alongside them: a copy outside those bytes is unauthenticated, + // and nothing downstream could tell the two apart if they disagreed. + TransferTransaction.expiresAtFromCBOR(certified[0]), + InclusionProof.fromCBOR(certified[1]), ); } @@ -182,7 +178,6 @@ class WorkerTransferTransaction { transaction.toCBOR(), CborSerializer.encodeByteString(transaction.sourceStateHash.imprint), transaction.lockScript.toCBOR(), - CborSerializer.encodeNullable(transaction.expiresAt, CborSerializer.encodeUnsignedInteger), ); } @@ -216,7 +211,6 @@ class WorkerTransferTransaction { this.inclusionProof, await this.calculateTransactionHash(), this.expiresAt, - this.referenceTime, this.lockScript, this.sourceStateHash, ); diff --git a/src/util/InclusionProofUtils.ts b/src/util/InclusionProofUtils.ts index e943ed28..3069b5ef 100644 --- a/src/util/InclusionProofUtils.ts +++ b/src/util/InclusionProofUtils.ts @@ -178,14 +178,6 @@ export async function waitInclusionProof( const poll = async (requestSignal: AbortSignal): Promise => { const { inclusionProof } = await client.getInclusionProof(stateId, { signal: requestSignal }); - if (inclusionProof.certificationData == null || inclusionProof.inclusionCertificate == null) { - return null; - } - - const referenceTime = inclusionProof.referenceTime; - if (referenceTime == null) { - throw new Error('Inclusion proof is missing its leaf creation reference time.'); - } const verificationStatus = await InclusionProofVerificationRule.verify( trustBase, @@ -194,7 +186,6 @@ export async function waitInclusionProof( inclusionProof, transactionHash, transaction.expiresAt, - referenceTime, transaction.lockScript, transaction.sourceStateHash, ); @@ -202,6 +193,11 @@ export async function waitInclusionProof( switch (verificationStatus.status) { case InclusionProofVerificationStatus.OK: return inclusionProof; + // The one status that means "not certified yet", and the only one worth + // polling through. A proof that is present but structurally impossible — + // certification data without a certificate, a leaf without its creation + // time — is a non-conforming service or a stripping proxy, and reporting + // it as pending would hide the cause behind the caller's own timeout. case InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING: return null; default: diff --git a/tests/functional/CertifiedTransactionWireTest.ts b/tests/functional/CertifiedTransactionWireTest.ts new file mode 100644 index 00000000..c6ff92d9 --- /dev/null +++ b/tests/functional/CertifiedTransactionWireTest.ts @@ -0,0 +1,154 @@ +import { TestAggregatorClient } from './TestAggregatorClient.js'; +import { InclusionProof } from '../../src/api/InclusionProof.js'; +import { SigningService } from '../../src/crypto/secp256k1/SigningService.js'; +import { SignaturePredicate } from '../../src/predicate/builtin/SignaturePredicate.js'; +import { PredicateVerifierService } from '../../src/predicate/verification/PredicateVerifierService.js'; +import { CborDeserializer } from '../../src/serialization/cbor/CborDeserializer.js'; +import { CborSerializer } from '../../src/serialization/cbor/CborSerializer.js'; +import { StateTransitionClient } from '../../src/StateTransitionClient.js'; +import { CertifiedMintTransaction } from '../../src/transaction/CertifiedMintTransaction.js'; +import { MintTransaction } from '../../src/transaction/MintTransaction.js'; +import { Token } from '../../src/transaction/Token.js'; +import { TransferTransaction } from '../../src/transaction/TransferTransaction.js'; +import { IVerificationContext } from '../../src/transaction/verification/IVerificationContext.js'; +import { MintJustificationVerifierService } from '../../src/transaction/verification/MintJustificationVerifierService.js'; +import { InclusionProofVerificationStatus } from '../../src/transaction/verification/rule/InclusionProofVerificationRule.js'; +import { TokenIssuanceVerifierService } from '../../src/transaction/verification/TokenIssuanceVerifierService.js'; +import { VerificationContext } from '../../src/transaction/verification/VerificationContext.js'; +import { VerificationError } from '../../src/verification/VerificationError.js'; +import { expiresAt } from '../utils/ExpiresAt.js'; +import { mintToken, transferToken } from '../utils/TokenUtils.js'; +import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; + +describe('Certified transaction wire format', () => { + const aggregatorClient = TestAggregatorClient.create(); + const client = new StateTransitionClient(aggregatorClient); + const trustBase = aggregatorClient.rootTrustBase; + const alice = SigningService.generate(); + const bob = SigningService.generate(); + + const context: IVerificationContext = new VerificationContext( + trustBase, + PredicateVerifierService.create(), + createUnicityCertificateVerifier(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), + ); + + let deadline: bigint; + let token: Token; + + beforeAll(async () => { + deadline = expiresAt(); + token = await transferToken( + client, + context, + ( + await mintToken( + client, + context, + SignaturePredicate.create(alice.publicKey), + null, + trustBase.networkId, + undefined, + undefined, + null, + deadline, + ) + ).toCBOR(), + SignaturePredicate.create(bob.publicKey), + alice, + deadline, + ); + }, 30000); + + // The reference time used to be written beside the proof that already + // carries it, costing an extra element plus a consistency check at every + // decode. The service records the leaf's creation time on the record and + // serves the same value for every proof of that leaf, so the proof is the + // single source and the copy is gone. + it('carries the transaction and its proof, and nothing else', () => { + for (const bytes of [token.genesis.toCBOR(), ...token.transactions.map((t) => t.toCBOR())]) { + expect(CborDeserializer.decodeArray(bytes)).toHaveLength(2); + } + }); + + it('reads the reference time back off the proof', async () => { + const round = await Token.fromCBOR(token.toCBOR()); + + expect(round.genesis.referenceTime).toEqual(round.genesis.inclusionProof.referenceTime); + expect(round.transactions[0].referenceTime).toEqual(round.transactions[0].inclusionProof.referenceTime); + await expect(round.verify(context).then((result) => result.status)).resolves.toEqual('OK'); + }, 30000); + + // Nothing certifies a transaction if the proof reports no leaf, and decoding + // one into a certified transaction would hand every later verifier a + // transaction with no reference time at all. + it('refuses to decode a certified transaction whose proof has no leaf', async () => { + const pending = new InclusionProof(null, null, null, token.genesis.inclusionProof.unicityCertificate); + const bytes = CborSerializer.encodeArray(CborDeserializer.decodeArray(token.genesis.toCBOR())[0], pending.toCBOR()); + + await expect(CertifiedMintTransaction.fromCBOR(bytes)).rejects.toThrow( + 'Certified mint transaction carries an inclusion proof with no certified leaf.', + ); + }); + + // Binding a transaction to a proof for a state the aggregator has not + // certified yet is the ordinary "not ready" case, and callers branch on that + // status to retry. A guard in this factory used to intercept it and report a + // missing reference time instead, which no retry path recognises. + it('reports a pending state as a missing certificate, not a missing reference time', async () => { + const pending = new InclusionProof(null, null, null, token.genesis.inclusionProof.unicityCertificate); + const uncertified = await MintTransaction.create(trustBase.networkId, SignaturePredicate.create(alice.publicKey), { + expiresAt: expiresAt(), + }); + + const rejection = await CertifiedMintTransaction.fromTransaction( + trustBase, + PredicateVerifierService.create(), + createUnicityCertificateVerifier(), + uncertified, + pending, + ).then( + () => null, + (error: VerificationError) => error, + ); + + expect(rejection?.verificationResult.status).toEqual( + InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING, + ); + }, 30000); + + // Every structure the token embeds changed shape in this release. Without the + // bump a token written by an older SDK passes the version check and then dies + // on a CBOR array-length error that never mentions versioning. + it('rejects a token written against a different version', async () => { + const bytes = token.toCBOR(); + const elements = CborDeserializer.decodeArray(CborDeserializer.decodeTag(bytes).data, 3); + expect(CborDeserializer.decodeUnsignedInteger(elements[0])).toEqual(2n); + + for (const badVersion of [1n, 3n]) { + const mismatched = CborSerializer.encodeTag( + Token.CBOR_TAG, + CborSerializer.encodeArray(CborSerializer.encodeUnsignedInteger(badVersion), elements[1], elements[2]), + ); + + await expect(Token.fromCBOR(mismatched)).rejects.toThrow(`Unsupported Token version: ${badVersion}`); + } + }); + + // Nine other wire types expose their version; these three lost theirs in the + // same release that changed their shape, leaving consumers no way to read it. + it('exposes the wire version of every transaction type', async () => { + const mint = await MintTransaction.create(trustBase.networkId, SignaturePredicate.create(alice.publicKey)); + const transfer = await TransferTransaction.create( + token, + SignaturePredicate.create(alice.publicKey), + token.genesis.stateMask, + ); + + expect(mint.version).toEqual(MintTransaction.VERSION); + expect(transfer.version).toEqual(TransferTransaction.VERSION); + expect(token.genesis.inclusionProof.certificationData?.version).toEqual(2n); + }, 30000); +}); diff --git a/tests/functional/ExpiresAtTest.ts b/tests/functional/ExpiresAtTest.ts index 391a3c7d..3e06c80a 100644 --- a/tests/functional/ExpiresAtTest.ts +++ b/tests/functional/ExpiresAtTest.ts @@ -4,14 +4,30 @@ import { CertificationStatus } from '../../src/api/CertificationResponse.js'; import { NetworkId } from '../../src/api/NetworkId.js'; import { SigningService } from '../../src/crypto/secp256k1/SigningService.js'; import { SignaturePredicate } from '../../src/predicate/builtin/SignaturePredicate.js'; +import { PredicateVerifierService } from '../../src/predicate/verification/PredicateVerifierService.js'; import { StateTransitionClient } from '../../src/StateTransitionClient.js'; import { MintTransaction } from '../../src/transaction/MintTransaction.js'; +import { StateMask } from '../../src/transaction/StateMask.js'; +import { TransferTransaction } from '../../src/transaction/TransferTransaction.js'; +import { MintJustificationVerifierService } from '../../src/transaction/verification/MintJustificationVerifierService.js'; +import { TokenIssuanceVerifierService } from '../../src/transaction/verification/TokenIssuanceVerifierService.js'; +import { VerificationContext } from '../../src/transaction/verification/VerificationContext.js'; import { expiredExpiresAt, expiresAt } from '../utils/ExpiresAt.js'; +import { mintToken } from '../utils/TokenUtils.js'; +import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; describe('Certification request timeout', () => { const aggregatorClient = TestAggregatorClient.create(); const client = new StateTransitionClient(aggregatorClient); + const trustBase = aggregatorClient.rootTrustBase; const recipient = SignaturePredicate.create(SigningService.generate().publicKey); + const context = new VerificationContext( + trustBase, + PredicateVerifierService.create(), + createUnicityCertificateVerifier(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), + ); const submit = async (timeout: bigint): Promise => { const transaction = await MintTransaction.create(NetworkId.LOCAL, recipient, { expiresAt: timeout }); @@ -28,6 +44,66 @@ describe('Certification request timeout', () => { await expect(submit(expiredExpiresAt())).resolves.toEqual(String(CertificationStatus.REQUEST_EXPIRED)); }); + it('rejects a request whose service-assigned deadline has already lapsed', async () => { + // A request that omits expiresAt is admitted under a deadline the service + // derives from consensus time. That branch is the default for every caller + // in this repo, and it can expire too — a zero-length lifetime is expired + // the moment it is granted, because the deadline is exclusive. + const service = TestAggregatorClient.create(); + const scoped = new StateTransitionClient(service); + service.setRequestTtl(0n); + + const transaction = await MintTransaction.create(NetworkId.LOCAL, recipient, { expiresAt: null }); + + await expect( + scoped + .submitCertificationRequest(await CertificationData.fromMintTransaction(transaction)) + .then((response) => response.status), + ).resolves.toEqual(String(CertificationStatus.REQUEST_EXPIRED)); + }); + + it('reports that it is not ready before consensus hands it a reference time', async () => { + const service = TestAggregatorClient.create(); + const scoped = new StateTransitionClient(service); + service.setReferenceTime(0n); + + const transaction = await MintTransaction.create(NetworkId.LOCAL, recipient, { expiresAt: expiresAt() }); + + await expect( + scoped + .submitCertificationRequest(await CertificationData.fromMintTransaction(transaction)) + .then((response) => response.status), + ).resolves.toEqual(String(CertificationStatus.SERVICE_NOT_READY)); + }); + + describe('validation', () => { + // Out-of-range deadlines used to be accepted here and surface much later as + // a bare CborError from inside the transaction hash, far from the mistake. + const rejected: ReadonlyArray<[string, bigint]> = [ + ['negative', -1n], + ['zero, which is expired against every reference time', 0n], + ['wider than CBOR can carry', 2n ** 70n], + ]; + + it.each(rejected)('rejects a mint deadline that is %s', async (_label, deadline) => { + await expect(MintTransaction.create(NetworkId.LOCAL, recipient, { expiresAt: deadline })).rejects.toThrow( + /Request deadline/, + ); + }); + + it.each(rejected)( + 'rejects a transfer deadline that is %s', + async (_label, deadline) => { + const token = await mintToken(client, context, recipient, null, trustBase.networkId); + + await expect( + TransferTransaction.create(token, recipient, StateMask.generate(), { expiresAt: deadline }), + ).rejects.toThrow(/Request deadline/); + }, + 30000, + ); + }); + it('binds the timeout into the transaction hash', async () => { const first = await MintTransaction.create(NetworkId.LOCAL, recipient, { expiresAt: 1755000000n }); const second = await MintTransaction.create(NetworkId.LOCAL, recipient, { diff --git a/tests/functional/TestAggregatorClient.ts b/tests/functional/TestAggregatorClient.ts index 21845c29..cf303952 100644 --- a/tests/functional/TestAggregatorClient.ts +++ b/tests/functional/TestAggregatorClient.ts @@ -31,6 +31,11 @@ export class TestAggregatorClient implements IAggregatorClient { * is against a live aggregator. */ private referenceTime: bigint = BigInt(Math.floor(Date.now() / 1000)); + /** + * Lifetime the service grants a request that omits its own deadline, matching + * the aggregator's one-hour DEFAULT_REQUEST_TTL fallback. + */ + private requestTtl: bigint = 3600n; private readonly requests: Map = new Map(); private constructor( @@ -79,6 +84,28 @@ export class TestAggregatorClient implements IAggregatorClient { ); } + /** + * Drive the round clock the fake certifies under. + * + * Zero is how the service reports that it has no consensus reference time + * yet: it certifies nothing until consensus hands it one. + * + * @param {bigint} referenceTime Reference time a round starting now would pin. + */ + public setReferenceTime(referenceTime: bigint): void { + this.referenceTime = referenceTime; + } + + /** + * Shorten the lifetime granted to a request that carries no deadline of its + * own, so a test can watch a service-assigned deadline lapse. + * + * @param {bigint} requestTtl Lifetime in seconds. + */ + public setRequestTtl(requestTtl: bigint): void { + this.requestTtl = requestTtl; + } + /** * @inheritDoc */ @@ -97,7 +124,19 @@ export class TestAggregatorClient implements IAggregatorClient { return CertificationResponse.create(CertificationStatus.SIGNATURE_VERIFICATION_FAILED); } - if (certificationData.expiresAt != null && this.referenceTime >= certificationData.expiresAt) { + // Nothing can be certified before consensus has handed the service a + // reference time to pin rounds to. + if (this.referenceTime === 0n) { + return CertificationResponse.create(CertificationStatus.SERVICE_NOT_READY); + } + + // An explicit deadline is used verbatim and is covered by the witness. A + // request without one is admitted under a deadline the service derives from + // consensus time; that value is service metadata, never recorded in the + // leaf and never re-checked by a later verifier. Either way the deadline is + // exclusive. + const effectiveTimeout = certificationData.expiresAt ?? this.referenceTime + this.requestTtl; + if (this.referenceTime >= effectiveTimeout) { return CertificationResponse.create(CertificationStatus.REQUEST_EXPIRED); } diff --git a/tests/functional/WorkerTokenVerifierTest.ts b/tests/functional/WorkerTokenVerifierTest.ts index 12113fef..061dcfbd 100644 --- a/tests/functional/WorkerTokenVerifierTest.ts +++ b/tests/functional/WorkerTokenVerifierTest.ts @@ -8,8 +8,10 @@ import { UnicityCertificateVerifier } from '../../src/api/bft/verification/Unici import { SigningService } from '../../src/crypto/secp256k1/SigningService.js'; import { SignaturePredicate } from '../../src/predicate/builtin/SignaturePredicate.js'; import { PredicateVerifierService } from '../../src/predicate/verification/PredicateVerifierService.js'; +import { CborDeserializer } from '../../src/serialization/cbor/CborDeserializer.js'; import { StateTransitionClient } from '../../src/StateTransitionClient.js'; import { Token } from '../../src/transaction/Token.js'; +import { TransferTransaction } from '../../src/transaction/TransferTransaction.js'; import { TokenVerifier } from '../../src/transaction/verification/default/TokenVerifier.js'; import { IVerificationContext } from '../../src/transaction/verification/IVerificationContext.js'; import { MintJustificationVerifierService } from '../../src/transaction/verification/MintJustificationVerifierService.js'; @@ -24,6 +26,7 @@ import { WorkerTokenVerifier, } from '../../src/transaction/verification/worker/WorkerTokenVerifier.js'; import { VerificationStatus } from '../../src/verification/VerificationStatus.js'; +import { expiresAt } from '../utils/ExpiresAt.js'; import { mintToken, transferToken } from '../utils/TokenUtils.js'; import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; @@ -103,7 +106,10 @@ describe('WorkerTokenVerifier', () => { ); // Mint a token and transfer it twice (alice -> bob -> carol), yielding two transfers. - const mintWithTwoTransfers = async (context: IVerificationContext): Promise => { + const mintWithTwoTransfers = async ( + context: IVerificationContext, + deadline: bigint | null = null, + ): Promise => { const alice = SigningService.generate(); const bob = SigningService.generate(); const carol = SigningService.generate(); @@ -114,6 +120,10 @@ describe('WorkerTokenVerifier', () => { SignaturePredicate.create(alice.publicKey), null, trustBase.networkId, + undefined, + undefined, + null, + deadline, ); const bobToken = await transferToken( client, @@ -121,9 +131,10 @@ describe('WorkerTokenVerifier', () => { aliceToken.toCBOR(), SignaturePredicate.create(bob.publicKey), alice, + deadline, ); - return transferToken(client, context, bobToken.toCBOR(), SignaturePredicate.create(carol.publicKey), bob); + return transferToken(client, context, bobToken.toCBOR(), SignaturePredicate.create(carol.publicKey), bob, deadline); }; // Shared across the tests below; minted once because certification is expensive. @@ -148,6 +159,44 @@ describe('WorkerTokenVerifier', () => { } }, 30000); + it('verifies transfers that carry an explicit deadline', async () => { + // Every other token in this suite leaves the deadline null, so without this + // the non-null path across the worker boundary never runs. + const deadline = expiresAt(); + const deadlineContext = createContext(); + const deadlineToken = await mintWithTwoTransfers(deadlineContext, deadline); + expect(deadlineToken.transactions.map((transaction) => transaction.expiresAt)).toEqual([deadline, deadline]); + + const verifier = new TestWorkerTokenVerifier(2); + const result = await verifier.verify(deadlineToken, deadlineContext); + expect(result.status).toEqual(VerificationStatus.OK); + verifier.dispose(); + }, 30000); + + // The deadline used to travel as a fourth element beside the transfer bytes, + // outside the bytes whose SHA-256 is the transaction hash, and the decoder + // never compared the two. It is recovered from those bytes now, so there is + // no second copy to disagree with them. + it('sends the transfer bytes and their chain context, with no separate deadline', async () => { + const deadline = expiresAt(); + const deadlineContext = createContext(); + const deadlineToken = await mintWithTwoTransfers(deadlineContext, deadline); + + let payload: Uint8Array | undefined; + const verifier = new TestWorkerTokenVerifier(1, (request) => { + payload ??= request.transfers[0].bytes; + return defaultResponder(request); + }); + await verifier.verify(deadlineToken, deadlineContext); + verifier.dispose(); + + const elements = CborDeserializer.decodeArray(payload!); + expect(elements).toHaveLength(3); + // The deadline the worker verifies against comes out of element 0, the + // transfer bytes the transaction hash commits to. + expect(TransferTransaction.expiresAtFromCBOR(CborDeserializer.decodeArray(elements[0], 2)[0])).toEqual(deadline); + }, 30000); + it('splits transfers across the pool and reuses it over verifications', async () => { const verifier = new TestWorkerTokenVerifier(2); diff --git a/tests/functional/payment/SplitBuilderTest.ts b/tests/functional/payment/SplitBuilderTest.ts index 45902d70..5109c776 100644 --- a/tests/functional/payment/SplitBuilderTest.ts +++ b/tests/functional/payment/SplitBuilderTest.ts @@ -76,6 +76,18 @@ describe('SplitBuilder Functional Test', () => { verificationContext, ); + // The burn transaction the split builds carries the deadline, so the split + // is a request boundary like the transaction factories and validates it the + // same way, before doing any of the tree work below. + await expect( + TokenSplit.split( + token, + TestPaymentData.decode, + [SplitTokenRequest.create(predicate, new TestPaymentData(PaymentAssetCollection.create(...assets)))], + { expiresAt: 0n }, + ), + ).rejects.toThrow(/Request deadline/); + await expect( TokenSplit.split( token, diff --git a/tests/integration/IntegrationConfig.ts b/tests/integration/IntegrationConfig.ts new file mode 100644 index 00000000..8fe634c9 --- /dev/null +++ b/tests/integration/IntegrationConfig.ts @@ -0,0 +1,51 @@ +import { readFileSync } from 'node:fs'; + +import { AggregatorClient } from '../../src/api/AggregatorClient.js'; +import { RootTrustBase } from '../../src/api/bft/RootTrustBase.js'; +import { IAggregatorClient } from '../../src/api/IAggregatorClient.js'; +import { StateTransitionClient } from '../../src/StateTransitionClient.js'; + +/** + * Read a value the stack published, or say why it is missing. + * + * Both are set by tests/integration/support/globalSetup.mjs once the stack it + * started is certifying. Their absence means this suite was run without + * jest.integration.config.js, so no stack exists to talk to. + * + * @param {string} name Environment variable to read. + * @returns {string} Its value. + * @throws {Error} If the variable is unset. + */ +function fromStack(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is unset. Run the integration suite with \`npm run test:integration\`.`); + } + + return value; +} + +/** + * Build the client and trust base the integration suite runs against. + * + * The endpoint and trust base come from the stack Testcontainers started for + * this run — a fresh chain, and a trust base its BFT root node generated at + * genesis. Nothing here is configurable: a run that could be pointed elsewhere + * would not be testing the compose file it exists to exercise, and pointing the + * SDK at a deployed network is what the e2e suite does. + * + * @returns {object} Aggregator client, state transition client and trust base for this run's stack. + */ +export function createIntegrationContext(): { + aggregatorClient: IAggregatorClient; + client: StateTransitionClient; + trustBase: RootTrustBase; +} { + const aggregatorClient = new AggregatorClient(fromStack('AGGREGATOR_URL'), null); + + return { + aggregatorClient, + client: new StateTransitionClient(aggregatorClient), + trustBase: RootTrustBase.fromJSON(JSON.parse(readFileSync(fromStack('TRUST_BASE_PATH'), 'utf-8'))), + }; +} diff --git a/tests/integration/RequestDeadlineTest.ts b/tests/integration/RequestDeadlineTest.ts new file mode 100644 index 00000000..7c229922 --- /dev/null +++ b/tests/integration/RequestDeadlineTest.ts @@ -0,0 +1,195 @@ +import { createIntegrationContext } from './IntegrationConfig.js'; +import { CertificationData } from '../../src/api/CertificationData.js'; +import { CertificationStatus } from '../../src/api/CertificationResponse.js'; +import { InclusionProof } from '../../src/api/InclusionProof.js'; +import { StateId } from '../../src/api/StateId.js'; +import { SigningService } from '../../src/crypto/secp256k1/SigningService.js'; +import { SignaturePredicate } from '../../src/predicate/builtin/SignaturePredicate.js'; +import { PredicateVerifierService } from '../../src/predicate/verification/PredicateVerifierService.js'; +import { MintTransaction } from '../../src/transaction/MintTransaction.js'; +import { InclusionProofVerificationStatus } from '../../src/transaction/verification/rule/InclusionProofVerificationRule.js'; +import { waitInclusionProof } from '../../src/util/InclusionProofUtils.js'; +import { VerificationError } from '../../src/verification/VerificationError.js'; +import { expiredExpiresAt, expiresAt } from '../utils/ExpiresAt.js'; +import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; + +/** + * Request-deadline behaviour against a real aggregator. + * + * The functional suite covers the same ground against + * {@link ../functional/TestAggregatorClient.js}, which derives leaf values with + * the very code under test; only a real service can tell whether the SDK and + * the aggregator still agree. Testcontainers starts the stack for the run; see + * ./support/aggregatorStack.mjs. + */ +describe('Integration request deadline', () => { + const { aggregatorClient, client, trustBase } = createIntegrationContext(); + const predicateVerifier = PredicateVerifierService.create(); + const unicityCertificateVerifier = createUnicityCertificateVerifier(); + + /** Build a mint request for a fresh recipient under the given deadline. */ + const mintTransaction = (deadline: bigint | null): Promise => + MintTransaction.create(trustBase.networkId, SignaturePredicate.create(SigningService.generate().publicKey), { + expiresAt: deadline, + }); + + const submit = async (transaction: MintTransaction): Promise => { + const response = await client.submitCertificationRequest(await CertificationData.fromMintTransaction(transaction)); + + return response.status; + }; + + /** Submit a mint and wait for the aggregator to certify it. */ + const certify = async (deadline: bigint | null): Promise<{ proof: InclusionProof; transaction: MintTransaction }> => { + const transaction = await mintTransaction(deadline); + expect(await submit(transaction)).toEqual(String(CertificationStatus.SUCCESS)); + + return { + proof: await waitInclusionProof(client, trustBase, predicateVerifier, unicityCertificateVerifier, transaction), + transaction, + }; + }; + + describe('at submission', () => { + it('accepts a deadline ahead of the round reference time', async () => { + await expect(submit(await mintTransaction(expiresAt()))).resolves.toEqual(String(CertificationStatus.SUCCESS)); + }, 30000); + + it('accepts a request that leaves the deadline to the service', async () => { + await expect(submit(await mintTransaction(null))).resolves.toEqual(String(CertificationStatus.SUCCESS)); + }, 30000); + + it('rejects a deadline that has already passed', async () => { + await expect(submit(await mintTransaction(expiredExpiresAt()))).resolves.toEqual( + String(CertificationStatus.REQUEST_EXPIRED), + ); + }, 30000); + + it('rejects a deadline equal to a reference time already reached, because the deadline is exclusive', async () => { + // A reference time the service has already certified a leaf under, so it + // is at or behind the reference time the next round pins. + const { proof } = await certify(expiresAt()); + const reached = proof.referenceTime; + expect(reached).not.toBeNull(); + + await expect(submit(await mintTransaction(reached))).resolves.toEqual( + String(CertificationStatus.REQUEST_EXPIRED), + ); + }, 60000); + }); + + describe('in the certified leaf', () => { + it('binds a service-assigned deadline without recording it', async () => { + const { proof, transaction } = await certify(null); + + // The service derives a deadline from consensus time for a request that + // omits one. That value is service metadata: it is never written to the + // leaf, so a later verifier sees the same null the requester sent and has + // nothing to re-check. + expect(transaction.expiresAt).toBeNull(); + expect(proof.certificationData?.expiresAt ?? null).toBeNull(); + expect(proof.referenceTime).not.toBeNull(); + }, 60000); + + it('serves back the explicit deadline the transaction hash commits to', async () => { + const deadline = expiresAt(); + const { proof, transaction } = await certify(deadline); + + expect(proof.certificationData?.expiresAt).toEqual(deadline); + // Admission is what the deadline governs, and it is exclusive: the leaf + // could only be created in a round strictly before it. + expect(proof.referenceTime).not.toBeNull(); + expect(proof.referenceTime! < deadline).toBe(true); + + // The whole certified transaction verifies, which re-derives the leaf + // value from this reference time and checks the SMT path against it. + const certified = await transaction.toCertifiedTransaction( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + proof, + ); + expect(certified.expiresAt).toEqual(deadline); + expect(certified.referenceTime).toEqual(proof.referenceTime); + }, 60000); + + it('reports a reference time no later than the round that certified it', async () => { + const { proof, transaction } = await certify(expiresAt()); + + // The service sets the round's input record timestamp to the very + // reference time its leaves are built from, so for the certifying round + // the two are equal and the bound the verification rule enforces is + // exact. Verification therefore has to accept this proof. + expect(proof.referenceTime).toEqual(proof.unicityCertificate.inputRecord.timestamp); + await expect( + transaction.toCertifiedTransaction(trustBase, predicateVerifier, unicityCertificateVerifier, proof), + ).resolves.toBeDefined(); + + // And reject the same leaf presented against a round whose signed clock + // precedes it, which no aggregator can produce. Note this bounds the + // reference time from above only; it does not establish when the leaf was + // created, so it does not stop a dishonest service back-dating one. + const backDated = new InclusionProof( + proof.certificationData, + proof.referenceTime! + 1n, + proof.inclusionCertificate, + proof.unicityCertificate, + ); + const rejection = await transaction + .toCertifiedTransaction(trustBase, predicateVerifier, unicityCertificateVerifier, backDated) + .then( + () => null, + (error: VerificationError) => error, + ); + expect(rejection?.verificationResult.status).toEqual(InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND); + }, 60000); + + it('keeps the reference time stable while the certifying round moves on', async () => { + const { proof, transaction } = await certify(expiresAt()); + const stateId = await StateId.fromTransaction(transaction); + const certifiedRound = proof.unicityCertificate.inputRecord.roundNumber; + const certifiedRoundTime = proof.unicityCertificate.inputRecord.timestamp; + + // The tree is append-only and rounds keep being certified, so wait until a + // re-fetch is genuinely served against a later root whose clock has moved + // on. Waiting on the timestamp rather than the round number is what makes + // the assertion below non-trivial: round timestamps are whole seconds and + // rounds are shorter than that, so a later round can still report the same + // second. + const waitUntil = Date.now() + 30000; + let refetched = (await aggregatorClient.getInclusionProof(stateId)).inclusionProof; + while (refetched.unicityCertificate.inputRecord.timestamp <= certifiedRoundTime && Date.now() < waitUntil) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetched = (await aggregatorClient.getInclusionProof(stateId)).inclusionProof; + } + expect(refetched.unicityCertificate.inputRecord.roundNumber).toBeGreaterThan(certifiedRound); + expect(refetched.unicityCertificate.inputRecord.timestamp).toBeGreaterThan(certifiedRoundTime); + + // The newer root moved the round clock forward, but the leaf's own + // creation time did not move with it. The SDK depends on exactly this: it + // pins the reference time when the transaction is first bound to a proof + // and rejects any later proof that disagrees. + expect(refetched.referenceTime).toEqual(proof.referenceTime); + expect(refetched.certificationData?.expiresAt).toEqual(proof.certificationData?.expiresAt); + + await expect( + transaction.toCertifiedTransaction(trustBase, predicateVerifier, unicityCertificateVerifier, refetched), + ).resolves.toBeDefined(); + }, 90000); + }); + + describe('for a pending state', () => { + it('reports a leaf-less proof for a request that was never submitted', async () => { + const transaction = await mintTransaction(expiresAt()); + const stateId = await StateId.fromTransaction(transaction); + + const { inclusionProof } = await client.getInclusionProof(stateId); + + // Nothing was certified, so the three leaf fields are absent together — + // the invariant InclusionProof.fromCBOR enforces on decode. + expect(inclusionProof.certificationData).toBeNull(); + expect(inclusionProof.referenceTime).toBeNull(); + expect(inclusionProof.inclusionCertificate).toBeNull(); + }, 30000); + }); +}); diff --git a/tests/integration/TransitionFlowTest.ts b/tests/integration/TransitionFlowTest.ts new file mode 100644 index 00000000..664cd14e --- /dev/null +++ b/tests/integration/TransitionFlowTest.ts @@ -0,0 +1,8 @@ +import { createIntegrationContext } from './IntegrationConfig.js'; +import { transitionFlowTest } from '../utils/TransitionFlow.js'; + +describe('Integration TransitionFlow', () => { + const { client, trustBase } = createIntegrationContext(); + + transitionFlowTest(client, trustBase); +}); diff --git a/tests/integration/docker/.gitignore b/tests/integration/docker/.gitignore new file mode 100644 index 00000000..8fce6030 --- /dev/null +++ b/tests/integration/docker/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/tests/integration/docker/docker-compose.yml b/tests/integration/docker/docker-compose.yml new file mode 100644 index 00000000..dfdff199 --- /dev/null +++ b/tests/integration/docker/docker-compose.yml @@ -0,0 +1,191 @@ +# Local aggregator stack the integration suite runs against. +# +# Mirrors the topology of aggregator-go's own docker-compose.yml, with two +# deliberate differences: the aggregator runs from a pinned prebuilt image +# instead of a local rocksdb build, and DEFAULT_REQUEST_TTL is short enough +# that the service-assigned request deadline can be observed within a test. +# +# Testcontainers drives it, from tests/integration/support/aggregatorStack.mjs: +# that creates the writable genesis directories this file mounts, publishes the +# aggregator on an ephemeral port, and waits for consensus to produce a +# reference time before any test runs. + +x-bft: &bft-base + platform: linux/amd64 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + # https://github.com/unicitynetwork/bft-core/pkgs/container/bft-core + image: ghcr.io/unicitynetwork/bft-core:ceceacd11b7a735de74ce17884a3a45e0db1748d + +services: + bft-root: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + healthcheck: + test: ["CMD", "nc", "-zv", "bft-root", "8002"] + interval: 2s + timeout: 3s + retries: 30 + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/root/node-info.json ] && [ -f /genesis/trust-base.json ] && [ -f /genesis/root/trust-base-signed.json ]; then + echo "Genesis files already exist, skipping initialization." + else + echo "Creating root genesis..." && + ubft root-node init --home /genesis/root -g && + echo "Creating root trust base..." && + ubft trust-base generate --home /genesis --network-id 3 --node-info /genesis/root/node-info.json && + echo "Signing root trust base..." && + ubft trust-base sign --home /genesis/root --trust-base /genesis/trust-base.json + fi + echo "Starting root node..." && + exec ubft root-node run --home /genesis/root --address "/ip4/$(hostname -i)/tcp/8000" --trust-base /genesis/trust-base.json --rpc-server-address "$(hostname -i):8002" + + bft-aggregator-genesis-gen: + <<: *bft-base + volumes: + - ./data/genesis-root:/genesis/root + - ./data/genesis:/genesis + depends_on: + bft-root: + condition: service_healthy + entrypoint: ["/busybox/sh", "-c"] + command: + - | + if [ -f /genesis/aggregator/node-info.json ] && [ -f /genesis/shard-conf-7_0.json ]; then + echo "Aggregator genesis and config already exist, skipping initialization." + else + echo "Creating aggregator genesis..." && + ubft shard-node init --home /genesis/aggregator --generate && + echo "Creating aggregator partition configuration..." && + ubft shard-conf generate --home /genesis --t2-timeout 5000 --network-id 3 --partition-id 7 --partition-type-id 7 --epoch-start 10 --node-info=/genesis/aggregator/node-info.json + fi + chmod -R 755 /genesis/aggregator + chmod 644 /genesis/shard-conf-7_0.json + chmod 644 /genesis/trust-base.json + chmod -R 755 /genesis/root + echo "Genesis ready." + + upload-configurations: + image: curlimages/curl:8.13.0 + user: "${USER_UID:-1001}:${USER_GID:-1001}" + depends_on: + bft-root: + condition: service_healthy + bft-aggregator-genesis-gen: + condition: service_completed_successfully + restart: on-failure + volumes: + - ./data/genesis:/genesis + command: | + /bin/sh -c " + echo Uploading aggregator configuration && + curl -sf -X PUT -H 'Content-Type: application/json' -d @/genesis/shard-conf-7_0.json http://bft-root:8002/api/v1/configurations + " + + redis: + image: redis:7-alpine + command: redis-server --save "" --appendonly no + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 15 + + mongodb: + image: mongo:7.0 + command: ["--replSet", "rs0", "--bind_ip_all", "--noauth"] + healthcheck: + # Initiates the replica set on the first probe, then reports healthy only + # once this node has actually been elected primary — rs.status() answers + # well before the set can accept writes, and the aggregator's storage + # init times out against a set that is still electing. + test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status() } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) } if (!db.hello().isWritablePrimary) { quit(1) }"] + interval: 2s + timeout: 5s + retries: 30 + start_period: 5s + + aggregator: + image: ghcr.io/unicitynetwork/aggregator-go:${AGGREGATOR_IMAGE_TAG:-sha-ae08165} + restart: on-failure + ports: + - "${AGGREGATOR_PORT:-3000}:3000" + volumes: + - ./data/genesis:/app/bft-config + environment: + PORT: "3000" + HOST: "0.0.0.0" + CONCURRENCY_LIMIT: "1000" + ENABLE_CORS: "true" + + MONGODB_URI: "mongodb://mongodb:27017/aggregator?replicaSet=rs0&directConnection=true" + MONGODB_DATABASE: "aggregator" + # Generous enough to ride out the replica-set election on a cold start. + # The aggregator creates its indexes during storage init and exits if that + # times out; the defaults give up while the fresh set is still electing. + MONGODB_CONNECT_TIMEOUT: "30s" + MONGODB_SERVER_SELECTION_TIMEOUT: "30s" + + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_DB: "0" + + USE_REDIS_FOR_COMMITMENTS: "true" + REDIS_FLUSH_INTERVAL: "50ms" + + SMT_BACKEND: "memory" + + # Deadline the service assigns to a request that omits expiresAt. Short + # enough that a test can watch such a request expire; the aggregator + # rejects anything below one whole second. + DEFAULT_REQUEST_TTL: "${DEFAULT_REQUEST_TTL:-30s}" + + DISABLE_HIGH_AVAILABILITY: "false" + LOCK_TTL_SECONDS: "30" + LEADER_HEARTBEAT_INTERVAL: "10s" + LEADER_ELECTION_POLLING_INTERVAL: "5s" + BLOCK_SYNC_INTERVAL: "1s" + + LOG_LEVEL: "${LOG_LEVEL:-info}" + LOG_FORMAT: "json" + LOG_ENABLE_JSON: "true" + + BATCH_LIMIT: "1000" + MAX_COMMITMENTS_PER_ROUND: "10000" + + SIGNING_KEY_FILE: "/app/bft-config/aggregator/keys.json" + + BFT_ENABLED: "true" + BFT_SHARD_CONF_FILE: "/app/bft-config/shard-conf-7_0.json" + BFT_TRUST_BASE_FILES: "/app/bft-config/trust-base.json" + BFT_RPC_ADDRESS: "http://127.0.0.1:8002" + entrypoint: ["/bin/sh", "-c"] + command: + - | + ROOT_NODE_ID=$$(grep -o '"nodeId": "[^"]*"' /app/bft-config/trust-base.json | head -1 | cut -d'"' -f4) + if [ -z "$$ROOT_NODE_ID" ]; then + echo "Error: could not read root nodeId from /app/bft-config/trust-base.json" + exit 1 + fi + export BFT_BOOTSTRAP_ADDRESSES="/dns4/bft-root/tcp/8000/p2p/$$ROOT_NODE_ID" + exec /app/aggregator + depends_on: + bft-aggregator-genesis-gen: + condition: service_completed_successfully + upload-configurations: + condition: service_completed_successfully + redis: + condition: service_healthy + mongodb: + condition: service_healthy + healthcheck: + # A real GET, not --spider: busybox wget spiders with HEAD, and /health is + # registered GET-only, so the image's own HEALTHCHECK never passes. + test: ["CMD", "wget", "--quiet", "--tries=1", "--output-document=/dev/null", "http://localhost:3000/health"] + interval: 2s + timeout: 5s + retries: 45 + start_period: 5s diff --git a/tests/integration/support/aggregatorStack.mjs b/tests/integration/support/aggregatorStack.mjs new file mode 100644 index 00000000..25a32e8a --- /dev/null +++ b/tests/integration/support/aggregatorStack.mjs @@ -0,0 +1,110 @@ +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { DockerComposeEnvironment, Wait } from 'testcontainers'; + +const COMPOSE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'docker'); +const DATA_DIR = path.join(COMPOSE_DIR, 'data'); +const TRUST_BASE_PATH = path.join(DATA_DIR, 'genesis', 'trust-base.json'); + +/** The aggregator's own port inside the container; the host port is ephemeral. */ +const AGGREGATOR_PORT = 3000; +/** Genesis, a replica-set election and the first certified round, on a cold start. */ +const STARTUP_TIMEOUT_MS = 240000; + +/** + * Ask the aggregator for its block height. + * + * @param {string} url Aggregator base URL. + * @returns {Promise} Height, or null if it cannot be read yet. + */ +async function blockHeight(url) { + try { + const response = await fetch(url, { + body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'get_block_height', params: {} }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + signal: AbortSignal.timeout(5000), + }); + if (!response.ok) { + return null; + } + const { result } = await response.json(); + return result?.blockNumber != null ? BigInt(result.blockNumber) : null; + } catch { + return null; + } +} + +/** + * Block until consensus is certifying rounds. + * + * A healthy aggregator is not a usable one: until consensus hands it a + * reference time it answers every certification request with + * SERVICE_NOT_READY, so the tests would fail on a service that is merely + * still starting. + * + * @param {string} url Aggregator base URL. + * @returns {Promise} Resolves once a block has been certified. + * @throws {Error} If no block is certified before the startup timeout. + */ +async function waitForCertification(url) { + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + const height = await blockHeight(url); + if (height != null && height > 0n) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error(`Aggregator at ${url} did not certify a block within ${STARTUP_TIMEOUT_MS}ms.`); +} + +/** + * Start the aggregator stack the integration suite runs against. + * + * The suite always runs against a stack it started itself, on a chain that + * begins empty. Pointing it at a service someone else is running is what the + * e2e suite is for; letting it happen here would mean a green integration run + * proved nothing about the compose file it is supposed to be exercising. + * + * @returns {Promise<{stop: () => Promise, trustBasePath: string, url: string}>} The running stack. + */ +export async function startAggregatorStack() { + // Genesis is bind-mounted and survives a container teardown. Reusing it + // against the fresh mongodb and redis volumes below would pair a chain that + // remembers nothing with a root node that remembers everything. + await rm(DATA_DIR, { force: true, recursive: true }); + await mkdir(path.join(DATA_DIR, 'genesis'), { recursive: true }); + await mkdir(path.join(DATA_DIR, 'genesis-root'), { recursive: true }); + + const environment = await new DockerComposeEnvironment(COMPOSE_DIR, 'docker-compose.yml') + .withEnvironment({ + // Port 0 publishes on an ephemeral host port, so concurrent runs and CI + // jobs cannot collide on a fixed one. + AGGREGATOR_PORT: '0', + USER_GID: String(process.getgid?.() ?? 1001), + USER_UID: String(process.getuid?.() ?? 1001), + }) + .withWaitStrategy( + 'aggregator-1', + Wait.forHttp('/health', AGGREGATOR_PORT).forStatusCode(200).withStartupTimeout(STARTUP_TIMEOUT_MS), + ) + .withStartupTimeout(STARTUP_TIMEOUT_MS) + .up(); + + const aggregator = environment.getContainer('aggregator-1'); + const url = `http://${aggregator.getHost()}:${aggregator.getMappedPort(AGGREGATOR_PORT)}`; + await waitForCertification(url); + + return { + stop: async () => { + await environment.down({ removeVolumes: true }); + await rm(DATA_DIR, { force: true, recursive: true }); + }, + trustBasePath: TRUST_BASE_PATH, + url, + }; +} diff --git a/tests/integration/support/globalSetup.mjs b/tests/integration/support/globalSetup.mjs new file mode 100644 index 00000000..856ea3a9 --- /dev/null +++ b/tests/integration/support/globalSetup.mjs @@ -0,0 +1,20 @@ +import { startAggregatorStack } from './aggregatorStack.mjs'; + +/** + * Start the aggregator stack once for the whole integration run and publish + * where it lives. + * + * The suites read AGGREGATOR_URL and TRUST_BASE_PATH; Jest copies the + * environment into its workers, so setting them here is what reaches the tests. + * + * @returns {Promise} Resolves once the stack is certifying. + */ +export default async function globalSetup() { + const stack = await startAggregatorStack(); + + // globalTeardown runs in this same process, so the handle can be passed + // through globalThis; nothing else can reach it. + globalThis.__AGGREGATOR_STACK__ = stack; + process.env.AGGREGATOR_URL = stack.url; + process.env.TRUST_BASE_PATH = stack.trustBasePath; +} diff --git a/tests/integration/support/globalTeardown.mjs b/tests/integration/support/globalTeardown.mjs new file mode 100644 index 00000000..13f93b87 --- /dev/null +++ b/tests/integration/support/globalTeardown.mjs @@ -0,0 +1,11 @@ +/** + * Stop the stack {@link ./globalSetup.mjs} started. + * + * A stack the run did not start is left alone: it belongs to whoever set + * AGGREGATOR_URL, and stopping it would break the next run. + * + * @returns {Promise} Resolves once the stack is down. + */ +export default async function globalTeardown() { + await globalThis.__AGGREGATOR_STACK__?.stop(); +} diff --git a/tests/unit/api/CertificationDataTest.ts b/tests/unit/api/CertificationDataTest.ts index 6927adcd..6be4f52d 100644 --- a/tests/unit/api/CertificationDataTest.ts +++ b/tests/unit/api/CertificationDataTest.ts @@ -2,6 +2,7 @@ import { CertificationData } from '../../../src/api/CertificationData.js'; import { NetworkId } from '../../../src/api/NetworkId.js'; import { SignaturePredicate } from '../../../src/predicate/builtin/SignaturePredicate.js'; import { EncodedPredicate } from '../../../src/predicate/EncodedPredicate.js'; +import { CborDeserializer } from '../../../src/serialization/cbor/CborDeserializer.js'; import { MintTransaction } from '../../../src/transaction/MintTransaction.js'; import { TokenSalt } from '../../../src/transaction/TokenSalt.js'; import { TokenType } from '../../../src/transaction/TokenType.js'; @@ -65,7 +66,12 @@ describe('CertificationData', () => { const encoded = withoutDeadline.toCBOR(); expect(encoded[3]).toBe(withDeadline.toCBOR()[3]); expect(encoded[4]).toBe(2); - expect(HexConverter.encode(encoded)).toContain('f6'); + // The deadline's own element, decoded rather than searched for: 'f6' + // appears somewhere in a hash-and-signature payload of this length with + // probability indistinguishable from one, so searching the hex asserts + // nothing about how the absent deadline was encoded. + const elements = CborDeserializer.decodeArray(CborDeserializer.decodeTag(encoded).data, 6); + expect(HexConverter.encode(elements[4])).toBe('f6'); const decoded = CertificationData.fromCBOR(encoded); expect(decoded.expiresAt).toBeNull(); diff --git a/tests/unit/api/InclusionProofTest.ts b/tests/unit/api/InclusionProofTest.ts index 17518670..029437c8 100644 --- a/tests/unit/api/InclusionProofTest.ts +++ b/tests/unit/api/InclusionProofTest.ts @@ -24,12 +24,12 @@ import { } from '../../../src/transaction/verification/rule/InclusionProofVerificationRule.js'; import { HexConverter } from '../../../src/util/HexConverter.js'; import { expiresAt } from '../../utils/ExpiresAt.js'; +import { REFERENCE_TIME } from '../../utils/ReferenceTime.js'; import { createRootTrustBase } from '../../utils/RootTrustBaseFixture.js'; import { createUnicityCertificate } from '../../utils/UnicityCertificateFixture.js'; import { createUnicityCertificateVerifier } from '../../utils/UnicityCertificateVerifierFixture.js'; describe('InclusionProof', () => { - const REFERENCE_TIME = 1755000000n; const signingService = new SigningService( new Uint8Array(HexConverter.decode('0000000000000000000000000000000000000000000000000000000000000001')), ); @@ -40,6 +40,7 @@ describe('InclusionProof', () => { let certificationData: CertificationData; let inclusionCertificate: InclusionCertificate; let unicityCertificate: UnicityCertificate; + let rootHash: DataHash; let trustBase: RootTrustBase; beforeAll(async () => { @@ -53,10 +54,11 @@ describe('InclusionProof', () => { await smt.addLeaf(stateId.data, (await calculateLeafValue(certificationData.transactionHash, REFERENCE_TIME)).data); const root = await smt.calculateRoot(); + rootHash = root.hash; inclusionCertificate = InclusionCertificate.create(root, stateId.data); - unicityCertificate = await createUnicityCertificate(root.hash, signingService); + unicityCertificate = await createUnicityCertificate(rootHash, signingService); trustBase = createRootTrustBase(signingService.publicKey); predicateVerifier = PredicateVerifierService.create(); unicityCertificateVerifier = createUnicityCertificateVerifier(); @@ -106,27 +108,55 @@ describe('InclusionProof', () => { new InclusionProof(certificationData, REFERENCE_TIME, inclusionCertificate, unicityCertificate), transactionHash, transaction.expiresAt, - REFERENCE_TIME, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), ).resolves.toEqual(InclusionProofVerificationStatus.OK); + // What the aggregator returns for a state it has not certified yet: all + // three leaf fields absent together. The one status a caller polls through. await expect( InclusionProofVerificationRule.verify( trustBase, predicateVerifier, unicityCertificateVerifier, - new InclusionProof(certificationData, REFERENCE_TIME, null, unicityCertificate), + new InclusionProof(null, null, null, unicityCertificate), transactionHash, transaction.expiresAt, - REFERENCE_TIME, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), ).resolves.toEqual(InclusionProofVerificationStatus.INCLUSION_CERTIFICATE_MISSING); }); + // A proof with some leaf fields but not others establishes neither a leaf nor + // its absence. fromCBOR rejects one off the wire, so these are reachable only + // hand-built, and each has to name what is missing rather than pass for + // "not certified yet" and leave the caller polling to its own deadline. + it.each([ + [null, REFERENCE_TIME, true, InclusionProofVerificationStatus.MISSING_CERTIFICATION_DATA], + [true, null, true, InclusionProofVerificationStatus.MISSING_REFERENCE_TIME], + [true, REFERENCE_TIME, false, InclusionProofVerificationStatus.INCOMPLETE_INCLUSION_PROOF], + ])('reports what a partially present proof is missing', async (hasData, referenceTime, hasCertificate, status) => { + await expect( + InclusionProofVerificationRule.verify( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + new InclusionProof( + hasData ? certificationData : null, + referenceTime, + hasCertificate ? inclusionCertificate : null, + unicityCertificate, + ), + await transaction.calculateTransactionHash(), + transaction.expiresAt, + transaction.lockScript, + transaction.sourceStateHash, + ).then((result) => result.status), + ).resolves.toEqual(status); + }); + it('verification fails with invalid transaction hash', async () => { const invalidTransactionHashInclusionProof = new InclusionProof( CertificationData.fromCBOR( @@ -158,7 +188,6 @@ describe('InclusionProof', () => { invalidTransactionHashInclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - REFERENCE_TIME, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), @@ -193,55 +222,113 @@ describe('InclusionProof', () => { inclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - REFERENCE_TIME, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), ).resolves.toEqual(InclusionProofVerificationStatus.NOT_AUTHENTICATED); }); - it('verification fails when the reference time does not match the certified leaf', async () => { - const inclusionProof = new InclusionProof( - certificationData, - REFERENCE_TIME, - inclusionCertificate, - unicityCertificate, - ); + // A leaf cannot postdate the round that certified it, and consensus signs + // that round's timestamp, so a leaf claiming to be newer than its own round + // is an impossible pairing and is rejected. + it('verification fails when the leaf claims a reference time after its certifying round', async () => { + // Same certified root, but the round certifying it reports a clock earlier + // than the leaf claims to have been created at. + const backDatedRound = await createUnicityCertificate(rootHash, signingService, REFERENCE_TIME - 1n); await expect( InclusionProofVerificationRule.verify( trustBase, predicateVerifier, unicityCertificateVerifier, - inclusionProof, + new InclusionProof(certificationData, REFERENCE_TIME, inclusionCertificate, backDatedRound), await transaction.calculateTransactionHash(), transaction.expiresAt, - REFERENCE_TIME + 1n, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), - ).resolves.toEqual(InclusionProofVerificationStatus.REFERENCE_TIME_MISMATCH); + ).resolves.toEqual(InclusionProofVerificationStatus.REFERENCE_TIME_AFTER_ROUND); + }); + + // Documents a gap this rule does NOT close, so that it stays visible and this + // test fails loudly if it is ever closed. + // + // The bound above is one-sided, and the useful direction is the other one. A + // service that receives a request after its deadline can insert the leaf now + // and write a pre-deadline reference time into it: the expiry check passes + // because that value is below the deadline, the bound above passes because + // the certifying round is later still, and the SMT path authenticates the + // value the service chose rather than when it chose it. Closing this needs + // signed evidence of the creation round, which an inclusion proof does not + // carry. + it('accepts a leaf back-dated by a dishonest service, which it cannot detect', async () => { + const deadline = REFERENCE_TIME; + const backDated = deadline - 1n; + const late = await MintTransaction.create(NetworkId.LOCAL, SignaturePredicate.fromSigningService(signingService), { + expiresAt: deadline, + salt: transaction.salt, + tokenType: transaction.tokenType, + }); + const lateCertificationData = await CertificationData.fromMintTransaction(late); + const stateId = await StateId.fromTransaction(late); + + // Built now, but claiming to have been created before the deadline. + const smt = new SparseMerkleTree(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); + await smt.addLeaf(stateId.data, (await calculateLeafValue(lateCertificationData.transactionHash, backDated)).data); + const root = await smt.calculateRoot(); + + await expect( + InclusionProofVerificationRule.verify( + trustBase, + predicateVerifier, + unicityCertificateVerifier, + new InclusionProof( + lateCertificationData, + backDated, + InclusionCertificate.create(root, stateId.data), + // A round certified long after the deadline had passed. + await createUnicityCertificate(root.hash, signingService, deadline + 4000n), + ), + await late.calculateTransactionHash(), + late.expiresAt, + late.lockScript, + late.sourceStateHash, + ).then((result) => result.status), + ).resolves.toEqual(InclusionProofVerificationStatus.OK); }); it('verification fails when the reference time has reached the request timeout', async () => { - const inclusionProof = new InclusionProof( - certificationData, - REFERENCE_TIME, - inclusionCertificate, - unicityCertificate, + // A leaf whose deadline the round it was created in had already reached. + // The deadline is exclusive, so equality is already too late. + const expired = await MintTransaction.create( + NetworkId.LOCAL, + SignaturePredicate.fromSigningService(signingService), + { expiresAt: REFERENCE_TIME, salt: transaction.salt, tokenType: transaction.tokenType }, ); + const expiredCertificationData = await CertificationData.fromMintTransaction(expired); + const smt = new SparseMerkleTree(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); + const stateId = await StateId.fromTransaction(expired); + await smt.addLeaf( + stateId.data, + (await calculateLeafValue(expiredCertificationData.transactionHash, REFERENCE_TIME)).data, + ); + const root = await smt.calculateRoot(); await expect( InclusionProofVerificationRule.verify( trustBase, predicateVerifier, unicityCertificateVerifier, - inclusionProof, - await transaction.calculateTransactionHash(), - transaction.expiresAt, - transaction.expiresAt!, - transaction.lockScript, - transaction.sourceStateHash, + new InclusionProof( + expiredCertificationData, + REFERENCE_TIME, + InclusionCertificate.create(root, stateId.data), + await createUnicityCertificate(root.hash, signingService), + ), + await expired.calculateTransactionHash(), + expired.expiresAt, + expired.lockScript, + expired.sourceStateHash, ).then((result) => result.status), ).resolves.toEqual(InclusionProofVerificationStatus.REQUEST_EXPIRED); }); @@ -262,7 +349,6 @@ describe('InclusionProof', () => { inclusionProof, await transaction.calculateTransactionHash(), transaction.expiresAt, - REFERENCE_TIME, transaction.lockScript, transaction.sourceStateHash, ).then((result) => result.status), diff --git a/tests/unit/transaction/ExpiresAtTest.ts b/tests/unit/transaction/ExpiresAtTest.ts new file mode 100644 index 00000000..6f091185 --- /dev/null +++ b/tests/unit/transaction/ExpiresAtTest.ts @@ -0,0 +1,30 @@ +import { validateExpiresAt } from '../../../src/transaction/ExpiresAt.js'; + +describe('validateExpiresAt', () => { + it('passes through a deadline the wire format can carry', () => { + expect(validateExpiresAt(1755000000n)).toEqual(1755000000n); + expect(validateExpiresAt(1n)).toEqual(1n); + expect(validateExpiresAt(2n ** 64n - 1n)).toEqual(2n ** 64n - 1n); + }); + + it('passes through the absent deadline the service assigns for', () => { + expect(validateExpiresAt(null)).toBeNull(); + }); + + it('rejects a deadline that cannot be encoded as an unsigned integer', () => { + expect(() => validateExpiresAt(-1n)).toThrow('Request deadline must be a positive number of Unix seconds, got -1.'); + }); + + // Zero encodes fine, which is why it used to slip through: it produces a + // request that is expired by construction, because the deadline is exclusive + // and every reference time is at or past it. + it('rejects a deadline of zero', () => { + expect(() => validateExpiresAt(0n)).toThrow('Request deadline must be a positive number of Unix seconds, got 0.'); + }); + + it('rejects a deadline wider than CBOR can carry', () => { + expect(() => validateExpiresAt(2n ** 64n)).toThrow( + 'Request deadline 18446744073709551616 exceeds the largest encodable value 18446744073709551615.', + ); + }); +}); diff --git a/tests/unit/util/InclusionProofUtilsTest.ts b/tests/unit/util/InclusionProofUtilsTest.ts index 701d8a2b..b5d49263 100644 --- a/tests/unit/util/InclusionProofUtilsTest.ts +++ b/tests/unit/util/InclusionProofUtilsTest.ts @@ -1,24 +1,31 @@ import { getEventListeners } from 'node:events'; +import { CertificationData } from '../../../src/api/CertificationData.js'; import { CertificationResponse } from '../../../src/api/CertificationResponse.js'; import { IAggregatorClient } from '../../../src/api/IAggregatorClient.js'; +import { InclusionCertificate } from '../../../src/api/InclusionCertificate.js'; import { InclusionProof } from '../../../src/api/InclusionProof.js'; import { InclusionProofResponse } from '../../../src/api/InclusionProofResponse.js'; import { IRequestOptions } from '../../../src/api/IRequestOptions.js'; import { JsonRpcNetworkError } from '../../../src/api/json-rpc/JsonRpcNetworkError.js'; +import { calculateLeafValue } from '../../../src/api/LeafValue.js'; import { NetworkId } from '../../../src/api/NetworkId.js'; import { StateId } from '../../../src/api/StateId.js'; import { DataHash } from '../../../src/crypto/hash/DataHash.js'; +import { DataHasherFactory } from '../../../src/crypto/hash/DataHasherFactory.js'; import { HashAlgorithm } from '../../../src/crypto/hash/HashAlgorithm.js'; +import { NodeDataHasher } from '../../../src/crypto/hash/NodeDataHasher.js'; import { SigningService } from '../../../src/crypto/secp256k1/SigningService.js'; import { SignaturePredicate } from '../../../src/predicate/builtin/SignaturePredicate.js'; import { PredicateVerifierService } from '../../../src/predicate/verification/PredicateVerifierService.js'; +import { SparseMerkleTree } from '../../../src/smt/radix/SparseMerkleTree.js'; import { StateTransitionClient } from '../../../src/StateTransitionClient.js'; import { MintTransaction } from '../../../src/transaction/MintTransaction.js'; import { TokenSalt } from '../../../src/transaction/TokenSalt.js'; import { TokenType } from '../../../src/transaction/TokenType.js'; import { SleepError, waitInclusionProof } from '../../../src/util/InclusionProofUtils.js'; import { expiresAt } from '../../utils/ExpiresAt.js'; +import { REFERENCE_TIME } from '../../utils/ReferenceTime.js'; import { createRootTrustBase } from '../../utils/RootTrustBaseFixture.js'; import { createUnicityCertificate } from '../../utils/UnicityCertificateFixture.js'; import { createUnicityCertificateVerifier } from '../../utils/UnicityCertificateVerifierFixture.js'; @@ -70,6 +77,7 @@ describe('waitInclusionProof', () => { let transaction: MintTransaction; let pendingProof: InclusionProof; + let certifiedProof: InclusionProof; beforeAll(async () => { transaction = await MintTransaction.create(NetworkId.LOCAL, SignaturePredicate.create(signingService.publicKey), { @@ -77,7 +85,7 @@ describe('waitInclusionProof', () => { salt: TokenSalt.generate(), tokenType: TokenType.generate(), }); - // A proof without an inclusion certificate is what the aggregator returns + // A proof with all three leaf fields absent is what the aggregator returns // for a state it has not certified yet, i.e. "keep polling". pendingProof = new InclusionProof( null, @@ -85,6 +93,19 @@ describe('waitInclusionProof', () => { null, await createUnicityCertificate(new DataHash(HashAlgorithm.SHA256, new Uint8Array(32)), signingService), ); + + // A complete leaf, for the tests below that strip one field back out of it. + const smt = new SparseMerkleTree(new DataHasherFactory(HashAlgorithm.SHA256, NodeDataHasher)); + const stateId = await StateId.fromTransaction(transaction); + const certificationData = await CertificationData.fromMintTransaction(transaction); + await smt.addLeaf(stateId.data, (await calculateLeafValue(certificationData.transactionHash, REFERENCE_TIME)).data); + const root = await smt.calculateRoot(); + certifiedProof = new InclusionProof( + certificationData, + REFERENCE_TIME, + InclusionCertificate.create(root, stateId.data), + await createUnicityCertificate(root.hash, signingService), + ); }); const wait = (aggregatorClient: IAggregatorClient, signal: AbortSignal, interval = 10): Promise => @@ -101,6 +122,43 @@ describe('waitInclusionProof', () => { const notFound = (): Promise => Promise.reject(new JsonRpcNetworkError(404, 'Inclusion proof not found')); + // A proof that is present but structurally impossible used to be answered + // with "keep polling": the wait then ran to its own deadline and reported a + // timeout, naming neither the stripped field nor the service that stripped + // it. + it.each([ + [ + 'certification data', + (proof: InclusionProof): InclusionProof => + new InclusionProof(null, proof.referenceTime, proof.inclusionCertificate, proof.unicityCertificate), + 'MISSING_CERTIFICATION_DATA', + ], + [ + 'the leaf creation time', + (proof: InclusionProof): InclusionProof => + new InclusionProof(proof.certificationData, null, proof.inclusionCertificate, proof.unicityCertificate), + 'MISSING_REFERENCE_TIME', + ], + [ + 'the inclusion certificate', + (proof: InclusionProof): InclusionProof => + new InclusionProof(proof.certificationData, proof.referenceTime, null, proof.unicityCertificate), + 'INCOMPLETE_INCLUSION_PROOF', + ], + ])( + 'should fail rather than poll when a proof is served without %s', + async (_field, strip, status) => { + const client = new StubAggregatorClient(() => + Promise.resolve(new InclusionProofResponse(1n, strip(certifiedProof))), + ); + + await expect(wait(client, AbortSignal.timeout(2000))).rejects.toThrow( + `Invalid inclusion proof status: ${status}`, + ); + }, + 10000, + ); + it('should reject when the deadline fires while a request is in flight', async () => { const started = createDeferred(); const response = createDeferred(); diff --git a/tests/utils/ReferenceTime.ts b/tests/utils/ReferenceTime.ts new file mode 100644 index 00000000..32319940 --- /dev/null +++ b/tests/utils/ReferenceTime.ts @@ -0,0 +1,10 @@ +/** + * Reference time the fixtures pin a certified leaf to. + * + * A real service sets the round's input record timestamp to the very reference + * time its leaves are built from, so a fixture certificate defaults to + * certifying a round with this clock. Pairing a leaf with a round whose + * timestamp precedes it is not something any aggregator can produce, and the + * verification rule now rejects it. + */ +export const REFERENCE_TIME = 1755000000n; diff --git a/tests/utils/TokenUtils.ts b/tests/utils/TokenUtils.ts index ec10f029..8773418c 100644 --- a/tests/utils/TokenUtils.ts +++ b/tests/utils/TokenUtils.ts @@ -66,6 +66,7 @@ export async function transferToken( tokenBytes: Uint8Array, recipient: IPredicate, signingService: SigningService, + expiresAt: bigint | null = null, ): Promise { const token = await Token.fromCBOR(tokenBytes); const result = await token.verify(verificationContext); @@ -74,7 +75,7 @@ export async function transferToken( throw new Error(`Token verification failed: ${result.status}`); } - const transaction = await TransferTransaction.create(token, recipient, StateMask.generate()); + const transaction = await TransferTransaction.create(token, recipient, StateMask.generate(), { expiresAt }); return transferTokenWithTransaction( client, diff --git a/tests/utils/TransitionFlow.ts b/tests/utils/TransitionFlow.ts index 9932c2b6..f8e87ef4 100644 --- a/tests/utils/TransitionFlow.ts +++ b/tests/utils/TransitionFlow.ts @@ -8,47 +8,82 @@ import { MintJustificationVerifierService } from '../../src/transaction/verifica import { TokenIssuanceVerifierService } from '../../src/transaction/verification/TokenIssuanceVerifierService.js'; import { VerificationContext } from '../../src/transaction/verification/VerificationContext.js'; import { VerificationStatus } from '../../src/verification/VerificationStatus.js'; +import { expiresAt } from '../utils/ExpiresAt.js'; import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVerifierFixture.js'; +/** + * The two ways a request gets a deadline. Both reach the aggregator, and only + * the explicit one is recorded in the token, so the flow is run under each. + */ +const DEADLINE_MODES: ReadonlyArray<{ deadline: () => bigint | null; name: string }> = [ + { deadline: (): null => null, name: 'a service-assigned deadline' }, + { deadline: expiresAt, name: 'an explicit deadline' }, +]; + export const transitionFlowTest = (client: StateTransitionClient, trustBase: RootTrustBase): void => { const ALICE_SIGNING_SERVICE = SigningService.generate(); const BOB_SIGNING_SERVICE = SigningService.generate(); const CAROL_SIGNING_SERVICE = SigningService.generate(); describe('Transition', () => { - it('default successful flow', async () => { - const predicateVerifier = PredicateVerifierService.create(); - const verificationContext = new VerificationContext( - trustBase, - predicateVerifier, - createUnicityCertificateVerifier(), - new MintJustificationVerifierService(), - new TokenIssuanceVerifierService(false), - ); - - const targetPredicate = SignaturePredicate.create(ALICE_SIGNING_SERVICE.publicKey); - - const aliceToken = await mintToken(client, verificationContext, targetPredicate, null, trustBase.networkId); - - const bobToken = await transferToken( - client, - verificationContext, - aliceToken.toCBOR(), - SignaturePredicate.create(BOB_SIGNING_SERVICE.publicKey), - ALICE_SIGNING_SERVICE, - ); - - const carolToken = await transferToken( - client, - verificationContext, - bobToken.toCBOR(), - SignaturePredicate.create(CAROL_SIGNING_SERVICE.publicKey), - BOB_SIGNING_SERVICE, - ); - - await expect(carolToken.verify(verificationContext).then((result) => result.status)).resolves.toEqual( - VerificationStatus.OK, - ); - }, 30000); + it.each(DEADLINE_MODES)( + 'default successful flow with $name', + async ({ deadline: chooseDeadline }) => { + // Fixed once: the explicit deadline is derived from the wall clock, and + // every request in the flow has to carry the same value for the + // assertions below to mean anything. + const deadline = chooseDeadline(); + const predicateVerifier = PredicateVerifierService.create(); + const verificationContext = new VerificationContext( + trustBase, + predicateVerifier, + createUnicityCertificateVerifier(), + new MintJustificationVerifierService(), + new TokenIssuanceVerifierService(false), + ); + + const targetPredicate = SignaturePredicate.create(ALICE_SIGNING_SERVICE.publicKey); + + const aliceToken = await mintToken( + client, + verificationContext, + targetPredicate, + null, + trustBase.networkId, + undefined, + undefined, + null, + deadline, + ); + + const bobToken = await transferToken( + client, + verificationContext, + aliceToken.toCBOR(), + SignaturePredicate.create(BOB_SIGNING_SERVICE.publicKey), + ALICE_SIGNING_SERVICE, + deadline, + ); + + const carolToken = await transferToken( + client, + verificationContext, + bobToken.toCBOR(), + SignaturePredicate.create(CAROL_SIGNING_SERVICE.publicKey), + BOB_SIGNING_SERVICE, + deadline, + ); + + // The deadline the requester chose is part of what the transaction hash + // commits to, so it has to survive certification and the token round trip. + expect(carolToken.genesis.expiresAt).toEqual(deadline); + expect(carolToken.transactions.map((transaction) => transaction.expiresAt)).toEqual([deadline, deadline]); + + await expect(carolToken.verify(verificationContext).then((result) => result.status)).resolves.toEqual( + VerificationStatus.OK, + ); + }, + 60000, + ); }); }; diff --git a/tests/utils/UnicityCertificateFixture.ts b/tests/utils/UnicityCertificateFixture.ts index 4ff41d15..b526f7e2 100644 --- a/tests/utils/UnicityCertificateFixture.ts +++ b/tests/utils/UnicityCertificateFixture.ts @@ -1,5 +1,6 @@ import { numberToBytesBE } from '@noble/curves/utils.js'; +import { REFERENCE_TIME } from './ReferenceTime.js'; import { InputRecord } from '../../src/api/bft/InputRecord.js'; import { ShardId } from '../../src/api/bft/ShardId.js'; import { ShardTreeCertificate } from '../../src/api/bft/ShardTreeCertificate.js'; @@ -16,7 +17,7 @@ import { CborSerializer } from '../../src/serialization/cbor/CborSerializer.js'; export async function createUnicityCertificate( rootHash: DataHash, signingService: SigningService, - timestamp: bigint = 0n, + timestamp: bigint = REFERENCE_TIME, ): Promise { const inputRecord = new InputRecord(0n, 0n, null, rootHash.data, new Uint8Array(0), timestamp, null, 0n, null); const technicalRecordHash = null; diff --git a/unicity-token-protocol-spec.md b/unicity-token-protocol-spec.md index daf8375e..4d49f862 100644 --- a/unicity-token-protocol-spec.md +++ b/unicity-token-protocol-spec.md @@ -168,6 +168,24 @@ The inclusion proof works through a Merkle-like authenticated data structure wit The combination of these elements creates a robust system where token states can be provably spent once and only once, without revealing token details to the broader network or requiring individual blockchain transactions for each token transfer. +### 2.7 Request Deadlines + +A commitment submitted to the Unicity Aggregator carries a deadline: the point beyond which the sender no longer wants it committed. The aggregator groups commitments into rounds, and each round is pinned to a single reference time drawn from consensus. A commitment is admitted to a round only when that round's reference time is strictly below the deadline — the deadline is exclusive, so a round whose reference time has reached it is already too late. + +Both the deadline and a round's reference time are **wall-clock instants in Unix seconds**, not round numbers or block heights. The reference time is the timestamp of the consensus seal that certified the preceding round, so it is the root chain's clock rather than any participant's. A sender that derives a deadline from its own clock is therefore comparing against a clock it does not control, and the two can differ by seconds; deadlines are meant to be set with enough margin to absorb that, and a sender with no trustworthy clock omits the deadline entirely (below). + +The deadline exists because submission and commitment are separated in time. A commitment that sits in the queue while conditions change — a price moves, an offer lapses, a counterparty withdraws — should expire rather than execute late. Without a deadline, a sender has no way to bound how long a submitted commitment stays live. + +A sender may supply the deadline explicitly, or leave it to the service: + +- **Explicit.** The deadline is part of what the commitment's transaction hash commits to, so it is carried in the token and re-checked by every later verifier against the reference time the commitment was validated under. A verifier reaching a commitment whose recorded reference time is at or past its deadline rejects it. + +- **Service-assigned.** A sender with no trustworthy clock omits the deadline, and the service derives one from consensus time plus its own configured request lifetime. That value is service metadata: it governs admission, but it is not recorded in the commitment, does not alter the transaction hash, and is not re-checked by a later verifier. + +The reference time a commitment was validated under is a property of the commitment, not of any particular proof of it. The authenticated data structure is append-only, so a commitment can be proven against any later root, and a proof obtained later is anchored to a later round. The reference time recorded with the commitment does not move with it. + +**What a deadline does and does not guarantee.** Admission is enforced by the aggregator at the moment it accepts the commitment. A later verifier can confirm that the recorded reference time is consistent with the commitment and precedes its deadline, but cannot establish when the commitment was actually created — the reference time 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 commitment after its deadline and recorded an earlier reference time would produce a proof that verifies. A deadline is therefore an instruction to an honest service, and the guarantee that a late commitment is dropped rather than executed rests on the same consensus that secures the aggregator, not on the deadline field alone. + ## 3. State Transition Process ### 3.1 Transaction and Transition Structures