From 242d7a1fe460c53c106c21271ede5899c2d293a4 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Tue, 25 Aug 2026 17:26:08 +0200 Subject: [PATCH 1/8] Address review findings on the service-time change Bind the leaf reference time to the signed round it was certified in, and drop the copy of it that travelled beside the proof. The service alone picks a leaf's reference time and the SMT path proves only that it committed to whatever it picked, so on its own that lets a service back-date a leaf and admit a request its deadline should have killed. Consensus signs the round's timestamp, which is that round's reference time, and a leaf cannot predate the round certifying it, so InclusionProofVerificationRule now enforces that as an upper bound. The rule reads the reference time off the proof instead of being handed it. The service records the leaf's creation time on the record and serves that same value for every proof of the leaf, so the copy the certified transactions carried could never legitimately differ from it; it cost a wire element and three consistency checks. Certified mint and transfer arrays are back to two elements and Token.VERSION goes to 2, so a token written by an older SDK is rejected by the version check rather than by a CBOR array-length error further down. The worker wire format loses its separate expiresAt element too and recovers the deadline from the transfer bytes the transaction hash commits to. Also: - Report what a partially present proof is missing rather than folding it into "not certified yet", which left the caller polling to its own deadline and blaming the timeout. Binding a transaction to a proof for an uncertified state reports INCLUSION_CERTIFICATE_MISSING again, the status retry paths branch on. - Validate expiresAt where it is accepted. Negative, zero and oversized deadlines were taken and surfaced much later as a bare CborError from inside the transaction hash. - Restore the version getters on MintTransaction, TransferTransaction and CertificationData; nine other wire types still expose theirs. Tests: - An e2e suite against a real aggregator, with the stack to run it: tests/e2e/docker brings up a BFT root node, mongodb, redis and a pinned aggregator build, driven by scripts/e2e-aggregator.sh. It is not wired into CI. Nothing but a real service can tell whether the SDK and the aggregator still agree on these formats: with the leaf derivation reverted to its pre-change form, the fake-aggregator suite passes whole and the e2e suite fails. - The transition flow now runs under both an explicit and a service-assigned deadline, so the explicit path is exercised end to end rather than only constructed. - The fake aggregator models the service-assigned deadline and SERVICE_NOT_READY, the branches every caller in the repo actually uses. - Fixture certificates now certify a round whose clock matches the leaf, instead of pairing a leaf with a round timestamp of zero. --- README.md | 18 +- package.json | 2 + scripts/e2e-aggregator.sh | 84 ++++++++ src/api/CertificationData.ts | 7 + src/payment/TokenSplit.ts | 4 +- src/transaction/CertifiedMintTransaction.ts | 54 +++-- .../CertifiedTransferTransaction.ts | 54 +++-- src/transaction/ExpiresAt.ts | 36 ++++ src/transaction/MintTransaction.ts | 11 +- src/transaction/Token.ts | 8 +- src/transaction/TransferTransaction.ts | 39 +++- ...ertifiedMintTransactionVerificationRule.ts | 1 - ...fiedTransferTransactionVerificationRule.ts | 1 - .../rule/InclusionProofVerificationRule.ts | 61 ++++-- .../worker/WorkerTokenVerifier.ts | 22 +- src/util/InclusionProofUtils.ts | 14 +- tests/e2e/RequestDeadlineTest.ts | 193 ++++++++++++++++++ tests/e2e/docker/.gitignore | 1 + tests/e2e/docker/docker-compose.yml | 187 +++++++++++++++++ .../CertifiedTransactionWireTest.ts | 154 ++++++++++++++ tests/functional/ExpiresAtTest.ts | 76 +++++++ tests/functional/TestAggregatorClient.ts | 41 +++- tests/functional/WorkerTokenVerifierTest.ts | 53 ++++- tests/functional/payment/SplitBuilderTest.ts | 12 ++ tests/unit/api/CertificationDataTest.ts | 8 +- tests/unit/api/InclusionProofTest.ts | 99 ++++++--- tests/unit/transaction/ExpiresAtTest.ts | 30 +++ tests/unit/util/InclusionProofUtilsTest.ts | 60 +++++- tests/utils/ReferenceTime.ts | 10 + tests/utils/TokenUtils.ts | 3 +- tests/utils/TransitionFlow.ts | 103 +++++++--- tests/utils/UnicityCertificateFixture.ts | 3 +- 32 files changed, 1272 insertions(+), 177 deletions(-) create mode 100755 scripts/e2e-aggregator.sh create mode 100644 src/transaction/ExpiresAt.ts create mode 100644 tests/e2e/RequestDeadlineTest.ts create mode 100644 tests/e2e/docker/.gitignore create mode 100644 tests/e2e/docker/docker-compose.yml create mode 100644 tests/functional/CertifiedTransactionWireTest.ts create mode 100644 tests/unit/transaction/ExpiresAtTest.ts create mode 100644 tests/utils/ReferenceTime.ts diff --git a/README.md b/README.md index ce0703e1..55e6bf90 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,28 @@ 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 end-to-end suite. It talks to a real aggregator — the wire formats it +covers (certification data, the transaction encodings, the inclusion proof and +the reference-time-bound leaf value) are shared with the service, so nothing but +a real service can tell whether the two still agree. + +Bring a local one up first. `scripts/e2e-aggregator.sh` starts the stack in +[`tests/e2e/docker`](./tests/e2e/docker) — a BFT root node, mongodb, redis and a +pinned aggregator build — and waits until consensus is certifying rounds: ```bash +npm run e2e:up +eval "$(./scripts/e2e-aggregator.sh env)" # AGGREGATOR_URL + the generated TRUST_BASE_PATH npm run test:e2e +npm run e2e:down ``` +The e2e suite is not part of CI — it needs a live aggregator — so run it locally +before changing anything on the wire. `e2e:down` also deletes the generated +genesis, so the next `e2e:up` starts from a clean chain. Without `AGGREGATOR_URL` and `TRUST_BASE_PATH` the suite falls +back to `http://localhost:3000` and the checked-in trust base, which only match +an aggregator someone else started. + To run it against another network, point it at that endpoint and supply the matching trust base: ```bash diff --git a/package.json b/package.json index dbe2fa2c..3fb2f624 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "build:check": "tsc --noEmit", + "e2e:up": "./scripts/e2e-aggregator.sh up", + "e2e:down": "./scripts/e2e-aggregator.sh down", "lint": "eslint \"src/**/*\" \"tests/**/*\"", "lint:fix": "eslint \"src/**/*\" \"tests/**/*\" --fix", "test": "npm run-script test:unit", diff --git a/scripts/e2e-aggregator.sh b/scripts/e2e-aggregator.sh new file mode 100755 index 00000000..4d48c253 --- /dev/null +++ b/scripts/e2e-aggregator.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Bring the local aggregator stack in tests/e2e/docker up or down. +# +# scripts/e2e-aggregator.sh up start the stack and wait until it certifies +# scripts/e2e-aggregator.sh down stop it and delete the generated genesis +# scripts/e2e-aggregator.sh logs follow the aggregator log +# scripts/e2e-aggregator.sh env print the exports the e2e suite needs +# +# `up` blocks until consensus has produced at least one block, because until +# then the aggregator answers every certification request with +# SERVICE_NOT_READY rather than certifying it. + +set -euo pipefail + +readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly COMPOSE_DIR="${REPO_ROOT}/tests/e2e/docker" +readonly DATA_DIR="${COMPOSE_DIR}/data" +readonly TRUST_BASE_PATH="${DATA_DIR}/genesis/trust-base.json" +readonly AGGREGATOR_PORT="${AGGREGATOR_PORT:-3000}" +readonly AGGREGATOR_URL="http://localhost:${AGGREGATOR_PORT}" + +# The stack runs as the invoking user so the genesis files it writes into the +# bind mounts stay readable and removable from the host. +export USER_UID="${USER_UID:-$(id -u)}" +export USER_GID="${USER_GID:-$(id -g)}" + +compose() { + docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" "$@" +} + +# Block height climbs only once consensus is certifying rounds, which is also +# when the aggregator starts handing out a non-zero reference time. +block_height() { + curl -sf -m 5 -X POST "${AGGREGATOR_URL}" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"get_block_height","params":{}}' 2>/dev/null | + grep -o '"blockNumber":"[0-9]*"' | head -1 | cut -d'"' -f4 +} + +wait_for_certification() { + local deadline=$((SECONDS + 180)) + while ((SECONDS < deadline)); do + local height + height="$(block_height || true)" + if [[ -n "${height}" && "${height}" != "0" ]]; then + echo "Aggregator certifying at block ${height}." + return 0 + fi + sleep 2 + done + + echo "Aggregator did not reach a certifying round within 180s." >&2 + compose logs --tail 50 aggregator >&2 + return 1 +} + +case "${1:-up}" in +up) + mkdir -p "${DATA_DIR}/genesis" "${DATA_DIR}/genesis-root" + compose up -d --wait + wait_for_certification + echo + echo "Run the e2e suite with:" + echo " $(printf '%q' "${BASH_SOURCE[0]}") env" + ;; +down) + compose down -v --remove-orphans + # Genesis is regenerated on the next `up`; leaving it behind pins the stack + # to keys the fresh mongodb/redis volumes no longer have any state for. + rm -rf "${DATA_DIR}" + ;; +logs) + compose logs -f aggregator + ;; +env) + echo "export AGGREGATOR_URL=${AGGREGATOR_URL}" + echo "export TRUST_BASE_PATH=${TRUST_BASE_PATH}" + ;; +*) + echo "usage: $0 {up|down|logs|env}" >&2 + exit 1 + ;; +esac 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/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/transaction/CertifiedMintTransaction.ts b/src/transaction/CertifiedMintTransaction.ts index 059308af..082dd48b 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, ) {} @@ -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. + * + * 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..eb0491e4 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, ) {} @@ -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. + * + * 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..426650d8 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, 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..97fba0bc 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', @@ -43,7 +44,6 @@ export class InclusionProofVerificationRule { * @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 {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', @@ -99,20 +126,24 @@ export class InclusionProofVerificationRule { 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) { + // The service alone picks the leaf's reference time, and the SMT path proves + // only that it committed to whatever it picked — on its own that lets a + // service back-date a leaf and admit a request its deadline should have + // killed. Consensus signs the round's own timestamp, which is that round's + // reference time, and a leaf cannot predate the round that certifies it, so + // this is a signed upper bound available for free. The tree is append-only, + // so a proof re-fetched later is certified by a later round and the bound + // only loosens. + 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/e2e/RequestDeadlineTest.ts b/tests/e2e/RequestDeadlineTest.ts new file mode 100644 index 00000000..38329513 --- /dev/null +++ b/tests/e2e/RequestDeadlineTest.ts @@ -0,0 +1,193 @@ +import { createE2EContext } from './E2EConfig.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 these tests can tell whether the SDK and the + * service agree. The stack is brought up by scripts/e2e-aggregator.sh. + */ +describe('E2E request deadline', () => { + const { aggregatorClient, client, trustBase } = createE2EContext(); + 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 an earlier round, which is + // how a service would back-date a leaf to slip a request past its + // deadline. Consensus signs the round timestamp, so it cannot. + 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/e2e/docker/.gitignore b/tests/e2e/docker/.gitignore new file mode 100644 index 00000000..8fce6030 --- /dev/null +++ b/tests/e2e/docker/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/tests/e2e/docker/docker-compose.yml b/tests/e2e/docker/docker-compose.yml new file mode 100644 index 00000000..1c4fd035 --- /dev/null +++ b/tests/e2e/docker/docker-compose.yml @@ -0,0 +1,187 @@ +# Local aggregator stack the e2e 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. +# +# Bring it up with scripts/e2e-aggregator.sh, which creates the writable +# genesis directories this file mounts and waits for consensus to produce a +# reference time. + +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-0c7f70b} + 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" + MONGODB_CONNECT_TIMEOUT: "10s" + MONGODB_SERVER_SELECTION_TIMEOUT: "5s" + + 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/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/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..383100d6 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,68 @@ 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, - ); + // The service alone chooses a leaf's reference time, so without an + // independent bound it could back-date one and slip a request past its own + // deadline. Consensus signs the round's timestamp and a leaf cannot predate + // the round certifying it, so a leaf claiming to be newer than its round 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); }); 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 +304,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; From 3fb2a4bca3cd8bfb4f070e36ebe092998e884bac Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Tue, 25 Aug 2026 18:00:06 +0200 Subject: [PATCH 2/8] Correct what the round-timestamp bound actually guarantees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bound added in the previous commit was described as preventing a service from back-dating a leaf to slip a request past its deadline. It does not, and Codex was right to flag it: it bounds the reference time from above, and the attack needs a bound from below. A service that receives a request after its deadline T can insert the leaf now and write referenceTime = T - 1 into it. The expiry check passes because T - 1 is below the deadline, the new bound passes because the certifying round's timestamp is later still, and the SMT path authenticates the value the service chose rather than the moment it chose it. Verified: with a deadline of 1000, a leaf back-dated to 999 and a round clock of 5000, the rule returns OK. The check stays, because a leaf postdating the round that certified it is an impossible pairing worth rejecting and the bound is free — it already caught a fixture that paired a leaf with a round timestamp of zero. Only the claim about it changes. - Rewrite the rule's comment to say what it does establish, and to state plainly that it does not bound back-dating and why. - Add a test pinning the gap, so it stays visible in the codebase and fails loudly if the protocol ever closes it. - Correct the same claim in the unit and e2e test comments. - Document in README.md that expiresAt is enforced by an honest service at admission and is not something a verifier can prove after the fact. Closing this properly needs signed evidence of the creation round, which an inclusion proof does not carry — an aggregator-side protocol change, not something this SDK can do on its own. --- README.md | 21 +++++++ .../rule/InclusionProofVerificationRule.ts | 23 +++++--- tests/e2e/RequestDeadlineTest.ts | 7 ++- tests/unit/api/InclusionProofTest.ts | 55 +++++++++++++++++-- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 55e6bf90..4a078a16 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,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 diff --git a/src/transaction/verification/rule/InclusionProofVerificationRule.ts b/src/transaction/verification/rule/InclusionProofVerificationRule.ts index 97fba0bc..8d110b19 100644 --- a/src/transaction/verification/rule/InclusionProofVerificationRule.ts +++ b/src/transaction/verification/rule/InclusionProofVerificationRule.ts @@ -126,14 +126,21 @@ export class InclusionProofVerificationRule { return new VerificationResult('InclusionProofVerificationRule', InclusionProofVerificationStatus.REQUEST_EXPIRED); } - // The service alone picks the leaf's reference time, and the SMT path proves - // only that it committed to whatever it picked — on its own that lets a - // service back-date a leaf and admit a request its deadline should have - // killed. Consensus signs the round's own timestamp, which is that round's - // reference time, and a leaf cannot predate the round that certifies it, so - // this is a signed upper bound available for free. The tree is append-only, - // so a proof re-fetched later is certified by a later round and the bound - // only loosens. + // 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', diff --git a/tests/e2e/RequestDeadlineTest.ts b/tests/e2e/RequestDeadlineTest.ts index 38329513..e75a6796 100644 --- a/tests/e2e/RequestDeadlineTest.ts +++ b/tests/e2e/RequestDeadlineTest.ts @@ -124,9 +124,10 @@ describe('E2E request deadline', () => { transaction.toCertifiedTransaction(trustBase, predicateVerifier, unicityCertificateVerifier, proof), ).resolves.toBeDefined(); - // And reject the same leaf presented against an earlier round, which is - // how a service would back-date a leaf to slip a request past its - // deadline. Consensus signs the round timestamp, so it cannot. + // 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, diff --git a/tests/unit/api/InclusionProofTest.ts b/tests/unit/api/InclusionProofTest.ts index 383100d6..029437c8 100644 --- a/tests/unit/api/InclusionProofTest.ts +++ b/tests/unit/api/InclusionProofTest.ts @@ -228,11 +228,9 @@ describe('InclusionProof', () => { ).resolves.toEqual(InclusionProofVerificationStatus.NOT_AUTHENTICATED); }); - // The service alone chooses a leaf's reference time, so without an - // independent bound it could back-date one and slip a request past its own - // deadline. Consensus signs the round's timestamp and a leaf cannot predate - // the round certifying it, so a leaf claiming to be newer than its round is - // rejected. + // 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. @@ -252,6 +250,53 @@ describe('InclusionProof', () => { ).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 () => { // A leaf whose deadline the round it was created in had already reached. // The deadline is exclusive, so equality is already too late. From 4b40222cff7921eff2bb0e4cd4cf42a7571164c5 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 15:33:58 +0200 Subject: [PATCH 3/8] Call the local-stack suite integration, and update the aggregator image These tests are not end-to-end. They bring up their own aggregator in docker and talk to nothing outside it, which is what an integration suite is; e2e here means the deployed testnet, and that suite already exists and stays as it is. - tests/e2e/docker -> tests/integration/docker, and the request-deadline suite with it. scripts/e2e-aggregator.sh -> integration-aggregator.sh, and the npm scripts follow: integration:up, integration:down, test:integration. test:ci ignores the new directory. - The integration suite finds its own stack: it defaults to localhost and to the genesis the root node generates on first start, so it needs no environment setup, and it says so rather than failing obscurely when the stack was never started. The e2e suite keeps taking an endpoint and a trust base, since it has no service of its own. - Run the transition flow against the local stack too, under both deadline modes, so the flow is covered without a deployed network. Aggregator image moves to sha-ae08165, which adds request/state-ID, certified-shard and network bindings to its own reference verifier. The CBOR wire format is unchanged, and the SDK already had all three: the network binding at UnicityCertificateVerifier.ts:72, the shard binding in ShardIdMatchesStateIdRule, and the state-ID binding by deriving it from the certification data. Nothing to add; 11/11 pass against it. The image also surfaced a cold-start race: the aggregator gives up creating its indexes while the fresh replica set is still electing, then restarts into a working state, which `compose up --wait` reports as a failure. Its mongo timeouts are raised to ride out the election, and `up` treats certification, not `--wait`, as the gate. --- README.md | 43 ++++++++------- package.json | 7 +-- ...ggregator.sh => integration-aggregator.sh} | 25 +++++---- tests/integration/IntegrationConfig.ts | 53 +++++++++++++++++++ .../RequestDeadlineTest.ts | 11 ++-- tests/integration/TransitionFlowTest.ts | 8 +++ tests/{e2e => integration}/docker/.gitignore | 0 .../docker/docker-compose.yml | 13 +++-- 8 files changed, 119 insertions(+), 41 deletions(-) rename scripts/{e2e-aggregator.sh => integration-aggregator.sh} (65%) create mode 100644 tests/integration/IntegrationConfig.ts rename tests/{e2e => integration}/RequestDeadlineTest.ts (96%) create mode 100644 tests/integration/TransitionFlowTest.ts rename tests/{e2e => integration}/docker/.gitignore (100%) rename tests/{e2e => integration}/docker/docker-compose.yml (93%) diff --git a/README.md b/README.md index 4a078a16..003a95fb 100644 --- a/README.md +++ b/README.md @@ -136,37 +136,42 @@ Run the example flows (requires a reachable aggregator; URL is read from each ex npm run test:examples ``` -Run the end-to-end suite. It talks to a real aggregator — the wire formats it -covers (certification data, the transaction encodings, the inclusion proof and -the reference-time-bound leaf value) are shared with the service, so nothing but -a real service can tell whether the two still agree. - -Bring a local one up first. `scripts/e2e-aggregator.sh` starts the stack in -[`tests/e2e/docker`](./tests/e2e/docker) — a BFT root node, mongodb, redis and a -pinned aggregator build — and waits until consensus is certifying rounds: +Run the integration suite. It talks to a real aggregator, but owns the one it +talks to: `scripts/integration-aggregator.sh` starts the stack in +[`tests/integration/docker`](./tests/integration/docker) — a BFT root node, +mongodb, redis and a pinned aggregator build — and waits until consensus is +certifying. Nothing external is involved. ```bash -npm run e2e:up -eval "$(./scripts/e2e-aggregator.sh env)" # AGGREGATOR_URL + the generated TRUST_BASE_PATH -npm run test:e2e -npm run e2e:down +npm run integration:up +npm run test:integration +npm run integration:down ``` -The e2e suite is not part of CI — it needs a live aggregator — so run it locally -before changing anything on the wire. `e2e:down` also deletes the generated -genesis, so the next `e2e:up` starts from a clean chain. Without `AGGREGATOR_URL` and `TRUST_BASE_PATH` the suite falls -back to `http://localhost:3000` and the checked-in trust base, which only match -an aggregator someone else started. +The suite finds the stack on its own: it defaults to `http://localhost:3000` and +to the trust base the root node generates on first start, so no environment +setup is needed. `integration:down` deletes that genesis, so the next +`integration:up` starts from a clean chain. -To run it against another network, point it at that endpoint and supply the matching trust base: +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 it before changing anything on the wire. + +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 ``` +Neither suite runs in CI — one needs a docker stack, the other a live network. + ### Linting Lint all code (source and tests): diff --git a/package.json b/package.json index 3fb2f624..18dc398f 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,16 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "build:check": "tsc --noEmit", - "e2e:up": "./scripts/e2e-aggregator.sh up", - "e2e:down": "./scripts/e2e-aggregator.sh down", + "integration:up": "./scripts/integration-aggregator.sh up", + "integration:down": "./scripts/integration-aggregator.sh down", "lint": "eslint \"src/**/*\" \"tests/**/*\"", "lint:fix": "eslint \"src/**/*\" \"tests/**/*\" --fix", "test": "npm run-script test:unit", "test:unit": "jest --testPathPatterns=tests/unit --testPathPatterns=tests/functional", "test:e2e": "jest --testPathPatterns=tests/e2e", + "test:integration": "jest --testPathPatterns=tests/integration", "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", diff --git a/scripts/e2e-aggregator.sh b/scripts/integration-aggregator.sh similarity index 65% rename from scripts/e2e-aggregator.sh rename to scripts/integration-aggregator.sh index 4d48c253..eadfeec4 100755 --- a/scripts/e2e-aggregator.sh +++ b/scripts/integration-aggregator.sh @@ -1,20 +1,24 @@ #!/usr/bin/env bash # -# Bring the local aggregator stack in tests/e2e/docker up or down. +# Bring the local aggregator stack in tests/integration/docker up or down. # -# scripts/e2e-aggregator.sh up start the stack and wait until it certifies -# scripts/e2e-aggregator.sh down stop it and delete the generated genesis -# scripts/e2e-aggregator.sh logs follow the aggregator log -# scripts/e2e-aggregator.sh env print the exports the e2e suite needs +# scripts/integration-aggregator.sh up start the stack and wait until it certifies +# scripts/integration-aggregator.sh down stop it and delete the generated genesis +# scripts/integration-aggregator.sh logs follow the aggregator log +# scripts/integration-aggregator.sh env print overrides for pointing a suite here # # `up` blocks until consensus has produced at least one block, because until # then the aggregator answers every certification request with # SERVICE_NOT_READY rather than certifying it. +# +# The integration suite finds this stack on its own — it defaults to localhost +# and to the genesis written below — so `env` is only needed to point the e2e +# suite, or another tool, at it. set -euo pipefail readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -readonly COMPOSE_DIR="${REPO_ROOT}/tests/e2e/docker" +readonly COMPOSE_DIR="${REPO_ROOT}/tests/integration/docker" readonly DATA_DIR="${COMPOSE_DIR}/data" readonly TRUST_BASE_PATH="${DATA_DIR}/genesis/trust-base.json" readonly AGGREGATOR_PORT="${AGGREGATOR_PORT:-3000}" @@ -58,11 +62,14 @@ wait_for_certification() { case "${1:-up}" in up) mkdir -p "${DATA_DIR}/genesis" "${DATA_DIR}/genesis-root" - compose up -d --wait + # `--wait` is a convenience, not the gate: the aggregator has a restart policy, + # and a container that exits once and comes back healthy fails `--wait` while + # ending up perfectly usable. wait_for_certification below is the real check, + # and it tolerates a restart because it polls for the outcome we need. + compose up -d --wait || echo "Some services reported unhealthy on start; waiting for certification anyway." wait_for_certification echo - echo "Run the e2e suite with:" - echo " $(printf '%q' "${BASH_SOURCE[0]}") env" + echo "Run the integration suite with: npm run test:integration" ;; down) compose down -v --remove-orphans diff --git a/tests/integration/IntegrationConfig.ts b/tests/integration/IntegrationConfig.ts new file mode 100644 index 00000000..411238ef --- /dev/null +++ b/tests/integration/IntegrationConfig.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +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'; + +/** + * The aggregator stack in ./docker, started by scripts/integration-aggregator.sh. + * + * Unlike the e2e suite, which is pointed at a deployed network, this one owns + * its service: the stack runs locally with no external dependency, and its + * trust base is generated per run by the BFT root node it brings up. Both are + * overridable, but the defaults are meant to need no setup beyond + * `npm run integration:up`. + */ +export const AGGREGATOR_URL = process.env.AGGREGATOR_URL ?? 'http://localhost:3000'; + +/** Genesis the local stack writes on first start. */ +const GENERATED_TRUST_BASE = path.join(__dirname, 'docker/data/genesis/trust-base.json'); + +/** + * Build the client and trust base the integration suite runs against. + * + * @returns {object} Aggregator client, state transition client and trust base for the local stack. + * @throws {Error} If the stack's genesis is missing, which means it was never started. + */ +export function createIntegrationContext(): { + aggregatorClient: IAggregatorClient; + client: StateTransitionClient; + trustBase: RootTrustBase; +} { + const trustBasePath = process.env.TRUST_BASE_PATH ?? GENERATED_TRUST_BASE; + + let trustBaseJson: string; + try { + trustBaseJson = readFileSync(trustBasePath, 'utf-8'); + } catch (cause) { + throw new Error( + `No trust base at ${trustBasePath}. Start the local aggregator stack with \`npm run integration:up\` first.`, + { cause }, + ); + } + + const aggregatorClient = new AggregatorClient(AGGREGATOR_URL, process.env.AGGREGATOR_API_KEY ?? null); + + return { + aggregatorClient, + client: new StateTransitionClient(aggregatorClient), + trustBase: RootTrustBase.fromJSON(JSON.parse(trustBaseJson)), + }; +} diff --git a/tests/e2e/RequestDeadlineTest.ts b/tests/integration/RequestDeadlineTest.ts similarity index 96% rename from tests/e2e/RequestDeadlineTest.ts rename to tests/integration/RequestDeadlineTest.ts index e75a6796..b7559064 100644 --- a/tests/e2e/RequestDeadlineTest.ts +++ b/tests/integration/RequestDeadlineTest.ts @@ -1,4 +1,4 @@ -import { createE2EContext } from './E2EConfig.js'; +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'; @@ -18,11 +18,12 @@ import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVer * * The functional suite covers the same ground against * {@link ../functional/TestAggregatorClient.js}, which derives leaf values with - * the very code under test; only these tests can tell whether the SDK and the - * service agree. The stack is brought up by scripts/e2e-aggregator.sh. + * the very code under test; only a real service can tell whether the SDK and + * the aggregator still agree. The stack is brought up by + * scripts/integration-aggregator.sh. */ -describe('E2E request deadline', () => { - const { aggregatorClient, client, trustBase } = createE2EContext(); +describe('Integration request deadline', () => { + const { aggregatorClient, client, trustBase } = createIntegrationContext(); const predicateVerifier = PredicateVerifierService.create(); const unicityCertificateVerifier = createUnicityCertificateVerifier(); 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/e2e/docker/.gitignore b/tests/integration/docker/.gitignore similarity index 100% rename from tests/e2e/docker/.gitignore rename to tests/integration/docker/.gitignore diff --git a/tests/e2e/docker/docker-compose.yml b/tests/integration/docker/docker-compose.yml similarity index 93% rename from tests/e2e/docker/docker-compose.yml rename to tests/integration/docker/docker-compose.yml index 1c4fd035..d1bbbd71 100644 --- a/tests/e2e/docker/docker-compose.yml +++ b/tests/integration/docker/docker-compose.yml @@ -1,11 +1,11 @@ -# Local aggregator stack the e2e suite runs against. +# 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. # -# Bring it up with scripts/e2e-aggregator.sh, which creates the writable +# Bring it up with scripts/integration-aggregator.sh, which creates the writable # genesis directories this file mounts and waits for consensus to produce a # reference time. @@ -108,7 +108,7 @@ services: start_period: 5s aggregator: - image: ghcr.io/unicitynetwork/aggregator-go:${AGGREGATOR_IMAGE_TAG:-sha-0c7f70b} + image: ghcr.io/unicitynetwork/aggregator-go:${AGGREGATOR_IMAGE_TAG:-sha-ae08165} restart: on-failure ports: - "${AGGREGATOR_PORT:-3000}:3000" @@ -122,8 +122,11 @@ services: MONGODB_URI: "mongodb://mongodb:27017/aggregator?replicaSet=rs0&directConnection=true" MONGODB_DATABASE: "aggregator" - MONGODB_CONNECT_TIMEOUT: "10s" - MONGODB_SERVER_SELECTION_TIMEOUT: "5s" + # 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" From 683d32c6c5c4712f79c670f258520f48ce0a7306 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 16:16:44 +0200 Subject: [PATCH 4/8] Let Testcontainers own the aggregator stack, and run it in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack was started by a shell script and stopped by another, which works on a laptop and not much else: the port was fixed, so two runs collided, and a failed run left containers behind. Testcontainers starts it from the same compose file, publishes the aggregator on an ephemeral port, and tears it down when the run ends. npm run test:integration is now the whole thing — no environment, no separate up and down. It costs a cold start of about a minute, so the script stays for the case it was actually good at: start the stack once, point the suite at it with `eval "$(./scripts/integration-aggregator.sh env)"`, and iterate at ~20s a run. A stack the run did not start is also one it does not stop. Waiting for the container to be healthy is not enough to run against: until consensus hands the aggregator a reference time it answers every certification request with SERVICE_NOT_READY, so setup blocks on a certified block rather than on the healthcheck. The support files are .mjs because the babel transform would otherwise rewrite globalSetup to CJS and then fail to require its own ESM imports; that also puts them outside the TS project, so they join tests/utils/*.mjs in the lint ignores. CI gets an integration job. It has no external dependency now — the ephemeral port means concurrent jobs cannot collide, and nothing is left running for the next one to trip over. The e2e suite stays out, since it needs a deployed network to be pointed at. --- .github/workflows/npm.yml | 26 + README.md | 28 +- eslint.config.js | 2 +- jest.integration.config.js | 19 + package-lock.json | 1291 ++++++++++++++++- package.json | 3 +- tests/integration/support/aggregatorStack.mjs | 122 ++ tests/integration/support/globalSetup.mjs | 20 + tests/integration/support/globalTeardown.mjs | 11 + 9 files changed, 1501 insertions(+), 21 deletions(-) create mode 100644 jest.integration.config.js create mode 100644 tests/integration/support/aggregatorStack.mjs create mode 100644 tests/integration/support/globalSetup.mjs create mode 100644 tests/integration/support/globalTeardown.mjs diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 0391569a..0e0047da 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -63,3 +63,29 @@ jobs: env: CI: true RELEASE_TAG: ${{ github.event.release.tag_name }} + + 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 diff --git a/README.md b/README.md index 003a95fb..d11e6133 100644 --- a/README.md +++ b/README.md @@ -137,27 +137,32 @@ npm run test:examples ``` Run the integration suite. It talks to a real aggregator, but owns the one it -talks to: `scripts/integration-aggregator.sh` starts the stack in +talks to: Testcontainers starts the stack in [`tests/integration/docker`](./tests/integration/docker) — a BFT root node, -mongodb, redis and a pinned aggregator build — and waits until consensus is -certifying. Nothing external is involved. +mongodb, redis and a pinned aggregator build — waits for consensus to certify a +round, and tears it down afterwards. Nothing external is involved, and no setup +is needed: ```bash -npm run integration:up npm run test:integration -npm run integration:down ``` -The suite finds the stack on its own: it defaults to `http://localhost:3000` and -to the trust base the root node generates on first start, so no environment -setup is needed. `integration:down` deletes that genesis, so the next -`integration:up` starts from a clean chain. +That pays a cold start of roughly a minute per run. While iterating, start the +stack once and point the suite at it — it reuses a stack it did not start, and +leaves it running: + +```bash +npm run integration:up +eval "$(./scripts/integration-aggregator.sh env)" +npm run test:integration # ~20s +npm run integration:down +``` 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 it before changing anything on the wire. +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 @@ -170,7 +175,8 @@ AGGREGATOR_API_KEY= \ npm run test:e2e ``` -Neither suite runs in CI — one needs a docker stack, the other a live network. +The integration suite runs in CI; the e2e suite does not, since it needs a live +network to be pointed at. ### Linting 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..a1814ebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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,13 +4740,68 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", + "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", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "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 18dc398f..ec258d21 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "npm run-script test:unit", "test:unit": "jest --testPathPatterns=tests/unit --testPathPatterns=tests/functional", "test:e2e": "jest --testPathPatterns=tests/e2e", - "test:integration": "jest --testPathPatterns=tests/integration", + "test:integration": "jest --config jest.integration.config.js", "test:examples": "jest --testPathPatterns=tests/examples", "test:ci": "jest --testPathPatterns=tests --testPathIgnorePatterns=tests/e2e --testPathIgnorePatterns=tests/integration --testPathIgnorePatterns=tests/examples --ci --reporters=default" }, @@ -55,6 +55,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/tests/integration/support/aggregatorStack.mjs b/tests/integration/support/aggregatorStack.mjs new file mode 100644 index 00000000..bbfc5c53 --- /dev/null +++ b/tests/integration/support/aggregatorStack.mjs @@ -0,0 +1,122 @@ +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. + * + * A caller that has already started one — `npm run integration:up`, or a shared + * service in some other environment — sets AGGREGATOR_URL, and this reuses it + * rather than starting a second. That keeps the edit-run loop fast: the stack + * takes the better part of a minute to reach its first certified round, and + * there is no reason to pay that per run while iterating on a test. + * + * @returns {Promise<{stop: () => Promise, trustBasePath: string, url: string}>} The running stack. + */ +export async function startAggregatorStack() { + if (process.env.AGGREGATOR_URL) { + const url = process.env.AGGREGATOR_URL; + await waitForCertification(url); + + return { + stop: () => Promise.resolve(), + trustBasePath: process.env.TRUST_BASE_PATH ?? TRUST_BASE_PATH, + url, + }; + } + + // 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(); +} From 50e0c162113fcd86cf1922210f1e7edc9984f11b Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 16:50:04 +0200 Subject: [PATCH 5/8] Gate npm publishing on the integration job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish steps lived in the `build` job, and `integration` was a sibling with no dependency between them. GitHub runs jobs without `needs` independently, so on a push to main `build` published as soon as its own steps went green — whether or not the integration suite had finished, or passed. Adding that job made the workflow look like it gated releases while it did not, and an npm publish cannot be taken back. Publishing moves into its own job with `needs: [build, integration]`. The event conditions stay where they were and now only choose which kind of release this is; `needs` is what gates it. Two things fall out of the split. `id-token: write` is needed only to publish, so the two test jobs no longer request it. And the publish job re-runs `npm ci` and `npm run build`, because jobs do not share a filesystem and `files` in package.json ships `lib/`. The `build` job keeps its bare name, so the check name PRs have always reported does not change under anyone's branch protection. Reported by Codex on #147. --- .github/workflows/npm.yml | 85 ++++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 0e0047da..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,29 +38,6 @@ 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) - 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);") - SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) - npm --no-git-tag-version version $NPM_PACKAGE_VERSION-rc.$SHORT_SHA - npm publish --access public --tag dev - env: - CI: true - GITHUB_SHA: ${{ github.sha }} - - - name: Publish on release - if: github.event_name == 'release' - run: | - VERSION="${RELEASE_TAG#v}" - echo "Publishing version $VERSION from release tag $RELEASE_TAG" - npm --no-git-tag-version version "$VERSION" --allow-same-version - npm publish --access public - env: - CI: true - RELEASE_TAG: ${{ github.event.release.tag_name }} - integration: name: Integration against a real aggregator runs-on: ubuntu-latest @@ -89,3 +63,58 @@ jobs: # 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);") + SHORT_SHA=$(echo $GITHUB_SHA | cut -c1-7) + npm --no-git-tag-version version $NPM_PACKAGE_VERSION-rc.$SHORT_SHA + npm publish --access public --tag dev + env: + CI: true + GITHUB_SHA: ${{ github.sha }} + + - name: Publish on release + if: github.event_name == 'release' + run: | + VERSION="${RELEASE_TAG#v}" + echo "Publishing version $VERSION from release tag $RELEASE_TAG" + npm --no-git-tag-version version "$VERSION" --allow-same-version + npm publish --access public + env: + CI: true + RELEASE_TAG: ${{ github.event.release.tag_name }} From 4d90a78c80af1e9aee870cf5bafb895956fe3d55 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 17:15:23 +0200 Subject: [PATCH 6/8] Release as 3.0.0 and document the break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formats this branch changes are shared with the Unicity Service, and they move in both directions at once: a token minted by 2.x no longer loads, and a 3.0 client and a 2.x-era aggregator cannot verify each other's proofs. Token, MintTransaction, TransferTransaction and CertificationData all go to wire version 2, and the certified leaf value becomes SHA-256(CBOR([transactionHash, referenceTime])) rather than the transaction hash alone. Under semver that is a major, and the package is published with real consumers on 2.1.0, so `^2.1.0` must not resolve to it. README gains an "Upgrading to 3.0" section covering what stops working — existing tokens, aggregator compatibility, and the compile-time breaks for anyone building on the verification internals — and a section on request deadlines, which had no user-facing documentation at all despite being the feature this release exists for. The protocol spec gains a section on deadlines in its own conceptual register: what a round's reference time is, why admission is exclusive, how an explicit deadline differs from a service-assigned one, and what a deadline does not guarantee against a dishonest service. Note the rest of that spec is stale independently of this change — it still describes Request IDs and Authenticators, which the code has not called that since 2.0. Refreshing it is its own piece of work. --- README.md | 56 ++++++++++++++++++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- unicity-token-protocol-spec.md | 16 ++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d11e6133..ad795e9e 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,32 @@ 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, +}); +``` + +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 diff --git a/package-lock.json b/package-lock.json index a1814ebe..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", diff --git a/package.json b/package.json index ec258d21..06d7f8cb 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": { diff --git a/unicity-token-protocol-spec.md b/unicity-token-protocol-spec.md index daf8375e..1bf0c4e3 100644 --- a/unicity-token-protocol-spec.md +++ b/unicity-token-protocol-spec.md @@ -168,6 +168,22 @@ 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. + +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 From 0c0b00b0125a0ee25d866810aec0d7882eba315e Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 17:29:00 +0200 Subject: [PATCH 7/8] State the units on expiresAt and reference time everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The option interfaces a caller writes to said "Unix seconds", but the getters a caller reads did not, and neither did the verification rule, the predicate verifier, the leaf value helper or the protocol spec. Both values are wall-clock instants, and both could plausibly have been round numbers or block heights to someone reading only the types — they are bigints named after time, sitting next to an InputRecord that really does carry a round number. Units added to the expiresAt getters on CertifiedMintTransaction, CertifiedTransferTransaction and TransferTransaction, and to the six places reference time is documented. The comparison itself now records something that was written down nowhere: both sides are consensus time, not the caller's. A round's reference time is the timestamp of the BFT seal, so a deadline derived from a local clock is compared against the root chain's, and the two differ by seconds. README says the same and adds the practical consequence — leave margin for skew and queue time; hour-scale deadlines do not care, second-scale ones do. The spec section says it too, and now states that neither value is a round number or block height. --- README.md | 7 +++++++ src/api/InclusionProof.ts | 2 +- src/api/LeafValue.ts | 2 +- .../builtin/verification/IBuiltInPredicateVerifier.ts | 2 +- src/predicate/verification/PredicateVerifierService.ts | 2 +- src/transaction/CertifiedMintTransaction.ts | 4 ++-- src/transaction/CertifiedTransferTransaction.ts | 4 ++-- src/transaction/TransferTransaction.ts | 2 +- .../verification/rule/InclusionProofVerificationRule.ts | 7 ++++++- unicity-token-protocol-spec.md | 2 ++ 10 files changed, 24 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ad795e9e..51bc1b83 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,13 @@ const transaction = await MintTransaction.create(networkId, recipient, { }); ``` +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 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/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 082dd48b..1252794f 100644 --- a/src/transaction/CertifiedMintTransaction.ts +++ b/src/transaction/CertifiedMintTransaction.ts @@ -40,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; @@ -75,7 +75,7 @@ export class CertifiedMintTransaction implements ITransaction { } /** - * @returns {bigint} Reference time of the round the leaf was created in. + * @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 diff --git a/src/transaction/CertifiedTransferTransaction.ts b/src/transaction/CertifiedTransferTransaction.ts index eb0491e4..55ce3c18 100644 --- a/src/transaction/CertifiedTransferTransaction.ts +++ b/src/transaction/CertifiedTransferTransaction.ts @@ -35,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; @@ -56,7 +56,7 @@ export class CertifiedTransferTransaction implements ITransaction { } /** - * @returns {bigint} Reference time of the round the leaf was created in. + * @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 diff --git a/src/transaction/TransferTransaction.ts b/src/transaction/TransferTransaction.ts index 426650d8..486db9db 100644 --- a/src/transaction/TransferTransaction.ts +++ b/src/transaction/TransferTransaction.ts @@ -98,7 +98,7 @@ export class TransferTransaction implements ITransaction { * instead leaves the value unauthenticated. * * @param {Uint8Array} bytes Encoded transfer transaction. - * @returns {bigint|null} Exclusive request deadline, or `null` when the service assigned one. + * @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 { diff --git a/src/transaction/verification/rule/InclusionProofVerificationRule.ts b/src/transaction/verification/rule/InclusionProofVerificationRule.ts index 8d110b19..9fd83a2f 100644 --- a/src/transaction/verification/rule/InclusionProofVerificationRule.ts +++ b/src/transaction/verification/rule/InclusionProofVerificationRule.ts @@ -43,7 +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|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. @@ -122,6 +122,11 @@ 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); } diff --git a/unicity-token-protocol-spec.md b/unicity-token-protocol-spec.md index 1bf0c4e3..4d49f862 100644 --- a/unicity-token-protocol-spec.md +++ b/unicity-token-protocol-spec.md @@ -172,6 +172,8 @@ The combination of these elements creates a robust system where token states can 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: From 074e95dfdc5aefd477f1541c600dfb8aaa4c2486 Mon Sep 17 00:00:00 2001 From: Pavel Grigorenko Date: Wed, 26 Aug 2026 17:38:55 +0200 Subject: [PATCH 8/8] Drop the shell-managed path; Testcontainers owns the stack outright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three support files are the Testcontainers integration, not leftovers from before it: aggregatorStack.mjs is what constructs the DockerComposeEnvironment, and globalSetup/globalTeardown are the Jest hooks that start and stop it. What was left over was the shell path beside them. scripts/integration-aggregator.sh and the integration:up and integration:down scripts are gone, and with them the branch that reused a stack the run did not start. That branch existed to make the script's workflow fast, and without the script its only remaining effect would be to let a stray AGGREGATOR_URL silently redirect the suite — a green run that proved nothing about the compose file it exists to exercise. IntegrationConfig follows: it now requires the endpoint and trust base the setup hook publishes rather than defaulting to localhost and a guessed path, and says how to run the suite when they are missing. Pointing the SDK at a service someone else runs is what the e2e suite is for, and that distinction is now enforced rather than merely described. Verified from cold with nothing pre-started: 11/11, no containers and no genesis directory left behind. --- README.md | 27 +++--- package.json | 2 - scripts/integration-aggregator.sh | 91 ------------------- tests/integration/IntegrationConfig.ts | 50 +++++----- tests/integration/RequestDeadlineTest.ts | 4 +- tests/integration/docker/docker-compose.yml | 7 +- tests/integration/support/aggregatorStack.mjs | 20 +--- 7 files changed, 45 insertions(+), 156 deletions(-) delete mode 100755 scripts/integration-aggregator.sh diff --git a/README.md b/README.md index 51bc1b83..fbe8ed10 100644 --- a/README.md +++ b/README.md @@ -199,27 +199,22 @@ Run the example flows (requires a reachable aggregator; URL is read from each ex npm run test:examples ``` -Run the integration suite. It talks to a real aggregator, but owns the one 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 afterwards. Nothing external is involved, and no setup -is needed: +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:integration ``` -That pays a cold start of roughly a minute per run. While iterating, start the -stack once and point the suite at it — it reuses a stack it did not start, and -leaves it running: - -```bash -npm run integration:up -eval "$(./scripts/integration-aggregator.sh env)" -npm run test:integration # ~20s -npm run integration:down -``` +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 diff --git a/package.json b/package.json index 06d7f8cb..f531b5b6 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,6 @@ "scripts": { "build": "tsc --project tsconfig.build.json", "build:check": "tsc --noEmit", - "integration:up": "./scripts/integration-aggregator.sh up", - "integration:down": "./scripts/integration-aggregator.sh down", "lint": "eslint \"src/**/*\" \"tests/**/*\"", "lint:fix": "eslint \"src/**/*\" \"tests/**/*\" --fix", "test": "npm run-script test:unit", diff --git a/scripts/integration-aggregator.sh b/scripts/integration-aggregator.sh deleted file mode 100755 index eadfeec4..00000000 --- a/scripts/integration-aggregator.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# -# Bring the local aggregator stack in tests/integration/docker up or down. -# -# scripts/integration-aggregator.sh up start the stack and wait until it certifies -# scripts/integration-aggregator.sh down stop it and delete the generated genesis -# scripts/integration-aggregator.sh logs follow the aggregator log -# scripts/integration-aggregator.sh env print overrides for pointing a suite here -# -# `up` blocks until consensus has produced at least one block, because until -# then the aggregator answers every certification request with -# SERVICE_NOT_READY rather than certifying it. -# -# The integration suite finds this stack on its own — it defaults to localhost -# and to the genesis written below — so `env` is only needed to point the e2e -# suite, or another tool, at it. - -set -euo pipefail - -readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -readonly COMPOSE_DIR="${REPO_ROOT}/tests/integration/docker" -readonly DATA_DIR="${COMPOSE_DIR}/data" -readonly TRUST_BASE_PATH="${DATA_DIR}/genesis/trust-base.json" -readonly AGGREGATOR_PORT="${AGGREGATOR_PORT:-3000}" -readonly AGGREGATOR_URL="http://localhost:${AGGREGATOR_PORT}" - -# The stack runs as the invoking user so the genesis files it writes into the -# bind mounts stay readable and removable from the host. -export USER_UID="${USER_UID:-$(id -u)}" -export USER_GID="${USER_GID:-$(id -g)}" - -compose() { - docker compose --project-directory "${COMPOSE_DIR}" -f "${COMPOSE_DIR}/docker-compose.yml" "$@" -} - -# Block height climbs only once consensus is certifying rounds, which is also -# when the aggregator starts handing out a non-zero reference time. -block_height() { - curl -sf -m 5 -X POST "${AGGREGATOR_URL}" \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"get_block_height","params":{}}' 2>/dev/null | - grep -o '"blockNumber":"[0-9]*"' | head -1 | cut -d'"' -f4 -} - -wait_for_certification() { - local deadline=$((SECONDS + 180)) - while ((SECONDS < deadline)); do - local height - height="$(block_height || true)" - if [[ -n "${height}" && "${height}" != "0" ]]; then - echo "Aggregator certifying at block ${height}." - return 0 - fi - sleep 2 - done - - echo "Aggregator did not reach a certifying round within 180s." >&2 - compose logs --tail 50 aggregator >&2 - return 1 -} - -case "${1:-up}" in -up) - mkdir -p "${DATA_DIR}/genesis" "${DATA_DIR}/genesis-root" - # `--wait` is a convenience, not the gate: the aggregator has a restart policy, - # and a container that exits once and comes back healthy fails `--wait` while - # ending up perfectly usable. wait_for_certification below is the real check, - # and it tolerates a restart because it polls for the outcome we need. - compose up -d --wait || echo "Some services reported unhealthy on start; waiting for certification anyway." - wait_for_certification - echo - echo "Run the integration suite with: npm run test:integration" - ;; -down) - compose down -v --remove-orphans - # Genesis is regenerated on the next `up`; leaving it behind pins the stack - # to keys the fresh mongodb/redis volumes no longer have any state for. - rm -rf "${DATA_DIR}" - ;; -logs) - compose logs -f aggregator - ;; -env) - echo "export AGGREGATOR_URL=${AGGREGATOR_URL}" - echo "export TRUST_BASE_PATH=${TRUST_BASE_PATH}" - ;; -*) - echo "usage: $0 {up|down|logs|env}" >&2 - exit 1 - ;; -esac diff --git a/tests/integration/IntegrationConfig.ts b/tests/integration/IntegrationConfig.ts index 411238ef..8fe634c9 100644 --- a/tests/integration/IntegrationConfig.ts +++ b/tests/integration/IntegrationConfig.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { AggregatorClient } from '../../src/api/AggregatorClient.js'; import { RootTrustBase } from '../../src/api/bft/RootTrustBase.js'; @@ -7,47 +6,46 @@ import { IAggregatorClient } from '../../src/api/IAggregatorClient.js'; import { StateTransitionClient } from '../../src/StateTransitionClient.js'; /** - * The aggregator stack in ./docker, started by scripts/integration-aggregator.sh. + * Read a value the stack published, or say why it is missing. * - * Unlike the e2e suite, which is pointed at a deployed network, this one owns - * its service: the stack runs locally with no external dependency, and its - * trust base is generated per run by the BFT root node it brings up. Both are - * overridable, but the defaults are meant to need no setup beyond - * `npm run integration:up`. + * 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. */ -export const AGGREGATOR_URL = process.env.AGGREGATOR_URL ?? 'http://localhost:3000'; +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\`.`); + } -/** Genesis the local stack writes on first start. */ -const GENERATED_TRUST_BASE = path.join(__dirname, 'docker/data/genesis/trust-base.json'); + return value; +} /** * Build the client and trust base the integration suite runs against. * - * @returns {object} Aggregator client, state transition client and trust base for the local stack. - * @throws {Error} If the stack's genesis is missing, which means it was never started. + * 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 trustBasePath = process.env.TRUST_BASE_PATH ?? GENERATED_TRUST_BASE; - - let trustBaseJson: string; - try { - trustBaseJson = readFileSync(trustBasePath, 'utf-8'); - } catch (cause) { - throw new Error( - `No trust base at ${trustBasePath}. Start the local aggregator stack with \`npm run integration:up\` first.`, - { cause }, - ); - } - - const aggregatorClient = new AggregatorClient(AGGREGATOR_URL, process.env.AGGREGATOR_API_KEY ?? null); + const aggregatorClient = new AggregatorClient(fromStack('AGGREGATOR_URL'), null); return { aggregatorClient, client: new StateTransitionClient(aggregatorClient), - trustBase: RootTrustBase.fromJSON(JSON.parse(trustBaseJson)), + trustBase: RootTrustBase.fromJSON(JSON.parse(readFileSync(fromStack('TRUST_BASE_PATH'), 'utf-8'))), }; } diff --git a/tests/integration/RequestDeadlineTest.ts b/tests/integration/RequestDeadlineTest.ts index b7559064..7c229922 100644 --- a/tests/integration/RequestDeadlineTest.ts +++ b/tests/integration/RequestDeadlineTest.ts @@ -19,8 +19,8 @@ import { createUnicityCertificateVerifier } from '../utils/UnicityCertificateVer * 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. The stack is brought up by - * scripts/integration-aggregator.sh. + * the aggregator still agree. Testcontainers starts the stack for the run; see + * ./support/aggregatorStack.mjs. */ describe('Integration request deadline', () => { const { aggregatorClient, client, trustBase } = createIntegrationContext(); diff --git a/tests/integration/docker/docker-compose.yml b/tests/integration/docker/docker-compose.yml index d1bbbd71..dfdff199 100644 --- a/tests/integration/docker/docker-compose.yml +++ b/tests/integration/docker/docker-compose.yml @@ -5,9 +5,10 @@ # 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. # -# Bring it up with scripts/integration-aggregator.sh, which creates the writable -# genesis directories this file mounts and waits for consensus to produce a -# reference time. +# 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 diff --git a/tests/integration/support/aggregatorStack.mjs b/tests/integration/support/aggregatorStack.mjs index bbfc5c53..25a32e8a 100644 --- a/tests/integration/support/aggregatorStack.mjs +++ b/tests/integration/support/aggregatorStack.mjs @@ -65,26 +65,14 @@ async function waitForCertification(url) { /** * Start the aggregator stack the integration suite runs against. * - * A caller that has already started one — `npm run integration:up`, or a shared - * service in some other environment — sets AGGREGATOR_URL, and this reuses it - * rather than starting a second. That keeps the edit-run loop fast: the stack - * takes the better part of a minute to reach its first certified round, and - * there is no reason to pay that per run while iterating on a test. + * 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() { - if (process.env.AGGREGATOR_URL) { - const url = process.env.AGGREGATOR_URL; - await waitForCertification(url); - - return { - stop: () => Promise.resolve(), - trustBasePath: process.env.TRUST_BASE_PATH ?? TRUST_BASE_PATH, - url, - }; - } - // 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.