From 39911689a49d64a6c83525a45cf6b9e394c9f3c0 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:42:01 +0000 Subject: [PATCH 1/2] refactor(foundation): adopt shared utility packages --- api-extractor.base.json | 50 + package.json | 9 +- packages/node-utils/README.md | 2 +- packages/node-utils/package.json | 10 +- packages/node-utils/src/account.ts | 80 ++ packages/node-utils/src/chain.ts | 208 +++ packages/node-utils/src/format.ts | 25 + packages/node-utils/src/index.test.ts | 796 ----------- packages/node-utils/src/index.ts | 653 +-------- packages/node-utils/src/logging.ts | 215 +++ packages/node-utils/src/retryable.ts | 79 ++ packages/node-utils/src/runtime_config.ts | 299 ++++ packages/node-utils/test/account.ts | 89 ++ packages/node-utils/test/chain.ts | 256 ++++ packages/node-utils/test/index.ts | 21 + packages/node-utils/test/logging.ts | 290 ++++ packages/node-utils/test/retryable.ts | 116 ++ packages/node-utils/test/runtime_config.ts | 310 +++++ packages/node-utils/test/sleep.ts | 26 + .../test/support/node_utils_support.ts | 96 ++ packages/node-utils/tsconfig.build.json | 13 + packages/node-utils/tsconfig.json | 8 +- packages/node-utils/vitest.config.mts | 9 +- packages/testkit/package.json | 10 +- packages/testkit/src/bytes.ts | 12 + packages/testkit/src/index.ts | 216 ++- packages/testkit/test/index.ts | 187 +++ packages/testkit/tsconfig.json | 7 +- packages/testkit/vitest.config.mts | 10 + packages/utils/api-extractor.json | 5 + packages/utils/package.json | 21 +- packages/utils/src/codec.ts | 9 +- packages/utils/src/heap.test.ts | 31 - packages/utils/src/heap.ts | 175 --- packages/utils/src/index.ts | 27 +- packages/utils/src/utils.test.ts | 51 - packages/utils/src/utils.ts | 197 ++- .../{src/codec.test.ts => test/codec.ts} | 11 +- packages/utils/test/index.ts | 15 + packages/utils/test/utils.ts | 176 +++ packages/utils/tsconfig.build.json | 13 + packages/utils/tsconfig.json | 8 +- packages/utils/vitest.config.mts | 4 +- pnpm-lock.yaml | 1214 ++++++++++------- pnpm-workspace.yaml | 8 +- scripts/tooling/build/rewrite-dts-imports.ts | 86 ++ tsconfig.json | 6 +- 47 files changed, 3809 insertions(+), 2350 deletions(-) create mode 100644 api-extractor.base.json create mode 100644 packages/node-utils/src/account.ts create mode 100644 packages/node-utils/src/chain.ts create mode 100644 packages/node-utils/src/format.ts delete mode 100644 packages/node-utils/src/index.test.ts create mode 100644 packages/node-utils/src/logging.ts create mode 100644 packages/node-utils/src/retryable.ts create mode 100644 packages/node-utils/src/runtime_config.ts create mode 100644 packages/node-utils/test/account.ts create mode 100644 packages/node-utils/test/chain.ts create mode 100644 packages/node-utils/test/index.ts create mode 100644 packages/node-utils/test/logging.ts create mode 100644 packages/node-utils/test/retryable.ts create mode 100644 packages/node-utils/test/runtime_config.ts create mode 100644 packages/node-utils/test/sleep.ts create mode 100644 packages/node-utils/test/support/node_utils_support.ts create mode 100644 packages/node-utils/tsconfig.build.json create mode 100644 packages/testkit/src/bytes.ts create mode 100644 packages/testkit/test/index.ts create mode 100644 packages/testkit/vitest.config.mts create mode 100644 packages/utils/api-extractor.json delete mode 100644 packages/utils/src/heap.test.ts delete mode 100644 packages/utils/src/heap.ts delete mode 100644 packages/utils/src/utils.test.ts rename packages/utils/{src/codec.test.ts => test/codec.ts} (51%) create mode 100644 packages/utils/test/index.ts create mode 100644 packages/utils/test/utils.ts create mode 100644 packages/utils/tsconfig.build.json create mode 100644 scripts/tooling/build/rewrite-dts-imports.ts diff --git a/api-extractor.base.json b/api-extractor.base.json new file mode 100644 index 0000000..efb3718 --- /dev/null +++ b/api-extractor.base.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": { + "enabled": false + }, + "compiler": { + "overrideTsconfig": { + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "customConditions": ["api-extractor"], + "skipLibCheck": true, + "preserveSymlinks": true + } + } + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "none" + }, + "ae-forgotten-export": { + "logLevel": "error" + }, + "ae-missing-release-tag": { + "logLevel": "error" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "none" + } + } + } +} diff --git a/package.json b/package.json index 856f20d..289a117 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "private": true, + "type": "module", "scripts": { "build": "pnpm -r --filter !./apps/** --filter '!./forks/**' build", "build:all": "pnpm -r --filter '!./forks/**' build", @@ -33,12 +34,14 @@ "coworker:ask": "opencode run --pure --agent plan" }, "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "devDependencies": { "@eslint/js": "^9.39.3", "@ickb/testkit": "workspace:*", - "@vitest/coverage-v8": "3.2.4", + "@microsoft/api-extractor": "^7.58.9", + "@typescript/native-preview": "latest", + "@vitest/coverage-v8": "4.1.8", "eslint": "^9.39.3", "eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-hooks": "^5.2.0", @@ -46,7 +49,7 @@ "madge": "^8.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", - "vitest": "^3.2.4" + "vitest": "^4.1.8" }, "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017" } diff --git a/packages/node-utils/README.md b/packages/node-utils/README.md index 7425ec5..8848bd8 100644 --- a/packages/node-utils/README.md +++ b/packages/node-utils/README.md @@ -2,7 +2,7 @@ Private workspace utilities for Node-based iCKB apps. -`@ickb/node-utils` owns process and operator glue for Node-based iCKB apps such as `apps/tester`: environment parsing, public RPC client construction, signer account-lock collection, sleep loops, CKB log formatting, JSON-safe error/log serialization, elapsed-loop logging, and broadcast-timeout stop handling. +`@ickb/node-utils` owns process and operator glue for Node-based iCKB apps such as `apps/validation`: environment parsing, public RPC client construction, signer account-lock collection, sleep loops, CKB log formatting, JSON-safe error/log serialization, elapsed-loop logging, and broadcast-timeout stop handling. This package is intentionally private and should not be used by the browser interface. Cross-runtime transaction lifecycle helpers, such as `sendAndWaitForCommit(...)`, stay in `@ickb/sdk`. diff --git a/packages/node-utils/package.json b/packages/node-utils/package.json index a2c18b5..bb8bcb2 100644 --- a/packages/node-utils/package.json +++ b/packages/node-utils/package.json @@ -21,6 +21,9 @@ }, "sideEffects": false, "type": "module", + "engines": { + "node": ">=22.19.0" + }, "main": "dist/index.js", "types": "src/index.ts", "exports": { @@ -32,10 +35,9 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/node-utils/src packages/node-utils/test", + "clean": "rm -fr dist" }, "dependencies": { "@ckb-ccc/core": "catalog:", diff --git a/packages/node-utils/src/account.ts b/packages/node-utils/src/account.ts new file mode 100644 index 0000000..c95e34d --- /dev/null +++ b/packages/node-utils/src/account.ts @@ -0,0 +1,80 @@ +import type { ccc } from "@ckb-ccc/core"; +import { unique } from "@ickb/utils"; + +/** + * Returns the primary lock plus all signer address locks, deduplicated by script hash. + */ +export async function signerAccountLocks( + signer: ccc.Signer, + primaryLock: ccc.Script, +): Promise { + return [ + ...unique([ + primaryLock, + ...(await signer.getAddressObjs()).map(({ script }) => script), + ]), + ]; +} + +/** + * Sums currently live plain CKB capacity controlled by the account locks. + */ +export function accountPlainCkbBalance( + capacityCells: readonly ccc.Cell[], + accountLocks: readonly ccc.Script[], +): bigint { + const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); + return capacityCells.reduce( + (total, cell) => + total + plainCapacity(cell.cellOutput, cell.outputData, accountLockHexes), + 0n, + ); +} + +/** + * Projects account plain CKB capacity after applying a transaction's inputs and outputs. + */ +export function postTransactionAccountPlainCkbBalance( + tx: ccc.Transaction, + capacityCells: readonly ccc.Cell[], + accountLocks: readonly ccc.Script[], +): bigint { + const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); + const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); + const unspentCapacity = capacityCells.reduce( + (total, cell) => + spentOutPoints.has(cell.outPoint.toHex()) + ? total + : total + plainCapacity(cell.cellOutput, cell.outputData, accountLockHexes), + 0n, + ); + const outputCapacity = tx.outputs.reduce( + (total, output, index) => + total + plainCapacity(output, tx.outputsData[index], accountLockHexes), + 0n, + ); + + return unspentCapacity + outputCapacity; +} + +function plainCapacity( + output: ccc.CellOutput, + outputData: string | undefined, + accountLockHexes: Set, +): bigint { + return isAccountPlainCapacityOutput(output, outputData, accountLockHexes) + ? output.capacity + : 0n; +} + +function isAccountPlainCapacityOutput( + output: ccc.CellOutput, + outputData: string | undefined, + accountLockHexes: Set, +): boolean { + return ( + output.type === undefined && + (outputData ?? "0x") === "0x" && + accountLockHexes.has(output.lock.toHex()) + ); +} diff --git a/packages/node-utils/src/chain.ts b/packages/node-utils/src/chain.ts new file mode 100644 index 0000000..7c0f434 --- /dev/null +++ b/packages/node-utils/src/chain.ts @@ -0,0 +1,208 @@ +import { ccc } from "@ckb-ccc/core"; +import { jsonLogReplacer, toJsonLogValue } from "./logging.ts"; +import { FETCH_FAILED_MESSAGE, isRetryableRpcTransportError } from "./retryable.ts"; + +const UNKNOWN_ERROR_MESSAGE = "Unknown error"; + +/** Supported public CKB network names. */ +export type SupportedChain = "mainnet" | "testnet"; + +interface ChainIdentity { + chain: SupportedChain; + networkName: string; + genesisHash: ccc.Hex; + genesisMessage: string; + genesisSource: string; + addressPrefix: "ckb" | "ckt"; +} + +const CHAIN_IDENTITIES = { + mainnet: { + chain: "mainnet", + networkName: "ckb", + genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", + genesisMessage: + "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", + genesisSource: + "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", + addressPrefix: "ckb", + }, + testnet: { + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", + genesisMessage: "aggron-v4", + genesisSource: + "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", + addressPrefix: "ckt", + }, +} as const satisfies Record; + +/** Public chain preflight evidence returned after identity verification. */ +export interface ChainPreflightEvidence { + /** Chain requested by the runtime config. */ + chain: SupportedChain; + + /** Expected public chain identity. */ + expected: ChainIdentity; + + /** Observed RPC identity and current tip evidence. */ + observed: { + /** Observed genesis block hash. */ + genesisHash: ccc.Hex; + /** Address prefix reported by the CCC client. */ + addressPrefix: string; + /** Tip header fields read during preflight. */ + tip: { + hash: ccc.Hex; + number: bigint; + timestamp: bigint; + }; + }; + + /** Per-field identity comparison results. */ + matches: { + genesisHash: boolean; + addressPrefix: boolean; + }; +} + +/** + * Verifies that an RPC client matches the expected public chain identity. + * + * @remarks Public chain mismatches keep their diagnostic text; unexpected + * failures expose only a safe error shape before loop logging adds context. + */ +export async function verifyChainPreflight( + client: ccc.Client, + chain: SupportedChain, +): Promise { + try { + return assertChainPreflight(await readChainPreflight(client, chain)); + } catch (error) { + if (isPublicChainPreflightFailure(error, chain)) { + const options = { + cause: toJsonLogValue(error, new WeakSet()), + }; + throw new Error(errorMessage(error), options); + } + if (isRetryableRpcTransportError(error)) { + const options = { + cause: { name: "TypeError", message: FETCH_FAILED_MESSAGE }, + }; + throw new Error(FETCH_FAILED_MESSAGE, options); + } + const options = { + cause: safePreflightFailureCause(error), + }; + throw new Error(`Failed to verify ${chain} RPC chain identity`, options); + } +} + +/** + * Creates a CCC public client for the selected chain and optional RPC URL. + */ +export function createPublicClient( + chain: SupportedChain, + rpcUrl: string | undefined, +): ccc.Client { + const config = rpcUrl === undefined || rpcUrl === "" ? undefined : { url: rpcUrl }; + return chain === "mainnet" + ? new ccc.ClientPublicMainnet(config) + : new ccc.ClientPublicTestnet(config); +} + +async function readChainPreflight( + client: ccc.Client, + chain: SupportedChain, +): Promise { + const expected = expectedChainIdentity(chain); + const [genesis, tip] = await Promise.all([ + client.getHeaderByNumber(0n), + client.getTipHeader(), + ]); + + if (genesis === undefined) { + throw new Error(`Missing ${chain} genesis header`); + } + + return { + chain, + expected, + observed: { + genesisHash: genesis.hash, + addressPrefix: client.addressPrefix, + tip: { + hash: tip.hash, + number: tip.number, + timestamp: tip.timestamp, + }, + }, + matches: { + genesisHash: genesis.hash === expected.genesisHash, + addressPrefix: client.addressPrefix === expected.addressPrefix, + }, + }; +} + +function assertChainPreflight(evidence: ChainPreflightEvidence): ChainPreflightEvidence { + const failures: string[] = []; + if (evidence.observed.genesisHash !== evidence.expected.genesisHash) { + failures.push( + `genesis hash expected ${evidence.expected.genesisHash} observed ${evidence.observed.genesisHash}`, + ); + } + if (evidence.observed.addressPrefix !== evidence.expected.addressPrefix) { + failures.push( + `address prefix expected ${evidence.expected.addressPrefix} observed ${evidence.observed.addressPrefix}`, + ); + } + if (failures.length > 0) { + throw new Error( + `Invalid ${evidence.chain} RPC chain identity: ${failures.join("; ")}`, + ); + } + + return evidence; +} + +function expectedChainIdentity(chain: SupportedChain): ChainIdentity { + return CHAIN_IDENTITIES[chain]; +} + +function safePreflightFailureCause(error: unknown): { name: string } | { type: string } { + if (error instanceof Error) { + return { name: safeErrorName(error.name) }; + } + return { type: error === null ? "null" : typeof error }; +} + +function safeErrorName(name: string): string { + return /^[A-Za-z][\w.-]{0,63}$/u.test(name) ? name : "Error"; +} + +function isPublicChainPreflightFailure(error: unknown, chain: SupportedChain): boolean { + const message = errorMessage(error); + return ( + message === `Missing ${chain} genesis header` || + message.startsWith(`Invalid ${chain} RPC chain identity:`) + ); +} + +function errorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + return error instanceof Error ? error.message : stringifyErrorMessage(error); +} + +function stringifyErrorMessage(error: unknown): string { + if (error === undefined || error === null) { + return UNKNOWN_ERROR_MESSAGE; + } + try { + return JSON.stringify(toJsonLogValue(error, new WeakSet()), jsonLogReplacer); + } catch { + return UNKNOWN_ERROR_MESSAGE; + } +} diff --git a/packages/node-utils/src/format.ts b/packages/node-utils/src/format.ts new file mode 100644 index 0000000..471baf0 --- /dev/null +++ b/packages/node-utils/src/format.ts @@ -0,0 +1,25 @@ +const CKB = 100000000n; + +/** + * Formats shannon as a trimmed decimal CKB amount. + */ +export function formatCkb(balance: bigint): string { + const sign = balance < 0n ? "-" : ""; + const absolute = balance < 0n ? -balance : balance; + const whole = absolute / CKB; + const fraction = absolute % CKB; + + if (fraction === 0n) { + return sign + whole.toString(); + } + + return `${sign}${whole.toString()}.${trimTrailingZeros(fraction.toString().padStart(8, "0"))}`; +} + +function trimTrailingZeros(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === "0") { + end -= 1; + } + return value.slice(0, end); +} diff --git a/packages/node-utils/src/index.test.ts b/packages/node-utils/src/index.test.ts deleted file mode 100644 index 9352e6d..0000000 --- a/packages/node-utils/src/index.test.ts +++ /dev/null @@ -1,796 +0,0 @@ -import { ccc } from "@ckb-ccc/core"; -import { byte32FromByte, headerLike, script } from "@ickb/testkit"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import process from "node:process"; -import { tmpdir } from "node:os"; -import { describe, expect, it, vi } from "vitest"; -import * as nodeUtils from "./index.js"; -import { - accountPlainCkbBalance, - assertChainPreflight, - CHAIN_IDENTITIES, - createPublicClient, - formatCkb, - handleLoopError, - isRetryableCkbStateRaceError, - isRetryableRpcTransportError, - logExecution, - parseMaxIterations, - parseRuntimeConfig, - parsePrivateKey, - postTransactionAccountPlainCkbBalance, - readRuntimeConfigEnv, - parseSleepInterval, - randomSleepIntervalMs, - readChainPreflight, - reachedMaxIterations, - signerAccountLocks, - STOP_EXIT_CODE, - verifyChainPreflight, - writeJsonLine, - type ChainPreflightClient, -} from "./index.js"; - -describe("node utilities", () => { - it("does not export generic secret-policing helpers", () => { - expect("assertNoPrivateKeyMaterial" in nodeUtils).toBe(false); - expect("assertNoSecretMaterial" in nodeUtils).toBe(false); - expect("SecretMaterialLogError" in nodeUtils).toBe(false); - expect("PrivateKeyMaterialLogError" in nodeUtils).toBe(false); - expect("sanitizeLogValue" in nodeUtils).toBe(false); - }); - - it("formats CKB values without losing bigint precision", () => { - const whole = 123456789012345678901234567890n; - - expect(formatCkb(whole * 100000000n + 12345670n)).toBe( - `${whole.toString()}.1234567`, - ); - expect(formatCkb(-100000000n - 1n)).toBe("-1.00000001"); - }); - - it("parses positive sleep intervals as milliseconds", () => { - expect(parseSleepInterval(1, "BOT_CONFIG_FILE")).toBe(1000); - expect(parseSleepInterval(2.5, "BOT_CONFIG_FILE")).toBe(2500); - expect(parseSleepInterval(1073741, "BOT_CONFIG_FILE")).toBe(1073741000); - }); - - it("rejects missing and sub-second sleep intervals", () => { - for (const value of [undefined, Number.NaN, Infinity, 0, 0.5, 1073741.824, 9007199254741]) { - expect(() => parseSleepInterval(value, "BOT_CONFIG_FILE")).toThrow( - "Invalid env BOT_CONFIG_FILE", - ); - } - }); - - it("parses bounded-run iteration limits", () => { - expect(parseMaxIterations(undefined, "BOT_CONFIG_FILE")).toBeUndefined(); - expect(parseMaxIterations(1, "BOT_CONFIG_FILE")).toBe(1); - expect(parseMaxIterations(2, "BOT_CONFIG_FILE")).toBe(2); - expect(reachedMaxIterations(0, 1)).toBe(false); - expect(reachedMaxIterations(1, 1)).toBe(true); - expect(reachedMaxIterations(10, undefined)).toBe(false); - expect(() => parseMaxIterations(0, "BOT_CONFIG_FILE")).toThrow( - "Invalid env BOT_CONFIG_FILE", - ); - expect(() => parseMaxIterations(1.5, "BOT_CONFIG_FILE")).toThrow( - "Invalid env BOT_CONFIG_FILE", - ); - }); - - it("randomizes sleep with triangular jitter centered on the interval", () => { - expect(randomSleepIntervalMs(1000, sequence(0, 0))).toBe(0); - expect(randomSleepIntervalMs(1000, sequence(0.5, 0.5))).toBe(1000); - expect(randomSleepIntervalMs(1000, sequence(0.999, 0.999))).toBe(1998); - - const samples = Array.from({ length: 1000 }, (_, index) => index / 1000); - const average = samples.reduce((sum, first) => ( - sum + randomSleepIntervalMs(1000, sequence(first, 1 - first)) - ), 0) / samples.length; - expect(average).toBe(1000); - expect(randomSleepIntervalMs(1073741823, sequence(0.999999, 0.999999))).toBeLessThanOrEqual(2147483647); - }); - - it("parses private keys as exact 0x-prefixed lowercase hex", () => { - const privateKey = `0x${"11".repeat(32)}`; - const secp256k1Order = "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; - - expect(parsePrivateKey(privateKey, "BOT_CONFIG_FILE")).toBe(privateKey); - for (const value of [ - "11".repeat(32), - `0X${"11".repeat(32)}`, - `0x${"AA".repeat(32)}`, - ` 0x${"11".repeat(32)}`, - `0x${"11".repeat(32)} `, - `0x${"11".repeat(31)}`, - `0x${"00".repeat(32)}`, - secp256k1Order, - "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142", - ]) { - expect(() => parsePrivateKey(value, "BOT_CONFIG_FILE")).toThrow( - "Invalid env BOT_CONFIG_FILE", - ); - } - }); - - it("parses exact runtime JSON config", () => { - const privateKey = `0x${"11".repeat(32)}`; - - expect(parseRuntimeConfig(JSON.stringify({ - chain: "testnet", - privateKey, - rpcUrl: "https://rpc.example/path?token=abc", - sleepIntervalSeconds: 60, - maxIterations: 2, - }), "BOT_CONFIG_FILE")).toEqual({ - chain: "testnet", - privateKey, - rpcUrl: "https://rpc.example/path?token=abc", - sleepIntervalMs: 60000, - maxIterations: 2, - maxRetryableAttempts: undefined, - }); - expect(parseRuntimeConfig(JSON.stringify({ - chain: "mainnet", - privateKey, - rpcUrl: "https://mainnet.example/", - sleepIntervalSeconds: 1, - }), "BOT_CONFIG_FILE")).toEqual({ - chain: "mainnet", - privateKey, - rpcUrl: "https://mainnet.example/", - sleepIntervalMs: 1000, - maxIterations: undefined, - maxRetryableAttempts: undefined, - }); - expect(parseRuntimeConfig(JSON.stringify({ - chain: "testnet", - privateKey, - sleepIntervalSeconds: 5, - }), "BOT_CONFIG_FILE")).toEqual({ - chain: "testnet", - privateKey, - rpcUrl: undefined, - sleepIntervalMs: 5000, - maxIterations: undefined, - maxRetryableAttempts: undefined, - }); - expect(parseRuntimeConfig(JSON.stringify({ - chain: "testnet", - privateKey, - sleepIntervalSeconds: 5, - maxRetryableAttempts: 3, - }), "BOT_CONFIG_FILE")).toEqual({ - chain: "testnet", - privateKey, - rpcUrl: undefined, - sleepIntervalMs: 5000, - maxIterations: undefined, - maxRetryableAttempts: 3, - }); - }); - - it("rejects invalid runtime JSON config without exposing contents", () => { - const privateKey = `0x${"11".repeat(32)}`; - const invalidValues = [ - "not-json", - JSON.stringify([]), - JSON.stringify({ chain: "devnet", privateKey, sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: privateKey, privateKey, rpcUrl: "https://rpc.example/", sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 60, extra: true }), - JSON.stringify({ chain: "testnet", privateKey: `${privateKey}\n`, sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, rpcUrl: "", sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, rpcUrl: "file:///tmp/socket", sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, rpcUrl: "https://rpc.example/ bad", sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, rpcUrl: 8114, sleepIntervalSeconds: 60 }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: "60" }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 0 }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 60, maxIterations: "1" }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 60, maxRetryableAttempts: "1" }), - JSON.stringify({ chain: "testnet", privateKey, sleepIntervalSeconds: 60, maxRetryableAttempts: 0 }), - ]; - - for (const value of invalidValues) { - expect(() => parseRuntimeConfig(value, "BOT_CONFIG_FILE")).toThrow( - "Invalid env BOT_CONFIG_FILE", - ); - expect(() => parseRuntimeConfig(value, "BOT_CONFIG_FILE")).not.toThrow(/rpc\.example|0x11/u); - } - }); - - it("reads runtime JSON config from a file env source", async () => { - const privateKey = `0x${"11".repeat(32)}`; - const dir = await mkdtemp(join(tmpdir(), "ickb-runtime-config-")); - const originalInitCwd = process.env.INIT_CWD; - try { - const configPath = join(dir, "config.json"); - await writeFile(configPath, JSON.stringify({ - chain: "testnet", - privateKey, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalSeconds: 60, - }), { mode: 0o600 }); - - await expect(readRuntimeConfigEnv(configPath, "BOT_CONFIG_FILE")).resolves.toEqual({ - chain: "testnet", - privateKey, - rpcUrl: "http://127.0.0.1:8114/", - sleepIntervalMs: 60000, - maxIterations: undefined, - maxRetryableAttempts: undefined, - }); - await expect(readRuntimeConfigEnv(undefined, "BOT_CONFIG_FILE")).rejects.toThrow( - "Empty env BOT_CONFIG_FILE", - ); - await expect(readRuntimeConfigEnv(join(dir, "missing"), "BOT_CONFIG_FILE")).rejects.toThrow( - "Invalid file from env BOT_CONFIG_FILE", - ); - process.env.INIT_CWD = dir; - await expect(readRuntimeConfigEnv("config.json", "BOT_CONFIG_FILE")).resolves.toMatchObject({ - chain: "testnet", - privateKey, - }); - await expect(readRuntimeConfigEnv(resolve("config.json"), "BOT_CONFIG_FILE")).rejects.toThrow( - "Invalid file from env BOT_CONFIG_FILE", - ); - } finally { - if (originalInitCwd === undefined) { - delete process.env.INIT_CWD; - } else { - process.env.INIT_CWD = originalInitCwd; - } - await rm(dir, { recursive: true, force: true }); - } - }); - - it("keeps the primary signer lock first and deduplicates account locks", async () => { - const primaryLock = script("11"); - const primaryLockCopy = ccc.Script.from(primaryLock); - const otherLock = script("22"); - const signer = { - getAddressObjs: async () => { - await Promise.resolve(); - return [{ script: otherLock }, { script: primaryLockCopy }]; - }, - } as ccc.Signer; - - await expect(signerAccountLocks(signer, primaryLock)).resolves.toEqual([ - primaryLock, - otherLock, - ]); - }); - - it("counts account plain CKB from owned plain capacity cells only", () => { - const lock = script("11"); - const otherLock = script("22"); - const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "bb"); - const typed = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("cc"), index: 0n }, - cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") }, - outputData: "0x", - }); - const data = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("dd"), index: 0n }, - cellOutput: { capacity: ccc.fixedPointFrom(8000), lock }, - outputData: "0x1234", - }); - - expect(accountPlainCkbBalance([unspent, typed, data, capacityCell(100n, otherLock, "ee")], [lock])).toBe( - ccc.fixedPointFrom(2000), - ); - }); - - it("counts post-transaction account plain CKB from unspent cells and new outputs", () => { - const lock = script("11"); - const otherLock = script("22"); - const spent = capacityCell(ccc.fixedPointFrom(1000), lock, "aa"); - const unspent = capacityCell(ccc.fixedPointFrom(2000), lock, "bb"); - const typed = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("cc"), index: 0n }, - cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") }, - outputData: "0x", - }); - const data = ccc.Cell.from({ - outPoint: { txHash: byte32FromByte("dd"), index: 0n }, - cellOutput: { capacity: ccc.fixedPointFrom(8000), lock }, - outputData: "0x1234", - }); - const tx = ccc.Transaction.default(); - tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); - tx.outputs.push( - ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock }), - ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(500), lock, type: script("33") }), - ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(700), lock: otherLock }), - ); - tx.outputsData.push("0x", "0x", "0x"); - - expect(postTransactionAccountPlainCkbBalance( - tx, - [spent, unspent, typed, data], - [lock], - )).toBe(ccc.fixedPointFrom(2300)); - }); - - it("creates network-specific public clients and forwards custom RPC URLs", () => { - const mainnet = createPublicClient("mainnet", "https://mainnet.example"); - const testnet = createPublicClient("testnet", undefined); - const defaultTestnet = new ccc.ClientPublicTestnet(); - - expect(mainnet).toBeInstanceOf(ccc.ClientPublicMainnet); - expect(testnet).toBeInstanceOf(ccc.ClientPublicTestnet); - expect(mainnet.addressPrefix).toBe("ckb"); - expect(testnet.addressPrefix).toBe("ckt"); - expect((mainnet as ccc.ClientPublicMainnet).url).toBe( - "https://mainnet.example", - ); - expect((testnet as ccc.ClientPublicTestnet).url).toBe(defaultTestnet.url); - }); - - it("pins official CKB chain identities for preflight checks", () => { - expect(CHAIN_IDENTITIES.mainnet).toMatchObject({ - chain: "mainnet", - networkName: "ckb", - genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", - genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", - genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", - addressPrefix: "ckb", - }); - expect(CHAIN_IDENTITIES.testnet).toMatchObject({ - chain: "testnet", - networkName: "ckb_testnet", - genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", - genesisMessage: "aggron-v4", - genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", - addressPrefix: "ckt", - }); - }); - - it("reads and verifies public chain identity evidence", async () => { - const client = preflightClient({ - addressPrefix: "ckt", - genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, - tipHash: byte32FromByte("22"), - tipNumber: 123n, - tipTimestamp: 456n, - }); - - await expect(readChainPreflight(client, "testnet")).resolves.toEqual({ - chain: "testnet", - expected: CHAIN_IDENTITIES.testnet, - observed: { - genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, - addressPrefix: "ckt", - tip: { - hash: byte32FromByte("22"), - number: 123n, - timestamp: 456n, - }, - }, - matches: { - genesisHash: true, - addressPrefix: true, - }, - }); - await expect(verifyChainPreflight(client, "testnet")).resolves.toMatchObject({ - chain: "testnet", - matches: { genesisHash: true, addressPrefix: true }, - }); - }); - - it("rejects mismatched public chain identity evidence", () => { - expect(() => assertChainPreflight({ - chain: "testnet", - expected: CHAIN_IDENTITIES.testnet, - observed: { - genesisHash: CHAIN_IDENTITIES.mainnet.genesisHash, - addressPrefix: "ckb", - tip: { hash: byte32FromByte("22"), number: 1n, timestamp: 2n }, - }, - matches: { genesisHash: false, addressPrefix: false }, - })).toThrow( - "Invalid testnet RPC chain identity: genesis hash expected " + - CHAIN_IDENTITIES.testnet.genesisHash + - " observed " + - CHAIN_IDENTITIES.mainnet.genesisHash + - "; address prefix expected ckt observed ckb", - ); - }); - - it("hides non-public preflight failure details before loop logging starts", async () => { - const client = preflightClient({ - addressPrefix: "ckt", - genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, - tipHash: byte32FromByte("22"), - tipNumber: 123n, - tipTimestamp: 456n, - }); - client.getHeaderByNumber = (): Promise => { - const error = new Error("RPC failed via https://user:pass@testnet.example/path?token=secret"); - error.name = "RpcPreflightError"; - throw error; - }; - - const failure = await verifyChainPreflight(client, "testnet").catch((error: unknown) => error); - expect(failure).toMatchObject({ - message: "Failed to verify testnet RPC chain identity", - cause: { name: "RpcPreflightError" }, - }); - expect(JSON.stringify(failure)).not.toMatch(/user|pass|secret|testnet\.example/u); - }); - - it("hides non-Error preflight failures before loop logging starts", async () => { - const client = preflightClient({ - addressPrefix: "ckt", - genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, - tipHash: byte32FromByte("22"), - tipNumber: 123n, - tipTimestamp: 456n, - }); - client.getHeaderByNumber = (): Promise => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Covers defensive handling for non-Error RPC failures. - return Promise.reject({ - reason: "failed", - amount: 9007199254740993n, - reasonCode: "transport", - }); - }; - - await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ - message: "Failed to verify testnet RPC chain identity", - cause: { type: "object" }, - }); - }); - - it("normalizes retryable preflight transport failures", async () => { - const client = preflightClient({ - addressPrefix: "ckt", - genesisHash: CHAIN_IDENTITIES.testnet.genesisHash, - tipHash: byte32FromByte("22"), - tipNumber: 123n, - tipTimestamp: 456n, - }); - client.getHeaderByNumber = (): Promise => { - throw new TypeError("fetch failed"); - }; - - await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ - message: "fetch failed", - cause: { name: "TypeError", message: "fetch failed" }, - }); - await expect(verifyChainPreflight(client, "testnet").catch((error: unknown) => { - expect(isRetryableRpcTransportError(error)).toBe(true); - })).resolves.toBeUndefined(); - }); - - it("classifies retryable RPC transport failures", async () => { - const { isRetryableRpcTransportError } = await import("./index.js"); - - expect(isRetryableRpcTransportError(new TypeError("fetch failed"))).toBe(true); - expect(isRetryableRpcTransportError(new Error("fetch failed", { cause: new TypeError("fetch failed") }))).toBe(true); - expect(isRetryableRpcTransportError(new Error("fetch failed"))).toBe(false); - expect(isRetryableRpcTransportError(new Error("Invalid testnet RPC chain identity"))).toBe(false); - }); - - it("classifies observed CKB state-race send failures", () => { - expect(isRetryableCkbStateRaceError(Object.assign(new Error("Client request error PoolRejectedRBF"), { - code: -1111, - data: "RBFRejected(\"Tx's current fee is 11795, expect it to >= 12326 to replace old txs\")", - currentFee: 11795n, - leastFee: 12326n, - }))).toBe(true); - expect(isRetryableCkbStateRaceError(Object.assign(new Error("Client request error TransactionFailedToResolve"), { - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - }))).toBe(true); - expect(isRetryableCkbStateRaceError({ - code: -301, - data: `Resolve(Dead(OutPoint(0x${"11".repeat(32)}00000000)))`, - })).toBe(true); - expect(isRetryableCkbStateRaceError(Object.assign(new Error("Client request error PoolRejectedDuplicatedTransaction"), { - code: -1107, - data: `Duplicated(Byte32(0x${"22".repeat(32)}))`, - txHash: `0x${"22".repeat(32)}`, - }))).toBe(true); - - expect(isRetryableCkbStateRaceError({ code: -301, data: "Resolve(InvalidHeader(Byte32(0x...)))" })).toBe(false); - expect(isRetryableCkbStateRaceError({ code: -302, data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))` })).toBe(false); - expect(isRetryableCkbStateRaceError(new Error("RBFRejected"))).toBe(false); - }); - - it("serializes error-like values for JSON logs", () => { - const executionLog: Record = {}; - - expect(handleLoopError(executionLog, new Error("failed"))).toBe(false); - expect(executionLog.error).toMatchObject({ - name: "Error", - message: "failed", - }); - expect(executionLog.error).toHaveProperty("stack"); - - const emptyLog: Record = {}; - expect(handleLoopError(emptyLog, undefined)).toBe(false); - expect(emptyLog.error).toBe("Empty Error"); - }); - - it("preserves public CKB RPC Error metadata in execution logs", () => { - const executionLog: Record = {}; - const error = Object.assign(new Error("Client request error TransactionFailedToResolve"), { - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - outPoint: { - txHash: `0x${"11".repeat(32)}`, - index: 0n, - }, - }); - - expect(handleLoopError(executionLog, error)).toBe(false); - expect(executionLog.error).toMatchObject({ - name: "Error", - message: "Client request error TransactionFailedToResolve", - code: -301, - data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, - outPoint: { - txHash: `0x${"11".repeat(32)}`, - index: "0", - }, - }); - }); - - it("stops after broadcast confirmation timeouts", () => { - expect(STOP_EXIT_CODE).toBe(2); - expect(handleLoopError({}, transactionError(true))).toBe(true); - expect(process.exitCode).toBe(STOP_EXIT_CODE); - process.exitCode = undefined; - - expect(handleLoopError({}, transactionError(false))).toBe(false); - expect(handleLoopError({}, new Error("failed"))).toBe(false); - }); - - it("records timeout errors, preserves broadcast hash, and sets exit code 2", () => { - const txHash = byte32FromByte("33"); - const executionLog: Record = { txHash }; - - expect(handleLoopError(executionLog, transactionError(true, txHash))).toBe(true); - expect(process.exitCode).toBe(STOP_EXIT_CODE); - expect(executionLog.txHash).toBe(txHash); - expect(executionLog.error).toMatchObject({ - name: "TransactionConfirmationError", - message: "Transaction confirmation timed out", - txHash, - status: "sent", - isTimeout: true, - }); - - process.exitCode = undefined; - }); - - it("records non-timeout transaction confirmation failures distinctly", () => { - const txHash = byte32FromByte("34"); - const executionLog: Record = { txHash }; - - expect(handleLoopError(executionLog, transactionError(false, txHash))).toBe(false); - expect(executionLog.error).toMatchObject({ - name: "TransactionConfirmationError", - message: "Transaction confirmation timed out", - txHash, - status: "rejected", - isTimeout: false, - }); - }); - - it("preserves CKB debugging metadata from non-Error loop failures", () => { - const rpcUrl = "https://testnet.example/rpc/path"; - const executionLog: Record = {}; - const circular: Record = {}; - circular.self = circular; - - expect(handleLoopError(executionLog, { - message: `failed via ${rpcUrl}`, - rpcUrl, - amount: 9007199254740993n, - nested: { - rpc_url: rpcUrl, - message: "nested public evidence", - }, - circular, - })).toBe(false); - const serialized = JSON.stringify(executionLog); - - expect(serialized).toContain(rpcUrl); - expect(executionLog.error).toMatchObject({ - message: "failed via " + rpcUrl, - rpcUrl, - amount: "9007199254740993", - nested: { - rpc_url: rpcUrl, - message: "nested public evidence", - }, - circular: { self: "[Circular]" }, - }); - }); - - it("logs one JSON entry with elapsed seconds", () => { - const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const now = vi.spyOn(Date, "now").mockReturnValue(2500); - const executionLog: Record = { - amount: 9007199254740993n, - txHash: byte32FromByte("44"), - }; - - logExecution(executionLog, new Date(1000)); - - expect(stdoutWrite).toHaveBeenCalledTimes(1); - const logLine = String(stdoutWrite.mock.calls[0]?.[0]); - expect(logLine).toBe( - JSON.stringify({ - amount: "9007199254740993", - txHash: byte32FromByte("44"), - ElapsedSeconds: 2, - }) + "\n", - ); - expect(JSON.parse(logLine)).toMatchObject({ - amount: "9007199254740993", - txHash: byte32FromByte("44"), - ElapsedSeconds: 2, - }); - - now.mockRestore(); - stdoutWrite.mockRestore(); - }); - - it("writes one JSON line with bigint-safe event serialization", () => { - const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - writeJsonLine({ - type: "bot.decision.skipped", - amount: 9007199254740993n, - observedAt: new Date("2026-01-02T03:04:05.006Z"), - invalidAt: new Date(Number.NaN), - }); - - expect(stdoutWrite).toHaveBeenCalledTimes(1); - const parsed = JSON.parse(String(stdoutWrite.mock.calls[0]?.[0])) as { - type: string; - amount: string; - observedAt: string; - invalidAt: null; - }; - expect(parsed).toEqual({ - type: "bot.decision.skipped", - amount: "9007199254740993", - observedAt: "2026-01-02T03:04:05.006Z", - invalidAt: null, - }); - expect(String(stdoutWrite.mock.calls[0]?.[0])).toMatch(/\n$/u); - - stdoutWrite.mockRestore(); - }); - - it("preserves CKB debugging metadata", () => { - const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const txHash = byte32FromByte("55"); - - writeJsonLine({ - txHash, - witness: "witnesses: 0x" + "22".repeat(80), - witnesses: ["0x" + "22".repeat(80)], - inputs: [{}], - outputs: [{}], - outputsData: ["0x" + "22".repeat(80)], - cellDeps: [{}], - headerDeps: [{}], - signedTx: "signed transaction 0x" + "33".repeat(80), - tx: { inputs: [], outputs: [], witnesses: [] }, - rawTransaction: { inputs: [], outputs: [], witnesses: [] }, - script: JSON.stringify({ - codeHash: "0x" + "44".repeat(32), - hashType: "type", - args: "0x" + "55".repeat(20), - }), - lock: { codeHash: "0x" + "44".repeat(32), hashType: "type", args: "0x" }, - cell: { cellOutput: { lock: script("66") } }, - transactionShape: { inputs: 1, outputs: 2, witnesses: 3 }, - env: "testnet", - environment: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, - config: { chain: "testnet" }, - }); - - const parsed = JSON.parse(String(stdoutWrite.mock.calls[0]?.[0])) as { - txHash: string; - witness: string; - witnesses: string[]; - inputs: unknown[]; - outputs: unknown[]; - outputsData: string[]; - cellDeps: unknown[]; - headerDeps: unknown[]; - signedTx: string; - tx: { inputs: unknown[]; outputs: unknown[]; witnesses: unknown[] }; - rawTransaction: { inputs: unknown[]; outputs: unknown[]; witnesses: unknown[] }; - script: string; - lock: { codeHash: string; hashType: string; args: string }; - cell: { cellOutput: { lock: unknown } }; - transactionShape: { inputs: number; outputs: number; witnesses: number }; - env: string; - environment: { BOT_CONFIG_FILE: string }; - config: { chain: string }; - }; - expect(parsed.txHash).toBe(txHash); - expect(parsed.witness).toBe("witnesses: 0x" + "22".repeat(80)); - expect(parsed.witnesses).toEqual(["0x" + "22".repeat(80)]); - expect(parsed.inputs).toEqual([{}]); - expect(parsed.outputs).toEqual([{}]); - expect(parsed.outputsData).toEqual(["0x" + "22".repeat(80)]); - expect(parsed.cellDeps).toEqual([{}]); - expect(parsed.headerDeps).toEqual([{}]); - expect(parsed.signedTx).toBe("signed transaction 0x" + "33".repeat(80)); - expect(parsed.tx).toEqual({ inputs: [], outputs: [], witnesses: [] }); - expect(parsed.rawTransaction).toEqual({ inputs: [], outputs: [], witnesses: [] }); - expect(parsed.script).toBe(JSON.stringify({ - codeHash: "0x" + "44".repeat(32), - hashType: "type", - args: "0x" + "55".repeat(20), - })); - expect(parsed.lock).toEqual({ codeHash: "0x" + "44".repeat(32), hashType: "type", args: "0x" }); - expect(parsed.cell).toHaveProperty("cellOutput"); - expect(parsed.transactionShape).toEqual({ inputs: 1, outputs: 2, witnesses: 3 }); - expect(parsed.env).toBe("testnet"); - expect(parsed.environment).toEqual({ BOT_CONFIG_FILE: "/run/credentials/config.json" }); - expect(parsed.config).toEqual({ chain: "testnet" }); - - stdoutWrite.mockRestore(); - }); - -}); - -function sequence(...values: number[]): () => number { - let index = 0; - return () => values[index++] ?? 0; -} - -function transactionError(isTimeout: boolean, txHash = byte32FromByte("11")): Error { - return Object.assign(new Error("Transaction confirmation timed out"), { - name: "TransactionConfirmationError", - txHash, - status: isTimeout ? "sent" : "rejected", - isTimeout, - }); -} - -function capacityCell(capacity: bigint, lock: ccc.Script, txByte: string): ccc.Cell { - return ccc.Cell.from({ - outPoint: { txHash: byte32FromByte(txByte), index: 0n }, - cellOutput: { capacity, lock }, - outputData: "0x", - }); -} - -function preflightClient({ - addressPrefix, - genesisHash, - tipHash, - tipNumber, - tipTimestamp, -}: { - addressPrefix: string; - genesisHash: `0x${string}`; - tipHash: `0x${string}`; - tipNumber: bigint; - tipTimestamp: bigint; -}): ChainPreflightClient { - return { - addressPrefix, - getHeaderByNumber: async (blockNumber): Promise => { - await Promise.resolve(); - if (blockNumber !== 0n) { - return; - } - return headerLike({ hash: genesisHash, number: 0n }); - }, - getTipHeader: async (): Promise => { - await Promise.resolve(); - return headerLike({ hash: tipHash, number: tipNumber, timestamp: tipTimestamp }); - }, - }; -} diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index 3d0560d..a630d83 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -1,618 +1,35 @@ -import { ccc } from "@ckb-ccc/core"; -import { unique } from "@ickb/utils"; -import { readFile } from "node:fs/promises"; -import { isAbsolute, resolve } from "node:path"; -import process from "node:process"; -import { setTimeout } from "node:timers"; - -const CKB = 100000000n; -const SECP256K1_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); -const MAX_TIMER_DELAY_MS = 2_147_483_647; - -export const STOP_EXIT_CODE = 2; - -export type SupportedChain = "mainnet" | "testnet"; - -export interface ChainIdentity { - chain: SupportedChain; - networkName: string; - genesisHash: ccc.Hex; - genesisMessage: string; - genesisSource: string; - addressPrefix: "ckb" | "ckt"; -} - -export const CHAIN_IDENTITIES = { - mainnet: { - chain: "mainnet", - networkName: "ckb", - genesisHash: "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5", - genesisMessage: "lina 0x18e020f6b1237a3d06b75121f25a7efa0550e4b3f44f974822f471902424c104", - genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/mainnet.toml", - addressPrefix: "ckb", - }, - testnet: { - chain: "testnet", - networkName: "ckb_testnet", - genesisHash: "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606", - genesisMessage: "aggron-v4", - genesisSource: "https://raw.githubusercontent.com/nervosnetwork/ckb/develop/resource/specs/testnet.toml", - addressPrefix: "ckt", - }, -} as const satisfies Record; - -export type ChainPreflightClient = Pick< - ccc.Client, - "addressPrefix" | "getHeaderByNumber" | "getTipHeader" ->; - -export interface ChainPreflightEvidence { - chain: SupportedChain; - expected: ChainIdentity; - observed: { - genesisHash: ccc.Hex; - addressPrefix: string; - tip: { - hash: ccc.Hex; - number: bigint; - timestamp: bigint; - }; - }; - matches: { - genesisHash: boolean; - addressPrefix: boolean; - }; -} - -export function expectedChainIdentity(chain: SupportedChain): ChainIdentity { - return CHAIN_IDENTITIES[chain]; -} - -export async function readChainPreflight( - client: ChainPreflightClient, - chain: SupportedChain, -): Promise { - const expected = expectedChainIdentity(chain); - const [genesis, tip] = await Promise.all([ - client.getHeaderByNumber(0n), - client.getTipHeader(), - ]); - - if (genesis === undefined) { - throw new Error(`Missing ${chain} genesis header`); - } - - return { - chain, - expected, - observed: { - genesisHash: genesis.hash, - addressPrefix: client.addressPrefix, - tip: { - hash: tip.hash, - number: tip.number, - timestamp: tip.timestamp, - }, - }, - matches: { - genesisHash: genesis.hash === expected.genesisHash, - addressPrefix: client.addressPrefix === expected.addressPrefix, - }, - }; -} - -export function assertChainPreflight( - evidence: ChainPreflightEvidence, -): ChainPreflightEvidence { - const failures: string[] = []; - if (evidence.observed.genesisHash !== evidence.expected.genesisHash) { - failures.push( - `genesis hash expected ${evidence.expected.genesisHash} observed ${evidence.observed.genesisHash}`, - ); - } - if (evidence.observed.addressPrefix !== evidence.expected.addressPrefix) { - failures.push( - `address prefix expected ${evidence.expected.addressPrefix} observed ${evidence.observed.addressPrefix}`, - ); - } - if (failures.length > 0) { - throw new Error(`Invalid ${evidence.chain} RPC chain identity: ${failures.join("; ")}`); - } - - return evidence; -} - -export async function verifyChainPreflight( - client: ChainPreflightClient, - chain: SupportedChain, -): Promise { - try { - return assertChainPreflight(await readChainPreflight(client, chain)); - } catch (error) { - if (isPublicChainPreflightFailure(error, chain)) { - throw new Error(errorMessage(error), { - cause: errorToLogValue(error, new WeakSet()), - }); - } - if (isRetryableRpcTransportError(error)) { - throw new Error("fetch failed", { - cause: { name: "TypeError", message: "fetch failed" }, - }); - } - throw new Error(`Failed to verify ${chain} RPC chain identity`, { - cause: safePreflightFailureCause(error), - }); - } -} - -function safePreflightFailureCause(error: unknown): { name: string } | { type: string } { - if (error instanceof Error) { - return { name: safeErrorName(error.name) }; - } - return { type: error === null ? "null" : typeof error }; -} - -function safeErrorName(name: string): string { - return /^[A-Za-z][\w.-]{0,63}$/u.test(name) ? name : "Error"; -} - -function isPublicChainPreflightFailure(error: unknown, chain: SupportedChain): boolean { - const message = errorMessage(error); - return message === `Missing ${chain} genesis header` || - message.startsWith(`Invalid ${chain} RPC chain identity:`); -} - -function errorMessage(error: unknown): string { - return typeof error === "string" - ? error - : error instanceof Error - ? error.message - : stringifyErrorMessage(error); -} - -function stringifyErrorMessage(error: unknown): string { - if (error === undefined || error === null) { - return "Unknown error"; - } - try { - return JSON.stringify(toJsonLogValue(error, new WeakSet()), jsonLogReplacer); - } catch { - return "Unknown error"; - } -} - -export function formatCkb(balance: bigint): string { - const sign = balance < 0n ? "-" : ""; - const absolute = balance < 0n ? -balance : balance; - const whole = absolute / CKB; - const fraction = absolute % CKB; - - if (fraction === 0n) { - return sign + whole.toString(); - } - - return `${sign}${whole.toString()}.${fraction.toString().padStart(8, "0").replace(/0+$/u, "")}`; -} - -export function jsonLogReplacer(_: unknown, value: unknown): unknown { - return typeof value === "bigint" ? value.toString() : value; -} - -const UNSAFE_LOG_VALUE = "[Unsupported log value]"; - -export function isRetryableRpcTransportError(error: unknown): boolean { - if (error instanceof TypeError && error.message === "fetch failed") { - return true; - } - if (!(error instanceof Error) || error.message !== "fetch failed") { - return false; - } - const cause = "cause" in error ? error.cause : undefined; - return typeof cause === "object" && cause !== null && - "name" in cause && cause.name === "TypeError" && - "message" in cause && cause.message === "fetch failed"; -} - -export function isRetryableCkbStateRaceError(error: unknown): boolean { - if (typeof error !== "object" || error === null) { - return false; - } - const code = "code" in error ? error.code : undefined; - const data = "data" in error ? error.data : undefined; - if (typeof code !== "number" || typeof data !== "string") { - return false; - } - return (code === -1111 && data.includes("RBFRejected(")) || - (code === -301 && ( - data.includes("Resolve(Unknown(OutPoint(") || - data.includes("Resolve(Dead(OutPoint(") - )) || - (code === -1107 && data.includes("Duplicated(Byte32(")); -} - -export function parseSleepInterval( - intervalSeconds: number | undefined, - envName: string, -): number { - if (intervalSeconds === undefined || !Number.isFinite(intervalSeconds) || intervalSeconds < 1) { - throw new Error("Invalid env " + envName); - } - - const intervalMs = intervalSeconds * 1000; - if (!Number.isSafeInteger(intervalMs) || intervalMs > Math.floor(MAX_TIMER_DELAY_MS / 2)) { - throw new Error("Invalid env " + envName); - } - - return intervalMs; -} - -export function parsePrivateKey(privateKey: string, envName: string): `0x${string}` { - if (/^0x[0-9a-f]{64}$/u.test(privateKey)) { - const value = BigInt(privateKey); - if (value > 0n && value < SECP256K1_ORDER) { - return privateKey as `0x${string}`; - } - } - - throw new Error("Invalid env " + envName); -} - -export type RuntimeConfig = { - chain: SupportedChain; - privateKey: `0x${string}`; - rpcUrl?: string; - sleepIntervalMs: number; - maxIterations: number | undefined; - maxRetryableAttempts: number | undefined; -}; - -export function parseRpcUrl(rpcUrl: string, envName: string): string { - for (let index = 0; index < rpcUrl.length; index += 1) { - const code = rpcUrl.charCodeAt(index); - if (/\s/u.test(rpcUrl[index] ?? "") || code < 0x20 || code === 0x7f) { - throw new Error("Invalid env " + envName); - } - } - if (rpcUrl === "") { - throw new Error("Invalid env " + envName); - } - let url: URL; - try { - url = new URL(rpcUrl); - } catch { - throw new Error("Invalid env " + envName); - } - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new Error("Invalid env " + envName); - } - return rpcUrl; -} - -function parseOptionalRpcUrl(rpcUrl: unknown, envName: string): string | undefined { - if (rpcUrl === undefined) { - return undefined; - } - if (typeof rpcUrl !== "string") { - throw new Error("Invalid env " + envName); - } - return parseRpcUrl(rpcUrl, envName); -} - -export function parseMaxIterations( - value: number | undefined, - envName: string, -): number | undefined { - return parsePositiveIntegerLimit(value, envName); -} - -function parseMaxRetryableAttempts( - value: number | undefined, - envName: string, -): number | undefined { - return parsePositiveIntegerLimit(value, envName); -} - -function parsePositiveIntegerLimit( - value: number | undefined, - envName: string, -): number | undefined { - if (value === undefined) { - return; - } - - if (!Number.isSafeInteger(value) || value < 1) { - throw new Error("Invalid env " + envName); - } - - return value; -} - -export function reachedMaxIterations( - completedIterations: number, - maxIterations: number | undefined, -): boolean { - return maxIterations !== undefined && completedIterations >= maxIterations; -} - -export function randomSleepIntervalMs( - sleepIntervalMs: number, - random: () => number = Math.random, -): number { - // Sum of two uniforms gives bounded triangular jitter centered on the configured interval. - return Math.floor(sleepIntervalMs * (random() + random())); -} - -export function parseRuntimeConfig(configText: string, envName: string): RuntimeConfig { - let config: unknown; - try { - config = JSON.parse(configText); - } catch { - throw new Error("Invalid env " + envName); - } - if (typeof config !== "object" || config === null || Array.isArray(config)) { - throw new Error("Invalid env " + envName); - } - - const record = config as Record; - for (const key of Object.keys(record)) { - if ( - key !== "chain" && - key !== "privateKey" && - key !== "rpcUrl" && - key !== "sleepIntervalSeconds" && - key !== "maxIterations" && - key !== "maxRetryableAttempts" - ) { - throw new Error("Invalid env " + envName); - } - } - if ( - typeof record.chain !== "string" || - typeof record.privateKey !== "string" || - typeof record.sleepIntervalSeconds !== "number" || - (record.maxIterations !== undefined && typeof record.maxIterations !== "number") || - (record.maxRetryableAttempts !== undefined && typeof record.maxRetryableAttempts !== "number") - ) { - throw new Error("Invalid env " + envName); - } - if (record.chain !== "mainnet" && record.chain !== "testnet") { - throw new Error("Invalid env " + envName); - } - - return { - chain: record.chain, - privateKey: parsePrivateKey(record.privateKey, envName), - rpcUrl: parseOptionalRpcUrl(record.rpcUrl, envName), - sleepIntervalMs: parseSleepInterval(record.sleepIntervalSeconds, envName), - maxIterations: parseMaxIterations(record.maxIterations, envName), - maxRetryableAttempts: parseMaxRetryableAttempts(record.maxRetryableAttempts, envName), - }; -} - -export async function readRuntimeConfigEnv( - fileEnvValue: string | undefined, - fileEnvName: string, -): Promise { - if (fileEnvValue === undefined || fileEnvValue === "") { - throw new Error(`Empty env ${fileEnvName}`); - } - - return parseRuntimeConfig(await readFileEnv(fileEnvValue, fileEnvName), fileEnvName); -} - -async function readFileEnv(fileEnvValue: string, fileEnvName: string): Promise { - const secretPath = isAbsolute(fileEnvValue) - ? fileEnvValue - : resolve(process.env.INIT_CWD ?? process.cwd(), fileEnvValue); - let fileSecret: string; - try { - fileSecret = await readFile(secretPath, "utf8"); - } catch (cause) { - throw new Error(`Invalid file from env ${fileEnvName}`, { cause }); - } - if (fileSecret === "") { - throw new Error(`Empty file from env ${fileEnvName}`); - } - return fileSecret; -} - -export function createPublicClient( - chain: SupportedChain, - rpcUrl: string | undefined, -): ccc.Client { - const config = rpcUrl ? { url: rpcUrl } : undefined; - return chain === "mainnet" - ? new ccc.ClientPublicMainnet(config) - : new ccc.ClientPublicTestnet(config); -} - -export async function signerAccountLocks( - signer: ccc.Signer, - primaryLock: ccc.Script, -): Promise { - return [...unique([ - primaryLock, - ...(await signer.getAddressObjs()).map(({ script }) => script), - ])]; -} - -export function accountPlainCkbBalance( - capacityCells: readonly ccc.Cell[], - accountLocks: readonly ccc.Script[], -): bigint { - const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); - return capacityCells.reduce( - (total, cell) => total + plainCapacity(cell.cellOutput, cell.outputData, accountLockHexes), - 0n, - ); -} - -export function postTransactionAccountPlainCkbBalance( - tx: ccc.Transaction, - capacityCells: readonly ccc.Cell[], - accountLocks: readonly ccc.Script[], -): bigint { - const accountLockHexes = new Set(accountLocks.map((lock) => lock.toHex())); - const spentOutPoints = new Set(tx.inputs.map((input) => input.previousOutput.toHex())); - const unspentCapacity = capacityCells.reduce( - (total, cell) => - spentOutPoints.has(cell.outPoint.toHex()) - ? total - : total + plainCapacity(cell.cellOutput, cell.outputData, accountLockHexes), - 0n, - ); - const outputCapacity = tx.outputs.reduce( - (total, output, index) => total + plainCapacity(output, tx.outputsData[index], accountLockHexes), - 0n, - ); - - return unspentCapacity + outputCapacity; -} - -function plainCapacity(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: Set): bigint { - return isAccountPlainCapacityOutput(output, outputData, accountLockHexes) ? output.capacity : 0n; -} - -function isAccountPlainCapacityOutput(output: ccc.CellOutput, outputData: string | undefined, accountLockHexes: Set): boolean { - return output.type === undefined && (outputData ?? "0x") === "0x" && accountLockHexes.has(output.lock.toHex()); -} - -function errorToLog(error: unknown): unknown { - return errorToLogValue(error, new WeakSet()); -} - -function errorToLogValue( - error: unknown, - seen: WeakSet, -): unknown { - if (error instanceof Object && "stack" in error) { - if (seen.has(error)) { - return "[Circular]"; - } - seen.add(error); - const stack = typeof error.stack === "string" ? error.stack : ""; - const message = "message" in error && typeof error.message === "string" - ? error.message - : "Unknown error"; - const logged: Record = { - ...errorOwnProperties(error, seen), - name: "name" in error ? error.name : undefined, - message, - txHash: "txHash" in error ? error.txHash : undefined, - status: "status" in error ? error.status : undefined, - isTimeout: "isTimeout" in error ? error.isTimeout : undefined, - stack, - }; - try { - if ("cause" in error) { - logged.cause = errorToLogValue(error.cause, seen); - } - return logged; - } finally { - seen.delete(error); - } - } - - if (typeof error === "object" && error !== null) { - return toJsonLogValue(error, seen); - } - - if (typeof error === "string") { - return error; - } - - return error ?? "Empty Error"; -} - -const ERROR_BUILTIN_KEYS = new Set(["name", "message", "stack", "cause"]); - -function errorOwnProperties(error: object, seen: WeakSet): Record { - const properties: Record = {}; - for (const [key, entry] of Object.entries(error)) { - if (ERROR_BUILTIN_KEYS.has(key)) { - continue; - } - properties[key] = toJsonLogValue(entry, seen); - } - return properties; -} - -function toJsonLogValue( - value: unknown, - seen: WeakSet, -): unknown { - if (typeof value === "string") { - return value; - } - if (typeof value === "bigint") { - return value.toString(); - } - if (typeof value === "function") { - return UNSAFE_LOG_VALUE; - } - if (typeof value !== "object" || value === null) { - return value; - } - if (value instanceof Date) { - return Number.isNaN(value.getTime()) ? null : value.toISOString(); - } - if (value instanceof Object && "stack" in value) { - return errorToLogValue(value, seen); - } - if (seen.has(value)) { - return "[Circular]"; - } - seen.add(value); - try { - if (Array.isArray(value)) { - return value.map((entry) => toJsonLogValue(entry, seen)); - } - const sanitized: Record = {}; - for (const [key, entry] of Object.entries(value)) { - sanitized[key] = toJsonLogValue(entry, seen); - } - return sanitized; - } finally { - seen.delete(value); - } -} - -function shouldStopAfterError(error: unknown): boolean { - return error instanceof Error && - error.name === "TransactionConfirmationError" && - "isTimeout" in error && - error.isTimeout === true; -} - -export function handleLoopError( - executionLog: Record, - error: unknown, -): boolean { - executionLog.error = errorToLog(error); - if (shouldStopAfterError(error)) { - process.exitCode = STOP_EXIT_CODE; - return true; - } - - return false; -} - -export function logExecution( - executionLog: Record, - startTime: Date, -): void { - executionLog.ElapsedSeconds = Math.round( - (Date.now() - startTime.getTime()) / 1000, - ); - writeJsonLine(executionLog); -} - -export function writeJsonLine(record: unknown): void { - process.stdout.write(`${JSON.stringify(toJsonLogValue(record, new WeakSet()), jsonLogReplacer)}\n`); -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} +/** + * Shared Node.js runtime helpers for iCKB apps. + * + * @packageDocumentation + */ + +export { + accountPlainCkbBalance, + postTransactionAccountPlainCkbBalance, + signerAccountLocks, +} from "./account.ts"; +export { createPublicClient, verifyChainPreflight } from "./chain.ts"; +export type { ChainPreflightEvidence, SupportedChain } from "./chain.ts"; +export { formatCkb } from "./format.ts"; +export { + STOP_EXIT_CODE, + handleLoopError, + jsonLogReplacer, + logExecution, + writeJsonLine, +} from "./logging.ts"; +export type { JsonLogValue } from "./logging.ts"; +export { + isRetryableCkbStateRaceError, + isRetryableRpcResponseShapeError, + isRetryableRpcTransportError, +} from "./retryable.ts"; +export { + parseRuntimeConfig, + randomSleepIntervalMs, + reachedMaxIterations, + readRuntimeConfigEnv, + sleep, +} from "./runtime_config.ts"; +export type { RuntimeConfig } from "./runtime_config.ts"; diff --git a/packages/node-utils/src/logging.ts b/packages/node-utils/src/logging.ts new file mode 100644 index 0000000..2efdeea --- /dev/null +++ b/packages/node-utils/src/logging.ts @@ -0,0 +1,215 @@ +import process from "node:process"; + +const UNKNOWN_ERROR_MESSAGE = "Unknown error"; +const MESSAGE_KEY = "message"; +const CIRCULAR_LOG_VALUE = "[Circular]"; +const UNSAFE_LOG_VALUE = "[Unsupported log value]"; +const ERROR_BUILTIN_KEYS = new Set(["name", "message", "stack", "cause"]); + +/** Process exit code used when a loop stops after a confirmation timeout. */ +export const STOP_EXIT_CODE = 2; + +type JsonLogPrimitive = string | number | boolean | symbol | null | undefined; + +/** JSON-line-safe value after log normalization. */ +export type JsonLogValue = JsonLogPrimitive | JsonLogValue[] | JsonLogRecord; + +interface JsonLogRecord { + [key: string]: JsonLogValue; +} + +/** + * Records a JSON-safe error on the execution log and returns true when the loop should stop. + */ +export function handleLoopError( + executionLog: Record, + error: unknown, +): boolean { + const log = executionLog; + log["error"] = errorToLog(error); + if (shouldStopAfterError(error)) { + process.exitCode = STOP_EXIT_CODE; + return true; + } + + return false; +} + +/** + * Adds elapsed time to an execution log and writes it as one JSON line. + */ +export function logExecution( + executionLog: Record, + startTime: Date, +): void { + const log = executionLog; + log["ElapsedSeconds"] = Math.round((Date.now() - startTime.getTime()) / 1000); + writeJsonLine(log); +} + +/** + * Writes a record as one JSON line to stdout with bigint and cycle-safe conversion. + */ +export function writeJsonLine(record: unknown): void { + process.stdout.write( + `${JSON.stringify(toJsonLogValue(record, new WeakSet()), jsonLogReplacer)}\n`, + ); +} + +/** + * Converts bigint values to strings for JSON log serialization. + */ +export function jsonLogReplacer(_: string, value: JsonLogValue | bigint): JsonLogValue { + return typeof value === "bigint" ? value.toString() : value; +} + +function errorToLog(error: unknown): JsonLogValue { + return toJsonLogValue(error, new WeakSet()); +} + +/** + * Converts an unknown value into a JSON-line-safe log value. + * + * @remarks + * Bigints become decimal strings, valid dates become ISO strings, invalid dates + * become `null`, cycles become `[Circular]`, and functions become + * `[Unsupported log value]`. Error-like objects keep public metadata such as + * `name`, `message`, `stack`, `cause`, `txHash`, `status`, and `isTimeout`. + * This normalizer makes values serializable; it does not sanitize arbitrary + * secrets that callers pass in. + */ +export function toJsonLogValue(value: unknown, seen: WeakSet): JsonLogValue { + let logValue: JsonLogValue; + if (typeof value === "string") { + logValue = value; + } else if (typeof value === "bigint") { + logValue = value.toString(); + } else if (typeof value === "function") { + logValue = UNSAFE_LOG_VALUE; + } else if (isJsonLogPrimitive(value)) { + logValue = value ?? "Empty Error"; + } else if (value instanceof Date) { + logValue = dateLogValue(value); + } else if (isErrorLike(value)) { + logValue = errorLikeToLogValue(value, seen, toJsonLogValue); + } else { + logValue = objectLogValue(value, seen, toJsonLogValue); + } + + return logValue; +} + +function isJsonLogPrimitive(value: unknown): value is JsonLogPrimitive { + return typeof value !== "object" || value === null; +} + +function dateLogValue(value: Date): string | null { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); +} + +function isErrorLike(value: unknown): value is object & { stack?: unknown } { + return value instanceof Object && "stack" in value; +} + +function errorLikeToLogValue( + error: object & { stack?: unknown }, + seen: WeakSet, + convert: (value: unknown, seen: WeakSet) => JsonLogValue, +): JsonLogValue { + let logValue: JsonLogValue = CIRCULAR_LOG_VALUE; + if (!seen.has(error)) { + seen.add(error); + try { + const logged: JsonLogRecord = { + ...errorOwnProperties(error, seen, convert), + name: logPropertyIfPresent(error, "name", seen, convert), + message: errorLogMessage(error), + txHash: logPropertyIfPresent(error, "txHash", seen, convert), + status: logPropertyIfPresent(error, "status", seen, convert), + isTimeout: logPropertyIfPresent(error, "isTimeout", seen, convert), + stack: typeof error.stack === "string" ? error.stack : "", + }; + if ("cause" in error) { + logged["cause"] = convert(Reflect.get(error, "cause"), seen); + } + logValue = logged; + } finally { + seen.delete(error); + } + } + + return logValue; +} + +function objectLogValue( + value: object, + seen: WeakSet, + convert: (value: unknown, seen: WeakSet) => JsonLogValue, +): JsonLogValue { + let logValue: JsonLogValue = CIRCULAR_LOG_VALUE; + if (!seen.has(value)) { + seen.add(value); + try { + logValue = Array.isArray(value) + ? value.map((entry): JsonLogValue => convert(entry, seen)) + : objectEntriesLogValue(value, seen, convert); + } finally { + seen.delete(value); + } + } + + return logValue; +} + +function logPropertyIfPresent( + value: object, + key: string, + seen: WeakSet, + convert: (value: unknown, seen: WeakSet) => JsonLogValue, +): JsonLogValue { + if (!(key in value)) { + return undefined; + } + return convert(Reflect.get(value, key), seen); +} + +function errorLogMessage(error: object): string { + const message = MESSAGE_KEY in error ? Reflect.get(error, MESSAGE_KEY) : undefined; + return typeof message === "string" ? message : UNKNOWN_ERROR_MESSAGE; +} + +function errorOwnProperties( + error: object, + seen: WeakSet, + convert: (value: unknown, seen: WeakSet) => JsonLogValue, +): JsonLogRecord { + const properties: JsonLogRecord = {}; + for (const [key, entry] of Object.entries(error)) { + if (ERROR_BUILTIN_KEYS.has(key)) { + continue; + } + properties[key] = convert(entry, seen); + } + return properties; +} + +function objectEntriesLogValue( + value: object, + seen: WeakSet, + convert: (value: unknown, seen: WeakSet) => JsonLogValue, +): JsonLogRecord { + const jsonValue: JsonLogRecord = {}; + for (const [key, entry] of Object.entries(value)) { + jsonValue[key] = convert(entry, seen); + } + return jsonValue; +} + +function shouldStopAfterError(error: unknown): boolean { + return ( + error instanceof Error && + error.name === "TransactionConfirmationError" && + "isTimeout" in error && + error.isTimeout === true + ); +} diff --git a/packages/node-utils/src/retryable.ts b/packages/node-utils/src/retryable.ts new file mode 100644 index 0000000..e933676 --- /dev/null +++ b/packages/node-utils/src/retryable.ts @@ -0,0 +1,79 @@ +const MESSAGE_KEY = "message"; + +/** Normalized message used for retryable fetch transport failures. */ +export const FETCH_FAILED_MESSAGE = "fetch failed"; + +/** + * Returns true for transient fetch transport failures surfaced by the RPC client. + */ +export function isRetryableRpcTransportError(error: unknown): boolean { + if (error instanceof TypeError && error.message === FETCH_FAILED_MESSAGE) { + return true; + } + if (!(error instanceof Error) || error.message !== FETCH_FAILED_MESSAGE) { + return false; + } + const cause = "cause" in error ? error.cause : undefined; + return isFetchFailedTypeErrorCause(cause); +} + +/** + * Returns true for malformed or mismatched RPC responses that can be retried. + */ +export function isRetryableRpcResponseShapeError(error: unknown): boolean { + return ( + error instanceof Error && + (/^Id mismatched, got .+, expected \d+$/u.test(error.message) || + (error.name === "SyntaxError" && + /^Unexpected token '<', .+ is not valid JSON$/u.test(error.message))) + ); +} + +/** + * Returns true for CKB pool/indexer state races that may succeed after retry. + */ +export function isRetryableCkbStateRaceError(error: unknown): boolean { + const parsed = rpcErrorCodeAndData(error); + return parsed !== undefined && isRetryableStateRaceData(parsed.code, parsed.data); +} + +function isFetchFailedTypeErrorCause(cause: unknown): boolean { + return ( + typeof cause === "object" && + cause !== null && + "name" in cause && + cause.name === "TypeError" && + MESSAGE_KEY in cause && + cause.message === FETCH_FAILED_MESSAGE + ); +} + +interface RpcErrorCodeAndData { + code: number; + data: string; +} + +function rpcErrorCodeAndData(error: unknown): RpcErrorCodeAndData | undefined { + if (typeof error !== "object" || error === null) { + return undefined; + } + const code = "code" in error ? error.code : undefined; + const data = "data" in error ? error.data : undefined; + if (typeof code !== "number" || typeof data !== "string") { + return undefined; + } + return { code, data }; +} + +const RETRYABLE_STATE_RACE_MARKERS = new Map([ + [-1111, ["RBFRejected("]], + [-301, ["Resolve(Unknown(OutPoint(", "Resolve(Dead(OutPoint("]], + [-1107, ["Duplicated(Byte32("]], +]); + +function isRetryableStateRaceData(code: number, data: string): boolean { + return ( + RETRYABLE_STATE_RACE_MARKERS.get(code)?.some((marker) => data.includes(marker)) ?? + false + ); +} diff --git a/packages/node-utils/src/runtime_config.ts b/packages/node-utils/src/runtime_config.ts new file mode 100644 index 0000000..de4362f --- /dev/null +++ b/packages/node-utils/src/runtime_config.ts @@ -0,0 +1,299 @@ +import path from "node:path"; +import process from "node:process"; +import { setTimeout } from "node:timers"; +import type { SupportedChain } from "./chain.ts"; + +const SECP256K1_ORDER = + 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const INVALID_ENV_MESSAGE = "Invalid env "; +const CHAIN_KEY = "chain"; +const PRIVATE_KEY_KEY = "privateKey"; +const RPC_URL_KEY = "rpcUrl"; +const SLEEP_INTERVAL_SECONDS_KEY = "sleepIntervalSeconds"; +const MAX_ITERATIONS_KEY = "maxIterations"; +const MAX_RETRYABLE_ATTEMPTS_KEY = "maxRetryableAttempts"; +const RUNTIME_CONFIG_KEYS = new Set([ + CHAIN_KEY, + PRIVATE_KEY_KEY, + RPC_URL_KEY, + SLEEP_INTERVAL_SECONDS_KEY, + MAX_ITERATIONS_KEY, + MAX_RETRYABLE_ATTEMPTS_KEY, +]); + +/** Runtime configuration loaded from a secret-backed JSON file. */ +export interface RuntimeConfig { + /** Public CKB chain expected by the app. */ + chain: SupportedChain; + + /** Secp256k1 private key used only for signing. */ + privateKey: `0x${string}`; + + /** Optional RPC URL override for the selected public chain. */ + rpcUrl?: string; + + /** Loop sleep interval in milliseconds, parsed from `sleepIntervalSeconds`. */ + sleepIntervalMs: number; + + /** Optional maximum completed loop iterations before stopping. */ + maxIterations: number | undefined; + + /** Optional maximum retryable failures before stopping. */ + maxRetryableAttempts: number | undefined; +} + +/** + * Reads and validates a JSON runtime config from the file named by an environment value. + * + * @remarks + * Relative file paths resolve against `INIT_CWD` when present, otherwise + * `process.cwd()`. Invalid file contents throw generic env-name errors so config + * values and signing material are not copied into logs. + */ +export async function readRuntimeConfigEnv( + fileEnvValue: string | undefined, + fileEnvName: string, +): Promise { + if (fileEnvValue === undefined || fileEnvValue === "") { + throw new Error(`Empty env ${fileEnvName}`); + } + + return parseRuntimeConfig(await readFileEnv(fileEnvValue, fileEnvName), fileEnvName); +} + +/** + * Returns true once the configured loop iteration limit has been reached. + */ +export function reachedMaxIterations( + completedIterations: number, + maxIterations: number | undefined, +): boolean { + return maxIterations !== undefined && completedIterations >= maxIterations; +} + +/** + * Returns a jittered sleep interval centered on the configured interval. + */ +export function randomSleepIntervalMs( + sleepIntervalMs: number, + random: () => number = Math.random, +): number { + // Sum of two uniforms gives bounded triangular jitter centered on the configured interval. + return Math.floor(sleepIntervalMs * (random() + random())); +} + +/** + * Resolves after the given number of milliseconds. + */ +export async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function readFileEnv(fileEnvValue: string, fileEnvName: string): Promise { + const secretPath = path.isAbsolute(fileEnvValue) + ? fileEnvValue + : path.resolve(process.env["INIT_CWD"] ?? process.cwd(), fileEnvValue); + let fileSecret: string; + try { + const fileSystem = await import("node:fs/promises"); + fileSecret = await fileSystem.readFile(secretPath, "utf8"); + } catch (cause) { + throw new Error(`Invalid file from env ${fileEnvName}`, { cause }); + } + if (fileSecret === "") { + throw new Error(`Empty file from env ${fileEnvName}`); + } + return fileSecret; +} + +export function parseRuntimeConfig(configText: string, envName: string): RuntimeConfig { + const record = parseRuntimeConfigRecord(configText, envName); + assertKnownRuntimeConfigKeys(record, envName); + const chain = parseSupportedChain(record[CHAIN_KEY], envName); + const privateKey = parseRequiredString(record[PRIVATE_KEY_KEY], envName); + const rpcUrl = parseOptionalRpcUrl(record[RPC_URL_KEY], envName); + const sleepIntervalSeconds = parseRequiredNumber( + record[SLEEP_INTERVAL_SECONDS_KEY], + envName, + ); + const maxIterations = parseOptionalNumber(record[MAX_ITERATIONS_KEY], envName); + const maxRetryableAttempts = parseOptionalNumber( + record[MAX_RETRYABLE_ATTEMPTS_KEY], + envName, + ); + + return { + chain, + privateKey: parsePrivateKey(privateKey, envName), + rpcUrl, + sleepIntervalMs: parseSleepInterval(sleepIntervalSeconds, envName), + maxIterations: parseMaxIterations(maxIterations, envName), + maxRetryableAttempts: parseMaxRetryableAttempts(maxRetryableAttempts, envName), + }; +} + +function parseRuntimeConfigRecord( + configText: string, + envName: string, +): Record { + let config: unknown; + try { + config = JSON.parse(configText); + } catch { + throw invalidEnvError(envName); + } + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw invalidEnvError(envName); + } + return Object.fromEntries(Object.entries(config)); +} + +function assertKnownRuntimeConfigKeys( + record: Record, + envName: string, +): void { + for (const key of Object.keys(record)) { + if (!RUNTIME_CONFIG_KEYS.has(key)) { + throw invalidEnvError(envName); + } + } +} + +function parseSupportedChain(value: unknown, envName: string): SupportedChain { + if (value !== "mainnet" && value !== "testnet") { + throw invalidEnvError(envName); + } + return value; +} + +function parseOptionalRpcUrl(rpcUrl: unknown, envName: string): string | undefined { + if (rpcUrl === undefined) { + return undefined; + } + if (typeof rpcUrl !== "string") { + throw invalidEnvError(envName); + } + return parseRpcUrl(rpcUrl, envName); +} + +function parseOptionalNumber(value: unknown, envName: string): number | undefined { + if (value === undefined) { + return undefined; + } + return parseRequiredNumber(value, envName); +} + +function parseRequiredString(value: unknown, envName: string): string { + if (typeof value !== "string") { + throw invalidEnvError(envName); + } + return value; +} + +function parseRequiredNumber(value: unknown, envName: string): number { + if (typeof value !== "number") { + throw invalidEnvError(envName); + } + return value; +} + +function parsePrivateKey(privateKey: string, envName: string): `0x${string}` { + if (isPrivateKeyHex(privateKey)) { + const value = BigInt(privateKey); + if (value > 0n && value < SECP256K1_ORDER) { + return privateKey; + } + } + + throw invalidEnvError(envName); +} + +function parseSleepInterval( + intervalSeconds: number | undefined, + envName: string, +): number { + if ( + intervalSeconds === undefined || + !Number.isFinite(intervalSeconds) || + intervalSeconds < 1 + ) { + throw invalidEnvError(envName); + } + + const intervalMs = intervalSeconds * 1000; + if ( + !Number.isSafeInteger(intervalMs) || + intervalMs > Math.floor(MAX_TIMER_DELAY_MS / 2) + ) { + throw invalidEnvError(envName); + } + + return intervalMs; +} + +function parseMaxIterations( + value: number | undefined, + envName: string, +): number | undefined { + return parsePositiveIntegerLimit(value, envName); +} + +function parseMaxRetryableAttempts( + value: number | undefined, + envName: string, +): number | undefined { + return parsePositiveIntegerLimit(value, envName); +} + +function parseRpcUrl(rpcUrl: string, envName: string): string { + for (let index = 0; index < rpcUrl.length; index += 1) { + const code = rpcUrl.codePointAt(index); + if ( + code === undefined || + /\s/u.test(rpcUrl[index] ?? "") || + code < 0x20 || + code === 0x7f + ) { + throw invalidEnvError(envName); + } + } + if (rpcUrl === "") { + throw invalidEnvError(envName); + } + let url: URL; + try { + url = new URL(rpcUrl); + } catch { + throw invalidEnvError(envName); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw invalidEnvError(envName); + } + return rpcUrl; +} + +function parsePositiveIntegerLimit( + value: number | undefined, + envName: string, +): number | undefined { + if (value === undefined) { + return undefined; + } + + if (!Number.isSafeInteger(value) || value < 1) { + throw invalidEnvError(envName); + } + + return value; +} + +function isPrivateKeyHex(value: string): value is `0x${string}` { + return /^0x[\da-f]{64}$/u.test(value); +} + +function invalidEnvError(envName: string): Error { + return new Error(INVALID_ENV_MESSAGE + envName); +} diff --git a/packages/node-utils/test/account.ts b/packages/node-utils/test/account.ts new file mode 100644 index 0000000..9ac1a60 --- /dev/null +++ b/packages/node-utils/test/account.ts @@ -0,0 +1,89 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + accountPlainCkbBalance, + postTransactionAccountPlainCkbBalance, + signerAccountLocks, +} from "../src/index.ts"; +import { + AddressStubSigner, + byte32FromByte, + capacityCell, + script, +} from "./support/node_utils_support.ts"; + +describe("account locks and balances", () => { + it("keeps the primary signer lock first and deduplicates account locks", async () => { + const primaryLock = script("11"); + const primaryLockCopy = ccc.Script.from(primaryLock); + const otherLock = script("22"); + const signer = new AddressStubSigner([ + new ccc.Address(otherLock, "ckt"), + new ccc.Address(primaryLockCopy, "ckt"), + ]); + + await expect(signerAccountLocks(signer, primaryLock)).resolves.toEqual([ + primaryLock, + otherLock, + ]); + }); + + it("counts account plain CKB from owned plain capacity cells only", () => { + const { lock, otherLock, unspent, typed, data } = accountCellFixture(); + + expect( + accountPlainCkbBalance( + [unspent, typed, data, capacityCell(100n, otherLock, "ee")], + [lock], + ), + ).toBe(ccc.fixedPointFrom(2000)); + }); + + it("counts post-transaction account plain CKB from unspent cells and new outputs", () => { + const { lock, otherLock, spent, unspent, typed, data } = accountCellFixture(); + const tx = ccc.Transaction.default(); + tx.inputs.push(ccc.CellInput.from({ previousOutput: spent.outPoint })); + tx.outputs.push( + ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(300), lock }), + ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(500), + lock, + type: script("33"), + }), + ccc.CellOutput.from({ capacity: ccc.fixedPointFrom(700), lock: otherLock }), + ); + tx.outputsData.push("0x", "0x", "0x"); + + expect( + postTransactionAccountPlainCkbBalance(tx, [spent, unspent, typed, data], [lock]), + ).toBe(ccc.fixedPointFrom(2300)); + }); +}); + +function accountCellFixture(): { + lock: ccc.Script; + otherLock: ccc.Script; + spent: ccc.Cell; + unspent: ccc.Cell; + typed: ccc.Cell; + data: ccc.Cell; +} { + const lock = script("11"); + const otherLock = script("22"); + return { + lock, + otherLock, + spent: capacityCell(ccc.fixedPointFrom(1000), lock, "aa"), + unspent: capacityCell(ccc.fixedPointFrom(2000), lock, "bb"), + typed: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("cc"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(4000), lock, type: script("33") }, + outputData: "0x", + }), + data: ccc.Cell.from({ + outPoint: { txHash: byte32FromByte("dd"), index: 0n }, + cellOutput: { capacity: ccc.fixedPointFrom(8000), lock }, + outputData: "0x1234", + }), + }; +} diff --git a/packages/node-utils/test/chain.ts b/packages/node-utils/test/chain.ts new file mode 100644 index 0000000..64fa620 --- /dev/null +++ b/packages/node-utils/test/chain.ts @@ -0,0 +1,256 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + createPublicClient, + isRetryableRpcTransportError, + verifyChainPreflight, +} from "../src/index.ts"; +import { + byte32FromByte, + FETCH_FAILED_MESSAGE, + MAINNET_GENESIS_HASH, + preflightClient, + RpcPreflightError, + TESTNET_GENESIS_HASH, +} from "./support/node_utils_support.ts"; + +const MISSING_TESTNET_GENESIS_HEADER = "Missing testnet genesis header"; +const TESTNET_PREFLIGHT_FAILURE_MESSAGE = "Failed to verify testnet RPC chain identity"; + +describe("public clients and preflight identity", () => { + it("creates network-specific public clients and forwards custom RPC URLs", () => { + const mainnet = createPublicClient("mainnet", "https://mainnet.example"); + const testnet = createPublicClient("testnet", undefined); + const emptyConfiguredTestnet = createPublicClient("testnet", ""); + const defaultTestnet = new ccc.ClientPublicTestnet(); + + expect(mainnet).toBeInstanceOf(ccc.ClientPublicMainnet); + expect(testnet).toBeInstanceOf(ccc.ClientPublicTestnet); + expect(mainnet.addressPrefix).toBe("ckb"); + expect(testnet.addressPrefix).toBe("ckt"); + expect(mainnet.url).toBe("https://mainnet.example"); + expect(testnet.url).toBe(defaultTestnet.url); + expect(emptyConfiguredTestnet.url).toBe(defaultTestnet.url); + }); + + it("reads and verifies public chain identity evidence", async () => { + const client = preflightClient({ + addressPrefix: "ckt", + genesisHash: TESTNET_GENESIS_HASH, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + }); + + await expect(verifyChainPreflight(client, "testnet")).resolves.toMatchObject({ + chain: "testnet", + expected: { + chain: "testnet", + networkName: "ckb_testnet", + genesisHash: TESTNET_GENESIS_HASH, + genesisMessage: "aggron-v4", + addressPrefix: "ckt", + }, + observed: { + genesisHash: TESTNET_GENESIS_HASH, + addressPrefix: "ckt", + tip: { hash: byte32FromByte("22"), number: 123n, timestamp: 456n }, + }, + matches: { genesisHash: true, addressPrefix: true }, + }); + }); + + it("returns undefined for non-genesis preflight header reads", async () => { + const client = testnetClient(); + + await expect(client.getHeaderByNumber(1n)).resolves.toBeUndefined(); + }); + + it("rejects mismatched public chain identity evidence", async () => { + const client = preflightClient({ + addressPrefix: "ckb", + genesisHash: MAINNET_GENESIS_HASH, + tipHash: byte32FromByte("22"), + tipNumber: 1n, + tipTimestamp: 2n, + }); + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + `Invalid testnet RPC chain identity: genesis hash expected ${ + TESTNET_GENESIS_HASH + } observed ${MAINNET_GENESIS_HASH}; address prefix expected ckt observed ckb`, + ); + }); + + it("rejects a missing genesis header as public identity evidence", async () => { + const client = testnetClient(); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + return undefined; + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + MISSING_TESTNET_GENESIS_HEADER, + ); + }); +}); + +describe("preflight failure redaction", () => { + it("hides non-public preflight failure details before loop logging starts", async () => { + const client = testnetClient(); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + throw new RpcPreflightError( + "RPC failed via https://user:pass@testnet.example/path?token=secret", + ); + }; + + let failure: unknown; + try { + await verifyChainPreflight(client, "testnet"); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { name: "RpcPreflightError" }, + }); + expect(JSON.stringify(failure)).not.toMatch(/user|pass|secret|testnet\.example/u); + }); + + it("hides non-Error preflight failures before loop logging starts", async () => { + const client = testnetClient(); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + const failure = { + reason: "failed", + amount: 9007199254740993n, + reasonCode: "transport", + }; + const rejectedHeader = Promise.withResolvers(); + rejectedHeader.reject(failure); + return rejectedHeader.promise; + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { type: "object" }, + }); + }); +}); + +describe("preflight failure normalization", () => { + it("normalizes string, null, and unsafe-named preflight failures", async () => { + const stringFailure = testnetClient(); + stringFailure.getHeaderByNumber = async (): Promise< + ccc.ClientBlockHeader | undefined + > => { + await Promise.resolve(); + return rejectedHeaderRead("rpc failed"); + }; + await expect(verifyChainPreflight(stringFailure, "testnet")).rejects.toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { type: "string" }, + }); + + const nullFailure = testnetClient(); + nullFailure.getHeaderByNumber = async (): Promise< + ccc.ClientBlockHeader | undefined + > => { + await Promise.resolve(); + return rejectedHeaderRead(null); + }; + await expect(verifyChainPreflight(nullFailure, "testnet")).rejects.toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { type: "null" }, + }); + + const unsafeNamedFailure = testnetClient(); + unsafeNamedFailure.getHeaderByNumber = async (): Promise< + ccc.ClientBlockHeader | undefined + > => { + await Promise.resolve(); + const error = new Error("failed"); + Object.defineProperty(error, "name", { value: "not safe" }); + throw error; + }; + await expect( + verifyChainPreflight(unsafeNamedFailure, "testnet"), + ).rejects.toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { name: "Error" }, + }); + }); + + it("preserves public string preflight failures", async () => { + const client = testnetClient(); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + throw new Error(MISSING_TESTNET_GENESIS_HEADER); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toThrow( + MISSING_TESTNET_GENESIS_HEADER, + ); + }); + + it("falls back when non-Error failure message stringification throws", async () => { + const client = testnetClient(); + const failure = Object.defineProperty({}, "message", { + enumerable: true, + get: () => { + throw new Error("getter failed"); + }, + }); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + return rejectedHeaderRead(failure); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ + message: TESTNET_PREFLIGHT_FAILURE_MESSAGE, + cause: { type: "object" }, + }); + }); +}); + +describe("retryable preflight failures", () => { + it("normalizes retryable preflight transport failures", async () => { + const client = testnetClient(); + client.getHeaderByNumber = async (): Promise => { + await Promise.resolve(); + throw new TypeError(FETCH_FAILED_MESSAGE); + }; + + await expect(verifyChainPreflight(client, "testnet")).rejects.toMatchObject({ + message: FETCH_FAILED_MESSAGE, + cause: { name: "TypeError", message: FETCH_FAILED_MESSAGE }, + }); + let caught: unknown; + try { + await verifyChainPreflight(client, "testnet"); + } catch (error) { + caught = error; + } + expect(isRetryableRpcTransportError(caught)).toBe(true); + }); +}); + +async function rejectedHeaderRead( + failure: unknown, +): Promise { + await Promise.resolve(); + const rejectedHeader = Promise.withResolvers(); + rejectedHeader.reject(failure); + return rejectedHeader.promise; +} + +function testnetClient(): ccc.Client { + return preflightClient({ + addressPrefix: "ckt", + genesisHash: TESTNET_GENESIS_HASH, + tipHash: byte32FromByte("22"), + tipNumber: 123n, + tipTimestamp: 456n, + }); +} diff --git a/packages/node-utils/test/index.ts b/packages/node-utils/test/index.ts new file mode 100644 index 0000000..15daf89 --- /dev/null +++ b/packages/node-utils/test/index.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import * as nodeUtils from "../src/index.ts"; +import { formatCkb } from "../src/index.ts"; + +describe("node utility exports and formatting", () => { + it("does not export generic secret-policing helpers", () => { + expect("assertNoPrivateKeyMaterial" in nodeUtils).toBe(false); + expect("assertNoSecretMaterial" in nodeUtils).toBe(false); + expect("SecretMaterialLogError" in nodeUtils).toBe(false); + expect("PrivateKeyMaterialLogError" in nodeUtils).toBe(false); + expect("sanitizeLogValue" in nodeUtils).toBe(false); + }); + + it("formats CKB values without losing bigint precision", () => { + const whole = 123456789012345678901234567890n; + + expect(formatCkb(100000000n)).toBe("1"); + expect(formatCkb(whole * 100000000n + 12345670n)).toBe(`${whole.toString()}.1234567`); + expect(formatCkb(-100000000n - 1n)).toBe("-1.00000001"); + }); +}); diff --git a/packages/node-utils/test/logging.ts b/packages/node-utils/test/logging.ts new file mode 100644 index 0000000..1a74488 --- /dev/null +++ b/packages/node-utils/test/logging.ts @@ -0,0 +1,290 @@ +import process from "node:process"; +import { describe, expect, it, vi } from "vitest"; +import { + handleLoopError, + jsonLogReplacer, + logExecution, + STOP_EXIT_CODE, + writeJsonLine, +} from "../src/index.ts"; +import { + byte32FromByte, + script, + TRANSACTION_CONFIRMATION_TIMEOUT_MESSAGE, + TRANSACTION_FAILED_TO_RESOLVE_MESSAGE, + transactionError, +} from "./support/node_utils_support.ts"; + +const UNKNOWN_ERROR_MESSAGE = "Unknown error"; + +describe("loop error logging", () => { + it("serializes error-like values for JSON logs", () => { + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, new Error("failed"))).toBe(false); + expect(executionLog["error"]).toMatchObject({ name: "Error", message: "failed" }); + expect(executionLog["error"]).toHaveProperty("stack"); + const emptyLog: Record = {}; + expect(handleLoopError(emptyLog, undefined)).toBe(false); + expect(emptyLog["error"]).toBe("Empty Error"); + }); + + it("converts bigint values in the JSON log replacer", () => { + expect(jsonLogReplacer("amount", 1n)).toBe("1"); + expect(jsonLogReplacer("status", "sent")).toBe("sent"); + }); + + it("serializes functions as unsupported log values", () => { + const executionLog: Record = {}; + + expect(handleLoopError(executionLog, unsupportedLogValue)).toBe(false); + + expect(executionLog["error"]).toBe("[Unsupported log value]"); + }); + + it("preserves public CKB RPC Error metadata in execution logs", () => { + const executionLog: Record = {}; + const error = Object.assign(new Error(TRANSACTION_FAILED_TO_RESOLVE_MESSAGE), { + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + outPoint: { txHash: `0x${"11".repeat(32)}`, index: 0n }, + }); + + expect(handleLoopError(executionLog, error)).toBe(false); + expect(executionLog["error"]).toMatchObject({ + name: "Error", + message: TRANSACTION_FAILED_TO_RESOLVE_MESSAGE, + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + outPoint: { txHash: `0x${"11".repeat(32)}`, index: "0" }, + }); + }); + + it("stops after broadcast confirmation timeouts", () => { + expect(STOP_EXIT_CODE).toBe(2); + expect(handleLoopError({}, transactionError(true))).toBe(true); + expect(process.exitCode).toBe(STOP_EXIT_CODE); + process.exitCode = undefined; + expect(handleLoopError({}, transactionError(false))).toBe(false); + expect(handleLoopError({}, new Error("failed"))).toBe(false); + }); +}); + +describe("loop transaction error logging", () => { + it("records timeout errors, preserves broadcast hash, and sets exit code 2", () => { + const txHash = byte32FromByte("33"); + const executionLog: Record = { txHash }; + + expect(handleLoopError(executionLog, transactionError(true, txHash))).toBe(true); + expect(process.exitCode).toBe(STOP_EXIT_CODE); + expect(executionLog["txHash"]).toBe(txHash); + expect(executionLog["error"]).toMatchObject({ + name: "TransactionConfirmationError", + message: TRANSACTION_CONFIRMATION_TIMEOUT_MESSAGE, + txHash, + status: "sent", + isTimeout: true, + }); + process.exitCode = undefined; + }); + + it("records non-timeout transaction confirmation failures distinctly", () => { + const txHash = byte32FromByte("34"); + const executionLog: Record = { txHash }; + + expect(handleLoopError(executionLog, transactionError(false, txHash))).toBe(false); + expect(executionLog["error"]).toMatchObject({ + name: "TransactionConfirmationError", + message: TRANSACTION_CONFIRMATION_TIMEOUT_MESSAGE, + txHash, + status: "rejected", + isTimeout: false, + }); + }); +}); + +describe("loop error shape logging", () => { + it("uses an unknown message for error-like values without string messages", () => { + const executionLog: Record = {}; + const error = { stack: "stack", message: 1 }; + + expect(handleLoopError(executionLog, error)).toBe(false); + + expect(executionLog["error"]).toMatchObject({ + message: UNKNOWN_ERROR_MESSAGE, + stack: "stack", + }); + + const missingMessageLog: Record = {}; + expect(handleLoopError(missingMessageLog, { stack: "stack" })).toBe(false); + expect(missingMessageLog["error"]).toMatchObject({ + message: UNKNOWN_ERROR_MESSAGE, + stack: "stack", + }); + + const nonStringStackLog: Record = {}; + expect(handleLoopError(nonStringStackLog, { stack: 1 })).toBe(false); + expect(nonStringStackLog["error"]).toMatchObject({ + message: UNKNOWN_ERROR_MESSAGE, + stack: "", + }); + }); + + it("serializes circular error causes without recursing", () => { + const executionLog: Record = {}; + const error = new Error("failed"); + Object.defineProperty(error, "cause", { value: error }); + + expect(handleLoopError(executionLog, error)).toBe(false); + + expect(executionLog["error"]).toMatchObject({ + message: "failed", + cause: "[Circular]", + }); + }); +}); + +describe("non-Error loop failure logging", () => { + it("preserves CKB debugging metadata from non-Error loop failures", () => { + const rpcUrl = "https://testnet.example/rpc/path"; + const executionLog: Record = {}; + const circular: Record = {}; + circular["self"] = circular; + + expect(handleLoopError(executionLog, loopFailure(rpcUrl, circular))).toBe(false); + const serialized = JSON.stringify(executionLog); + expect(serialized).toContain(rpcUrl); + expect(executionLog["error"]).toMatchObject({ + message: `failed via ${rpcUrl}`, + rpcUrl, + amount: "9007199254740993", + nested: { rpc_url: rpcUrl, message: "nested public evidence" }, + circular: { self: "[Circular]" }, + }); + }); +}); + +describe("JSON line logging", () => { + it("logs one JSON entry with elapsed seconds", () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const now = vi.spyOn(Date, "now").mockReturnValue(2500); + const executionLog: Record = { + amount: 9007199254740993n, + txHash: byte32FromByte("44"), + }; + + logExecution(executionLog, new Date(1000)); + + expect(stdoutWrite).toHaveBeenCalledTimes(1); + const logLine = String(stdoutWrite.mock.calls[0]?.[0]); + expect(logLine).toBe( + `${JSON.stringify({ + amount: "9007199254740993", + txHash: byte32FromByte("44"), + ElapsedSeconds: 2, + })}\n`, + ); + expect(JSON.parse(logLine)).toMatchObject({ + amount: "9007199254740993", + ElapsedSeconds: 2, + }); + now.mockRestore(); + stdoutWrite.mockRestore(); + }); + + it("writes one JSON line with bigint-safe event serialization", () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + writeJsonLine({ + type: "bot.decision.skipped", + amount: 9007199254740993n, + observedAt: new Date("2026-01-02T03:04:05.006Z"), + invalidAt: new Date(NaN), + }); + + expect(stdoutWrite).toHaveBeenCalledTimes(1); + const parsed = jsonRecord(String(stdoutWrite.mock.calls[0]?.[0])); + expect(parsed).toEqual({ + type: "bot.decision.skipped", + amount: "9007199254740993", + observedAt: "2026-01-02T03:04:05.006Z", + invalidAt: null, + }); + expect(String(stdoutWrite.mock.calls[0]?.[0])).toMatch(/\n$/u); + stdoutWrite.mockRestore(); + }); + + it("preserves CKB debugging metadata", () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const txHash = byte32FromByte("55"); + + writeJsonLine(debugLogValue(txHash)); + + const parsed = jsonRecord(String(stdoutWrite.mock.calls[0]?.[0])); + expect(parsed["txHash"]).toBe(txHash); + expect(parsed["witness"]).toBe(`witnesses: 0x${"22".repeat(80)}`); + expect(parsed["witnesses"]).toEqual([`0x${"22".repeat(80)}`]); + expect(parsed["inputs"]).toEqual([{}]); + expect(parsed["outputsData"]).toEqual([`0x${"22".repeat(80)}`]); + expect(parsed["signedTx"]).toBe(`signed transaction 0x${"33".repeat(80)}`); + expect(parsed["cell"]).toHaveProperty("cellOutput"); + expect(parsed["transactionShape"]).toEqual({ inputs: 1, outputs: 2, witnesses: 3 }); + expect(parsed["environment"]).toEqual({ + BOT_CONFIG_FILE: "/run/credentials/config.json", + }); + expect(parsed["config"]).toEqual({ chain: "testnet" }); + stdoutWrite.mockRestore(); + }); +}); + +function jsonRecord(text: string): Record { + const parsed: unknown = JSON.parse(text); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("Expected JSON object"); + } + return Object.fromEntries(Object.entries(parsed)); +} + +function unsupportedLogValue(): string { + return "unsupported"; +} + +function loopFailure( + rpcUrl: string, + circular: Record, +): Record { + return { + message: `failed via ${rpcUrl}`, + rpcUrl, + amount: 9007199254740993n, + nested: { rpc_url: rpcUrl, message: "nested public evidence" }, + circular, + }; +} + +function debugLogValue(txHash: string): Record { + return { + txHash, + witness: `witnesses: 0x${"22".repeat(80)}`, + witnesses: [`0x${"22".repeat(80)}`], + inputs: [{}], + outputs: [{}], + outputsData: [`0x${"22".repeat(80)}`], + cellDeps: [{}], + headerDeps: [{}], + signedTx: `signed transaction 0x${"33".repeat(80)}`, + tx: { inputs: [], outputs: [], witnesses: [] }, + rawTransaction: { inputs: [], outputs: [], witnesses: [] }, + script: JSON.stringify({ + codeHash: `0x${"44".repeat(32)}`, + hashType: "type", + args: `0x${"55".repeat(20)}`, + }), + lock: { codeHash: `0x${"44".repeat(32)}`, hashType: "type", args: "0x" }, + cell: { cellOutput: { lock: script("66") } }, + transactionShape: { inputs: 1, outputs: 2, witnesses: 3 }, + env: "testnet", + environment: { BOT_CONFIG_FILE: "/run/credentials/config.json" }, + config: { chain: "testnet" }, + }; +} diff --git a/packages/node-utils/test/retryable.ts b/packages/node-utils/test/retryable.ts new file mode 100644 index 0000000..9164e59 --- /dev/null +++ b/packages/node-utils/test/retryable.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + isRetryableCkbStateRaceError, + isRetryableRpcResponseShapeError, + isRetryableRpcTransportError, +} from "../src/index.ts"; +import { + FETCH_FAILED_MESSAGE, + TRANSACTION_FAILED_TO_RESOLVE_MESSAGE, +} from "./support/node_utils_support.ts"; + +describe("retryable error classifiers", () => { + it("classifies retryable RPC transport failures", async () => { + const { isRetryableRpcTransportError: importedIsRetryableRpcTransportError } = + await import("../src/index.ts"); + + expect( + importedIsRetryableRpcTransportError(new TypeError(FETCH_FAILED_MESSAGE)), + ).toBe(true); + expect( + importedIsRetryableRpcTransportError( + new Error(FETCH_FAILED_MESSAGE, { cause: new TypeError(FETCH_FAILED_MESSAGE) }), + ), + ).toBe(true); + expect(importedIsRetryableRpcTransportError(new Error(FETCH_FAILED_MESSAGE))).toBe( + false, + ); + expect( + importedIsRetryableRpcTransportError( + new Error("Invalid testnet RPC chain identity"), + ), + ).toBe(false); + expect(isRetryableRpcTransportError(new TypeError(FETCH_FAILED_MESSAGE))).toBe(true); + }); + + it("classifies retryable RPC response shape failures", () => { + expect( + isRetryableRpcResponseShapeError( + new Error("Id mismatched, got null, expected 319"), + ), + ).toBe(true); + expect( + isRetryableRpcResponseShapeError(new Error("Id mismatched, got 318, expected 319")), + ).toBe(true); + expect( + isRetryableRpcResponseShapeError( + new SyntaxError("Unexpected token '<', \" { + it("classifies observed CKB state-race send failures", () => { + expect( + isRetryableCkbStateRaceError( + Object.assign(new Error("Client request error PoolRejectedRBF"), { + code: -1111, + data: 'RBFRejected("Tx\'s current fee is 11795, expect it to >= 12326 to replace old txs")', + currentFee: 11795n, + leastFee: 12326n, + }), + ), + ).toBe(true); + expect( + isRetryableCkbStateRaceError( + Object.assign(new Error(TRANSACTION_FAILED_TO_RESOLVE_MESSAGE), { + code: -301, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + }), + ), + ).toBe(true); + expect( + isRetryableCkbStateRaceError({ + code: -301, + data: `Resolve(Dead(OutPoint(0x${"11".repeat(32)}00000000)))`, + }), + ).toBe(true); + expect( + isRetryableCkbStateRaceError( + Object.assign( + new Error("Client request error PoolRejectedDuplicatedTransaction"), + { + code: -1107, + data: `Duplicated(Byte32(0x${"22".repeat(32)}))`, + txHash: `0x${"22".repeat(32)}`, + }, + ), + ), + ).toBe(true); + expect( + isRetryableCkbStateRaceError({ + code: -301, + data: "Resolve(InvalidHeader(Byte32(0x...)))", + }), + ).toBe(false); + expect( + isRetryableCkbStateRaceError({ + code: -302, + data: `Resolve(Unknown(OutPoint(0x${"11".repeat(32)}00000000)))`, + }), + ).toBe(false); + expect(isRetryableCkbStateRaceError(null)).toBe(false); + expect(isRetryableCkbStateRaceError("RBFRejected")).toBe(false); + expect(isRetryableCkbStateRaceError({ code: -301 })).toBe(false); + expect(isRetryableCkbStateRaceError(new Error("RBFRejected"))).toBe(false); + }); +}); diff --git a/packages/node-utils/test/runtime_config.ts b/packages/node-utils/test/runtime_config.ts new file mode 100644 index 0000000..0ca2d6a --- /dev/null +++ b/packages/node-utils/test/runtime_config.ts @@ -0,0 +1,310 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { describe, expect, it } from "vitest"; +import { + parseRuntimeConfig, + randomSleepIntervalMs, + reachedMaxIterations, + readRuntimeConfigEnv, + type RuntimeConfig, +} from "../src/index.ts"; +import { sequence } from "./support/node_utils_support.ts"; + +const VALID_PRIVATE_KEY = `0x${"11".repeat(32)}`; +const CONFIG_FILE_NAME = "config.json"; +const CONFIG_ENV_NAME = "BOT_CONFIG_FILE"; +const INVALID_CONFIG_ENV_ERROR = `Invalid env ${CONFIG_ENV_NAME}`; +const RUNTIME_CONFIG_TEST_DIR = path.join( + import.meta.dirname, + "../../../.scratch/node-utils-runtime-config", +); +const RUNTIME_CONFIG_FILE_PATH = path.join(RUNTIME_CONFIG_TEST_DIR, CONFIG_FILE_NAME); + +describe("runtime config intervals", () => { + it("parses positive sleep intervals as milliseconds", async () => { + await expect( + readRuntimeConfigText(runtimeConfigText({ sleepIntervalSeconds: 1 })), + ).resolves.toMatchObject({ sleepIntervalMs: 1000 }); + await expect( + readRuntimeConfigText(runtimeConfigText({ sleepIntervalSeconds: 2.5 })), + ).resolves.toMatchObject({ sleepIntervalMs: 2500 }); + await expect( + readRuntimeConfigText(runtimeConfigText({ sleepIntervalSeconds: 1073741 })), + ).resolves.toMatchObject({ sleepIntervalMs: 1073741000 }); + }); + + it("rejects missing and sub-second sleep intervals", async () => { + for (const value of [undefined, NaN, Infinity, 0, 0.5, 1073741.824, 9007199254741]) { + await expect( + readRuntimeConfigText(runtimeConfigText({ sleepIntervalSeconds: value })), + ).rejects.toThrow(INVALID_CONFIG_ENV_ERROR); + } + }); + + it("parses bounded-run iteration limits", async () => { + await expect( + readRuntimeConfigText(runtimeConfigText({ maxIterations: undefined })), + ).resolves.toMatchObject({ maxIterations: undefined }); + await expect( + readRuntimeConfigText(runtimeConfigText({ maxIterations: 1 })), + ).resolves.toMatchObject({ maxIterations: 1 }); + await expect( + readRuntimeConfigText(runtimeConfigText({ maxIterations: 2 })), + ).resolves.toMatchObject({ maxIterations: 2 }); + expect(reachedMaxIterations(0, 1)).toBe(false); + expect(reachedMaxIterations(1, 1)).toBe(true); + expect(reachedMaxIterations(10, undefined)).toBe(false); + await expect( + readRuntimeConfigText(runtimeConfigText({ maxIterations: 0 })), + ).rejects.toThrow(INVALID_CONFIG_ENV_ERROR); + await expect( + readRuntimeConfigText(runtimeConfigText({ maxIterations: 1.5 })), + ).rejects.toThrow(INVALID_CONFIG_ENV_ERROR); + }); + + it("randomizes sleep with triangular jitter centered on the interval", () => { + expect(randomSleepIntervalMs(1000, sequence(0, 0))).toBe(0); + expect(randomSleepIntervalMs(1000, sequence(0.5, 0.5))).toBe(1000); + expect(randomSleepIntervalMs(1000, sequence(0.999, 0.999))).toBe(1998); + expect(randomSleepIntervalMs(1000, sequence(0.5))).toBe(500); + const samples = Array.from({ length: 1000 }, (_, index) => index / 1000); + const average = + samples.reduce( + (sum, first) => sum + randomSleepIntervalMs(1000, sequence(first, 1 - first)), + 0, + ) / samples.length; + expect(average).toBe(1000); + expect( + randomSleepIntervalMs(1073741823, sequence(0.999999, 0.999999)), + ).toBeLessThanOrEqual(2147483647); + }); +}); + +describe("runtime config JSON", () => { + it("parses private keys as exact 0x-prefixed lowercase hex", async () => { + const privateKey = `0x${"11".repeat(32)}`; + const secp256k1Order = + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; + + await expect( + readRuntimeConfigText(runtimeConfigText({ privateKey })), + ).resolves.toMatchObject({ + privateKey, + }); + for (const value of [ + "11".repeat(32), + `0X${"11".repeat(32)}`, + `0x${"AA".repeat(32)}`, + ` 0x${"11".repeat(32)}`, + `0x${"11".repeat(32)} `, + `0x${"11".repeat(31)}`, + `0x${"00".repeat(32)}`, + secp256k1Order, + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142", + ]) { + await expect( + readRuntimeConfigText(runtimeConfigText({ privateKey: value })), + ).rejects.toThrow(INVALID_CONFIG_ENV_ERROR); + } + }); +}); + +describe("runtime config JSON shape", () => { + it("parses exact runtime JSON config", async () => { + const privateKey = `0x${"11".repeat(32)}`; + expect( + parseRuntimeConfig(runtimeConfigText({ sleepIntervalSeconds: 5 }), CONFIG_ENV_NAME), + ).toMatchObject({ chain: "testnet", sleepIntervalMs: 5000 }); + + await expect( + readRuntimeConfigText( + JSON.stringify({ + chain: "testnet", + privateKey, + rpcUrl: "https://rpc.example/path?token=abc", + sleepIntervalSeconds: 60, + maxIterations: 2, + }), + ), + ).resolves.toEqual({ + chain: "testnet", + privateKey, + rpcUrl: "https://rpc.example/path?token=abc", + sleepIntervalMs: 60000, + maxIterations: 2, + maxRetryableAttempts: undefined, + }); + await expect( + readRuntimeConfigText( + runtimeConfigText({ + chain: "mainnet", + rpcUrl: "https://mainnet.example/", + sleepIntervalSeconds: 1, + }), + ), + ).resolves.toMatchObject({ + chain: "mainnet", + rpcUrl: "https://mainnet.example/", + sleepIntervalMs: 1000, + }); + await expect( + readRuntimeConfigText(runtimeConfigText({ sleepIntervalSeconds: 5 })), + ).resolves.toMatchObject({ + rpcUrl: undefined, + sleepIntervalMs: 5000, + maxRetryableAttempts: undefined, + }); + await expect( + readRuntimeConfigText( + runtimeConfigText({ sleepIntervalSeconds: 5, maxRetryableAttempts: 3 }), + ), + ).resolves.toMatchObject({ maxRetryableAttempts: 3 }); + }); + + it("rejects invalid runtime JSON config without exposing contents", async () => { + for (const value of invalidRuntimeConfigTexts()) { + let error: unknown; + try { + await readRuntimeConfigText(value); + } catch (caught) { + error = caught; + } + expect(error).toMatchObject({ message: INVALID_CONFIG_ENV_ERROR }); + expect(error instanceof Error ? error.message : String(error)).not.toMatch( + /rpc\.example|0x11/u, + ); + } + }); + + it("rejects non-object JSON object members without exposing contents", async () => { + await expect(readRuntimeConfigText("null")).rejects.toThrow(INVALID_CONFIG_ENV_ERROR); + }); +}); + +describe("runtime config file path resolution", () => { + it("uses the current working directory for relative config paths without INIT_CWD", async () => { + const originalInitCwd = process.env["INIT_CWD"]; + const relativeConfigPath = path.relative(process.cwd(), RUNTIME_CONFIG_FILE_PATH); + await writeRuntimeConfigFile(runtimeConfigText({ sleepIntervalSeconds: 5 })); + try { + delete process.env["INIT_CWD"]; + await expect( + readRuntimeConfigEnv(relativeConfigPath, CONFIG_ENV_NAME), + ).resolves.toMatchObject({ + chain: "testnet", + sleepIntervalMs: 5000, + }); + } finally { + if (originalInitCwd === undefined) { + delete process.env["INIT_CWD"]; + } else { + process.env["INIT_CWD"] = originalInitCwd; + } + await rm(RUNTIME_CONFIG_TEST_DIR, { recursive: true, force: true }); + } + }); +}); + +describe("runtime config file env", () => { + it("reads runtime JSON config from a file env source", async () => { + const privateKey = VALID_PRIVATE_KEY; + const originalInitCwd = process.env["INIT_CWD"]; + await writeRuntimeConfigFile( + JSON.stringify({ + chain: "testnet", + privateKey, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalSeconds: 60, + }), + ); + try { + await expect( + readRuntimeConfigEnv(RUNTIME_CONFIG_FILE_PATH, CONFIG_ENV_NAME), + ).resolves.toEqual({ + chain: "testnet", + privateKey, + rpcUrl: "http://127.0.0.1:8114/", + sleepIntervalMs: 60000, + maxIterations: undefined, + maxRetryableAttempts: undefined, + }); + await expect(readRuntimeConfigEnv(undefined, CONFIG_ENV_NAME)).rejects.toThrow( + `Empty env ${CONFIG_ENV_NAME}`, + ); + await expect( + readRuntimeConfigEnv(path.join(RUNTIME_CONFIG_TEST_DIR, "missing"), CONFIG_ENV_NAME), + ).rejects.toThrow(`Invalid file from env ${CONFIG_ENV_NAME}`); + process.env["INIT_CWD"] = RUNTIME_CONFIG_TEST_DIR; + await expect( + readRuntimeConfigEnv(CONFIG_FILE_NAME, CONFIG_ENV_NAME), + ).resolves.toMatchObject({ + chain: "testnet", + privateKey, + }); + await expect( + readRuntimeConfigEnv(path.resolve(CONFIG_FILE_NAME), CONFIG_ENV_NAME), + ).rejects.toThrow(`Invalid file from env ${CONFIG_ENV_NAME}`); + await writeRuntimeConfigFile(""); + await expect( + readRuntimeConfigEnv(RUNTIME_CONFIG_FILE_PATH, CONFIG_ENV_NAME), + ).rejects.toThrow(`Empty file from env ${CONFIG_ENV_NAME}`); + } finally { + if (originalInitCwd === undefined) { + delete process.env["INIT_CWD"]; + } else { + process.env["INIT_CWD"] = originalInitCwd; + } + await rm(RUNTIME_CONFIG_TEST_DIR, { recursive: true, force: true }); + } + }); +}); + +async function readRuntimeConfigText(configText: string): Promise { + await writeRuntimeConfigFile(configText); + try { + return await readRuntimeConfigEnv(RUNTIME_CONFIG_FILE_PATH, CONFIG_ENV_NAME); + } finally { + await rm(RUNTIME_CONFIG_TEST_DIR, { recursive: true, force: true }); + } +} + +async function writeRuntimeConfigFile(configText: string): Promise { + await rm(RUNTIME_CONFIG_TEST_DIR, { recursive: true, force: true }); + await mkdir(RUNTIME_CONFIG_TEST_DIR, { recursive: true, mode: 0o700 }); + await writeFile(RUNTIME_CONFIG_FILE_PATH, configText, { mode: 0o600 }); +} + +function invalidRuntimeConfigTexts(): string[] { + return [ + "not-json", + JSON.stringify([]), + runtimeConfigText({ chain: "devnet" }), + runtimeConfigText({ chain: VALID_PRIVATE_KEY, rpcUrl: "https://rpc.example/" }), + runtimeConfigText({ extra: true }), + runtimeConfigText({ privateKey: 1 }), + runtimeConfigText({ privateKey: `${VALID_PRIVATE_KEY}\n` }), + runtimeConfigText({ rpcUrl: "" }), + runtimeConfigText({ rpcUrl: "file:///tmp/socket" }), + runtimeConfigText({ rpcUrl: "https://[bad" }), + runtimeConfigText({ rpcUrl: "https://rpc.example/ bad" }), + runtimeConfigText({ rpcUrl: 8114 }), + runtimeConfigText({ sleepIntervalSeconds: "60" }), + runtimeConfigText({ sleepIntervalSeconds: 0 }), + runtimeConfigText({ maxIterations: "1" }), + runtimeConfigText({ maxRetryableAttempts: "1" }), + runtimeConfigText({ maxRetryableAttempts: 0 }), + ]; +} + +function runtimeConfigText(overrides: Record): string { + const config: Record = { + chain: "testnet", + privateKey: VALID_PRIVATE_KEY, + sleepIntervalSeconds: 60, + ...overrides, + }; + return JSON.stringify( + Object.fromEntries(Object.entries(config).filter(([, value]) => value !== undefined)), + ); +} diff --git a/packages/node-utils/test/sleep.ts b/packages/node-utils/test/sleep.ts new file mode 100644 index 0000000..70f7f73 --- /dev/null +++ b/packages/node-utils/test/sleep.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { sleep } from "../src/index.ts"; + +describe("sleep", () => { + it("resolves after the requested timeout", async () => { + vi.useFakeTimers(); + try { + const done = vi.fn(); + const promise = markDoneAfterSleep(done); + + await vi.advanceTimersByTimeAsync(24); + expect(done).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await promise; + expect(done).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +async function markDoneAfterSleep(done: () => unknown): Promise { + await sleep(25); + done(); +} diff --git a/packages/node-utils/test/support/node_utils_support.ts b/packages/node-utils/test/support/node_utils_support.ts new file mode 100644 index 0000000..2f4bec4 --- /dev/null +++ b/packages/node-utils/test/support/node_utils_support.ts @@ -0,0 +1,96 @@ +import { ccc } from "@ckb-ccc/core"; +import { + byte32FromByte, + capacityCell, + headerLike, + script, + StubClient, +} from "@ickb/testkit"; + +export const TESTNET_GENESIS_HASH = + "0x10639e0895502b5688a6be8cf69460d76541bfa4821629d86d62ba0aae3f9606"; +export const MAINNET_GENESIS_HASH = + "0x92b197aa1fba0f63633922c61c92375c9c074a93e85963554f5499fe1450d0e5"; +export const FETCH_FAILED_MESSAGE = "fetch failed"; +export const TRANSACTION_FAILED_TO_RESOLVE_MESSAGE = + "Client request error TransactionFailedToResolve"; +export const TRANSACTION_CONFIRMATION_TIMEOUT_MESSAGE = + "Transaction confirmation timed out"; + +export function sequence(...values: number[]): () => number { + let index = 0; + return () => values[index++] ?? 0; +} + +export function transactionError( + isTimeout: boolean, + txHash = byte32FromByte("11"), +): Error { + return Object.assign( + new TransactionConfirmationError(TRANSACTION_CONFIRMATION_TIMEOUT_MESSAGE), + { + txHash, + status: isTimeout ? "sent" : "rejected", + isTimeout, + }, + ); +} + +export class RpcPreflightError extends Error { + public override name = "RpcPreflightError"; +} + +export function preflightClient({ + addressPrefix, + genesisHash, + tipHash, + tipNumber, + tipTimestamp, +}: { + addressPrefix: string; + genesisHash: `0x${string}`; + tipHash: `0x${string}`; + tipNumber: bigint; + tipTimestamp: bigint; +}): ccc.Client { + return new StubClient({ + addressPrefix, + getHeaderByNumber: async ( + blockNumber, + ): Promise => { + await Promise.resolve(); + if (blockNumber !== 0n) { + return undefined; + } + return headerLike({ hash: genesisHash, number: 0n }); + }, + getTipHeader: async (): Promise => { + await Promise.resolve(); + return headerLike({ + hash: tipHash, + number: tipNumber, + timestamp: tipTimestamp, + }); + }, + }); +} + +export class AddressStubSigner extends ccc.SignerCkbPrivateKey { + private readonly addresses: ccc.Address[]; + + constructor(addresses: ccc.Address[]) { + super(new StubClient(), `0x${"11".repeat(32)}`); + this.addresses = addresses; + } + + public override async getAddressObjs(): Promise { + await Promise.resolve(); + return this.addresses; + } +} + +class TransactionConfirmationError extends Error { + public override name = "TransactionConfirmationError"; +} + +export { byte32FromByte, capacityCell, script }; diff --git a/packages/node-utils/tsconfig.build.json b/packages/node-utils/tsconfig.build.json new file mode 100644 index 0000000..c23b9fe --- /dev/null +++ b/packages/node-utils/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["test"] +} diff --git a/packages/node-utils/tsconfig.json b/packages/node-utils/tsconfig.json index 1172216..34b5438 100644 --- a/packages/node-utils/tsconfig.json +++ b/packages/node-utils/tsconfig.json @@ -1,12 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src", + "noEmit": true, "types": ["node"] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/node-utils/vitest.config.mts b/packages/node-utils/vitest.config.mts index 8fb6f2d..e5f423a 100644 --- a/packages/node-utils/vitest.config.mts +++ b/packages/node-utils/vitest.config.mts @@ -1,3 +1,10 @@ import { defineConfig } from "vitest/config"; -export default defineConfig({}); +export default defineConfig({ + test: { + include: ["{test,tests}/*.{ts,tsx}"], + coverage: { + include: ["src/**/*.{ts,tsx}"], + }, + }, +}); diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 379771d..c874ad1 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -4,6 +4,11 @@ "private": true, "sideEffects": false, "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "main": "src/index.ts", + "types": "src/index.ts", "exports": { ".": { "types": "./src/index.ts", @@ -11,12 +16,9 @@ } }, "scripts": { - "build": "tsc", "test": "vitest", "test:ci": "vitest run", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/testkit/src packages/testkit/test" }, "dependencies": { "@ckb-ccc/core": "catalog:" diff --git a/packages/testkit/src/bytes.ts b/packages/testkit/src/bytes.ts new file mode 100644 index 0000000..45e92d7 --- /dev/null +++ b/packages/testkit/src/bytes.ts @@ -0,0 +1,12 @@ +/** + * Creates a 32-byte hex string by repeating one byte. + * + * @param hexByte - Exactly two hex characters. + */ +export function byte32FromByte(hexByte: string): `0x${string}` { + if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { + throw new Error("Expected exactly one byte as two hex chars"); + } + + return `0x${hexByte.repeat(32)}`; +} diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts index 27b53b7..ada8d28 100644 --- a/packages/testkit/src/index.ts +++ b/packages/testkit/src/index.ts @@ -1,15 +1,146 @@ +/** + * Shared test fixtures for iCKB Stack packages. + * + * @packageDocumentation + */ + import { ccc } from "@ckb-ccc/core"; +import { byte32FromByte } from "./bytes.ts"; -export function byte32FromByte(hexByte: string): `0x${string}` { - if (!/^[0-9a-f]{2}$/iu.test(hexByte)) { - throw new Error("Expected exactly one byte as two hex chars"); - } +export { byte32FromByte } from "./bytes.ts"; + +/** Creates a 32-byte hash fixture by repeating one byte. */ +export function hash(hexByte: string): `0x${string}` { + return byte32FromByte(hexByte); +} + +type ClientMethod = Extract< + ccc.Client[K], + (...args: never[]) => unknown +>; - return `0x${hexByte.repeat(32)}`; +interface StubClientHandlers { + addressPrefix?: string; + cache?: ccc.Client["cache"]; + findCells?: ClientMethod<"findCells">; + findCellsOnChain?: ClientMethod<"findCellsOnChain">; + getCell?: ClientMethod<"getCell">; + getHeaderByNumber?: ClientMethod<"getHeaderByNumber">; + getTipHeader?: ClientMethod<"getTipHeader">; + getTransaction?: ClientMethod<"getTransaction">; + getTransactionWithHeader?: ClientMethod<"getTransactionWithHeader">; + sendTransactionDry?: ClientMethod<"sendTransactionDry">; } -export const hash = byte32FromByte; +export type CommittedTransactionResponseOverrides = Partial< + Omit +>; + +export interface TransactionWithHeader { + transaction: ccc.ClientTransactionResponse; + header: ccc.ClientBlockHeader; +} + +/** + * CCC client test double with per-method handler overrides. + * + * @remarks + * Unspecified methods fall back to `ClientPublicTestnet`, whose URL is an + * invalid host. Tests should override every method expected to cross the RPC + * boundary. + */ +export class StubClient extends ccc.ClientPublicTestnet { + private readonly handlers: StubClientHandlers; + private readonly findCellsHandler: ClientMethod<"findCells">; + private readonly findCellsOnChainHandler: ClientMethod<"findCellsOnChain">; + private readonly getCellHandler: ClientMethod<"getCell">; + private readonly getHeaderByNumberHandler: ClientMethod<"getHeaderByNumber">; + private readonly getTransactionHandler: ClientMethod<"getTransaction">; + private readonly getTransactionWithHeaderHandler: ClientMethod<"getTransactionWithHeader">; + declare public getTipHeader: ClientMethod<"getTipHeader">; + declare public sendTransactionDry: ClientMethod<"sendTransactionDry">; + + /** + * Creates a stub client using the supplied method overrides. + */ + constructor(handlers: StubClientHandlers = {}) { + super({ url: "https://example.invalid" }); + this.handlers = handlers; + if (handlers.cache !== undefined) { + this.cache = handlers.cache; + } + this.findCellsHandler = handlers.findCells ?? super.findCells.bind(this); + this.findCellsOnChainHandler = + handlers.findCellsOnChain ?? super.findCellsOnChain.bind(this); + this.getCellHandler = handlers.getCell ?? super.getCell.bind(this); + this.getHeaderByNumberHandler = + handlers.getHeaderByNumber ?? super.getHeaderByNumber.bind(this); + this.getTransactionHandler = + handlers.getTransaction ?? super.getTransaction.bind(this); + this.getTransactionWithHeaderHandler = + handlers.getTransactionWithHeader ?? super.getTransactionWithHeader.bind(this); + if (handlers.getTipHeader !== undefined) { + this.getTipHeader = handlers.getTipHeader; + } + if (handlers.sendTransactionDry !== undefined) { + this.sendTransactionDry = handlers.sendTransactionDry; + } + } + + /** Address prefix override used by address formatting tests. */ + public override get addressPrefix(): string { + return this.handlers.addressPrefix ?? super.addressPrefix; + } + + /** Delegates cell scans to the configured handler or the base client. */ + public override findCells( + ...args: Parameters> + ): ReturnType> { + return this.findCellsHandler(...args); + } + + /** Delegates on-chain cell scans to the configured handler or the base client. */ + public override findCellsOnChain( + ...args: Parameters> + ): ReturnType> { + return this.findCellsOnChainHandler(...args); + } + + /** Delegates single-cell lookup to the configured handler or the base client. */ + public override async getCell( + ...args: Parameters> + ): ReturnType> { + return this.getCellHandler(...args); + } + + /** Delegates block-header lookup to the configured handler or the base client. */ + public override async getHeaderByNumber( + ...args: Parameters> + ): ReturnType> { + return this.getHeaderByNumberHandler(...args); + } + + /** Delegates transaction lookup to the configured handler or the base client. */ + public override async getTransaction( + ...args: Parameters> + ): ReturnType> { + return this.getTransactionHandler(...args); + } + + /** Delegates transaction-with-header lookup to the configured handler or the base client. */ + public override async getTransactionWithHeader( + ...args: Parameters> + ): ReturnType> { + return this.getTransactionWithHeaderHandler(...args); + } +} +/** + * Creates a type-hash script whose code hash is a repeated byte. + * + * @param codeHashByte - Two hex characters repeated to form the 32-byte code hash. + * @param args - Script args hex string. + */ export function script(codeHashByte: string, args = "0x"): ccc.Script { return ccc.Script.from({ codeHash: byte32FromByte(codeHashByte), @@ -18,6 +149,12 @@ export function script(codeHashByte: string, args = "0x"): ccc.Script { }); } +/** + * Creates an out point whose transaction hash is a repeated byte. + * + * @param txHashByte - Two hex characters repeated to form the 32-byte tx hash. + * @param index - Output index for the out point. + */ export function outPoint(txHashByte: string, index = 0n): ccc.OutPoint { return ccc.OutPoint.from({ txHash: byte32FromByte(txHashByte), @@ -25,6 +162,73 @@ export function outPoint(txHashByte: string, index = 0n): ccc.OutPoint { }); } +/** + * Creates a live-cell-shaped capacity cell fixture with empty data. + * + * @param capacity - Cell capacity in shannons. + * @param lock - Lock script assigned to the cell output. + * @param txHashByte - Two hex characters repeated to form the out point tx hash. + */ +export function capacityCell( + capacity: bigint, + lock: ccc.Script, + txHashByte: string, +): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: byte32FromByte(txHashByte), index: 0n }, + cellOutput: { capacity, lock }, + outputData: "0x", + }); +} + +/** + * Async form of {@link passthroughTransaction} for client handler tests. + */ +export async function asyncPassthroughTransaction( + txLike: ccc.TransactionLike, +): Promise { + await Promise.resolve(); + return passthroughTransaction(txLike); +} + +/** + * Normalizes a transaction-like value into a CCC transaction. + */ +export function passthroughTransaction(txLike: ccc.TransactionLike): ccc.Transaction { + return ccc.Transaction.from(txLike); +} + +/** + * Creates a committed transaction response fixture. + */ +export function committedTransactionResponse( + transaction: ccc.TransactionLike, + overrides: CommittedTransactionResponseOverrides = {}, +): ccc.ClientTransactionResponse { + return ccc.ClientTransactionResponse.from({ + transaction, + status: "committed", + ...overrides, + }); +} + +/** + * Creates a transaction-with-header response using a default committed transaction. + */ +export function transactionWithHeader( + header: ccc.ClientBlockHeader, +): TransactionWithHeader { + return { + transaction: committedTransactionResponse(ccc.Transaction.default()), + header, + }; +} + +/** + * Creates a block-header fixture with deterministic defaults. + * + * @param overrides - Header fields to replace after defaults are applied. + */ export function headerLike( overrides: Partial = {}, ): ccc.ClientBlockHeader { diff --git a/packages/testkit/test/index.ts b/packages/testkit/test/index.ts new file mode 100644 index 0000000..78ec396 --- /dev/null +++ b/packages/testkit/test/index.ts @@ -0,0 +1,187 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it, vi } from "vitest"; +import { + asyncPassthroughTransaction, + byte32FromByte, + capacityCell, + committedTransactionResponse, + headerLike, + outPoint, + passthroughTransaction, + script, + StubClient, + transactionWithHeader, +} from "../src/index.ts"; + +describe("byte32FromByte", () => { + it("creates a repeated 32-byte hex string", () => { + expect(byte32FromByte("ab")).toBe(`0x${"ab".repeat(32)}`); + }); + + it("rejects non-byte hex input", () => { + expect(() => byte32FromByte("abc")).toThrow("Expected exactly one byte"); + }); +}); + +describe("cell fixtures", () => { + it("creates reusable scripts, out points, and capacity cells", () => { + const lock = script("11", "0x1234"); + const capacity = ccc.fixedPointFrom(100); + const cell = capacityCell(capacity, lock, "22"); + + expect(lock.codeHash).toBe(byte32FromByte("11")); + expect(lock.hashType).toBe("type"); + expect(lock.args).toBe("0x1234"); + expect(outPoint("33", 2n).toHex()).toBe(`${byte32FromByte("33")}02000000`); + expect(cell.cellOutput.capacity).toBe(capacity); + expect(cell.cellOutput.lock.eq(lock)).toBe(true); + expect(cell.outputData).toBe("0x"); + }); +}); + +describe("transaction fixtures", () => { + it("normalizes transactions and committed responses", () => { + const tx = passthroughTransaction({ outputs: [] }); + const response = committedTransactionResponse(tx, { blockNumber: 7n }); + + expect(tx).toBeInstanceOf(ccc.Transaction); + expect(response.transaction.hash()).toBe(tx.hash()); + expect(response.status).toBe("committed"); + expect(response.blockNumber).toBe(7n); + }); + + it("normalizes transactions asynchronously", async () => { + await expect(asyncPassthroughTransaction({ outputs: [] })).resolves.toBeInstanceOf( + ccc.Transaction, + ); + }); + + it("creates transaction-with-header fixtures", () => { + const header = headerLike({ number: 9n }); + const result = transactionWithHeader(header); + + expect(result.header.number).toBe(9n); + expect(result.transaction.status).toBe("committed"); + }); +}); + +describe("StubClient", () => { + it("delegates configured handlers", async () => { + const cell = capacityCell(1n, script("44"), "55"); + const getCell = vi.fn(async (): Promise => { + await Promise.resolve(); + return cell; + }); + const client = new StubClient({ addressPrefix: "ckt", getCell }); + + await expect(client.getCell(outPoint("66"))).resolves.toBe(cell); + expect(client.addressPrefix).toBe("ckt"); + expect(getCell).toHaveBeenCalledTimes(1); + }); + + it("keeps default fallback handlers and cache overrides", () => { + const cache = new TestCache(); + const client = new StubClient({ cache }); + + expect(client.cache).toBe(cache); + expect(client.addressPrefix).toBe("ckt"); + }); + + it("assigns constructor handlers for declared client methods", async () => { + const tip = headerLike({ number: 11n }); + const sendTransactionDry: ccc.Client["sendTransactionDry"] = vi.fn( + async (): ReturnType => { + await Promise.resolve(); + return 0n; + }, + ); + const getTipHeader = vi.fn(async (): Promise => { + await Promise.resolve(); + return tip; + }); + const client = new StubClient({ getTipHeader, sendTransactionDry }); + + await expect(client.getTipHeader()).resolves.toBe(tip); + expect(client.sendTransactionDry).toBe(sendTransactionDry); + }); + + it("delegates scan and transaction handlers", async () => { + const cell = capacityCell(1n, script("77"), "88"); + const transaction = committedTransactionResponse(ccc.Transaction.default()); + const withHeader = transactionWithHeader(headerLike({ number: 3n })); + const header = headerLike({ number: 4n }); + const client = new StubClient({ + async *findCells(): ReturnType { + await Promise.resolve(); + yield cell; + }, + async *findCellsOnChain(): ReturnType { + await Promise.resolve(); + yield cell; + }, + getTransaction: async (): ReturnType => { + await Promise.resolve(); + return transaction; + }, + getTransactionWithHeader: async (): ReturnType< + ccc.Client["getTransactionWithHeader"] + > => { + await Promise.resolve(); + return withHeader; + }, + getHeaderByNumber: async (): ReturnType => { + await Promise.resolve(); + return header; + }, + }); + + const searchKey = { + script: script("aa"), + scriptType: "lock", + scriptSearchMode: "exact", + } as const; + + await expect(collect(client.findCells(searchKey, "asc", 1))).resolves.toEqual([cell]); + await expect(collect(client.findCellsOnChain(searchKey, "asc", 1))).resolves.toEqual([ + cell, + ]); + await expect(client.getTransaction(byte32FromByte("99"))).resolves.toBe(transaction); + await expect(client.getTransactionWithHeader(byte32FromByte("99"))).resolves.toBe( + withHeader, + ); + await expect(client.getHeaderByNumber(4n)).resolves.toBe(header); + }); +}); + +async function collect(source: AsyncIterable): Promise { + const values: T[] = []; + for await (const value of source) { + values.push(value); + } + return values; +} + +class TestCache extends ccc.ClientCache { + public override async markUsableNoCache(): Promise { + await Promise.resolve(); + } + + public override async markUnusable(): Promise { + await Promise.resolve(); + } + + public override async isUnusable(): Promise { + await Promise.resolve(); + return false; + } + + public override async clear(): Promise { + await Promise.resolve(); + } + + public override async *findCells(): AsyncGenerator { + const cells: ccc.Cell[] = []; + yield* cells; + await Promise.resolve(); + } +} diff --git a/packages/testkit/tsconfig.json b/packages/testkit/tsconfig.json index ca222a6..c6a53fe 100644 --- a/packages/testkit/tsconfig.json +++ b/packages/testkit/tsconfig.json @@ -1,10 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/testkit/vitest.config.mts b/packages/testkit/vitest.config.mts new file mode 100644 index 0000000..4e880f1 --- /dev/null +++ b/packages/testkit/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["{test,tests}/*.{ts,tsx}"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/packages/utils/api-extractor.json b/packages/utils/api-extractor.json new file mode 100644 index 0000000..d842b4d --- /dev/null +++ b/packages/utils/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json", + "mainEntryPointFilePath": "/dist/index.d.ts" +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 880448f..d8ee24c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -13,17 +13,24 @@ "homepage": "https://ickb.org", "repository": { "type": "git", - "url": "https://github.com/ickb/stack" + "url": "git+https://github.com/ickb/stack.git" }, "bugs": { "url": "https://github.com/ickb/stack/issues" }, "sideEffects": false, "type": "module", + "engines": { + "node": ">=22.19.0" + }, "main": "dist/index.js", "types": "src/index.ts", "exports": { ".": { + "api-extractor": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "types": "./src/index.ts", "import": "./dist/index.js" } @@ -31,18 +38,18 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "tsc", - "lint": "eslint ./src", - "clean": "rm -fr dist", - "clean:deep": "rm -fr dist node_modules" + "build": "rm -fr dist && tsgo -p tsconfig.build.json && node ../../scripts/tooling/build/rewrite-dts-imports.ts dist", + "lint:api": "api-extractor run --local", + "lint": "pnpm --workspace-root exec eslint --max-warnings=0 packages/utils/src packages/utils/test", + "clean": "rm -fr dist" }, "files": [ - "dist", - "src" + "dist" ], "publishConfig": { "access": "public", "provenance": true, + "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { diff --git a/packages/utils/src/codec.ts b/packages/utils/src/codec.ts index aadb17c..27d6332 100644 --- a/packages/utils/src/codec.ts +++ b/packages/utils/src/codec.ts @@ -1,7 +1,14 @@ import { ccc } from "@ckb-ccc/core"; /** - * Boundary-checked codec for little-endian 32-bit signed integers. + * Codec for little-endian 32-bit signed integers. + * + * @remarks + * The encoder rejects numeric values outside the signed 32-bit range before + * writing bytes. Decoding reads a signed little-endian integer from the bytes + * after CCC byte normalization. + * + * @public */ export const CheckedInt32LE = ccc.Codec.from({ byteLength: 4, diff --git a/packages/utils/src/heap.test.ts b/packages/utils/src/heap.test.ts deleted file mode 100644 index 93bc7bc..0000000 --- a/packages/utils/src/heap.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { MinHeap } from "./heap.js"; - -describe("MinHeap.remove", () => { - it("removes the requested non-root element", () => { - const heap = MinHeap.from(compareNumbers, [1, 2, 3]); - - expect(heap.remove(1)).toBe(2); - expect(heap.pop()).toBe(1); - expect(heap.pop()).toBe(3); - }); - - it("ignores negative indexes", () => { - const heap = MinHeap.from(compareNumbers, [1, 2, 3]); - - expect(heap.remove(-1)).toBeUndefined(); - expect(heap.pop()).toBe(1); - expect(heap.pop()).toBe(2); - expect(heap.pop()).toBe(3); - }); -}); - -function compareNumbers(items: number[], i: number, j: number): boolean { - const left = items.at(i); - const right = items.at(j); - if (left === undefined || right === undefined) { - throw new Error("Heap comparator index out of bounds"); - } - - return left < right; -} diff --git a/packages/utils/src/heap.ts b/packages/utils/src/heap.ts deleted file mode 100644 index 72ca3e7..0000000 --- a/packages/utils/src/heap.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * A class representing a minimum heap data structure. - * @template T - The type of elements in the heap. - * - * @credits go standard library authors, this implementation is just a translation: - * https://go.dev/src/container/heap/heap.go - */ -export class MinHeap { - /** - * Creates an instance of MinHeap. - * @param {(heap: T[], i: number, j: number) => boolean} lessFunc - A function that evaluates if element at index i is less than element at index j. - * @param {T[]} heap - The initial array of elements in the heap. - */ - constructor( - public readonly lessFunc: (heap: T[], i: number, j: number) => boolean, - public readonly heap: T[], - ) {} - - /** - * Initializes the heap from an array of elements. - * @param {T[]} unordered - The array of unordered elements to create the heap from. - * @param {(heap: T[], i: number, j: number) => boolean} lessFunc - A function that evaluates if element at index i is less than element at index j. - * @returns {MinHeap} A new instance of MinHeap. - */ - static from( - lessFunc: (heap: T[], i: number, j: number) => boolean, - unordered: T[] = [], - ): MinHeap { - const heap = new MinHeap(lessFunc, [...unordered]); - const n = heap.len(); - for (let i = (n >> 1) - 1; i >= 0; i--) { - heap.down(i, n); - } - return heap; - } - - /** - * Evaluates if the element at index i is less than the element at index j using the external lessFunc. - * @param {number} i - The index of the first element. - * @param {number} j - The index of the second element. - * @returns {boolean} True if the element at index i is less than the element at index j; otherwise, false. - */ - private less(i: number, j: number): boolean { - return this.lessFunc(this.heap, i, j); - } - - /** - * Returns the number of elements in the heap. - * @returns {number} The number of elements in the heap. - */ - len(): number { - return this.heap.length; - } - - /** - * Returns true iff the number of elements in the heap is zero. - * @returns {number} the emptiness of the array. - */ - isEmpty(): boolean { - return this.heap.length === 0; - } - - /** - * Adds an element x to the heap. - * @param {T} x - The element to be added to the heap. - */ - push(x: T): void { - this.heap.push(x); - this.up(this.len() - 1); - } - - /** - * Removes and returns the minimum element from the heap. - * @returns {T | undefined} The minimum element from the heap, or undefined if the heap is empty. - */ - pop(): T | undefined { - if (this.len() === 0) { - return; - } - - const n = this.len() - 1; - this.swap(0, n); - this.down(0, n); - return this.heap.pop(); - } - - /** - * Removes and returns the element at index i from the heap. - * @param {number} i - The index of the element to be removed. - * @returns {T | undefined} The removed element, or undefined if the index is out of bounds. - */ - remove(i: number): T | undefined { - if (i < 0 || this.len() <= i) { - return; - } - - const n = this.len() - 1; - if (n !== i) { - this.swap(i, n); - if (!this.down(i, n)) { - this.up(i); - } - } - return this.heap.pop(); - } - - /** - * Re-establishes the heap ordering after the element at index i has changed its value. - * @param {number} i - The index of the element to fix. - */ - fix(i: number): void { - if (this.len() <= i) { - return; - } - - if (!this.down(i, this.len())) { - this.up(i); - } - } - - /** - * Moves the element at index j up the heap to restore the heap property. - * @param {number} j - The index of the element to move up. - */ - private up(j: number): void { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const i = (j - 1) >> 1; // parent - if (i === j || !this.less(j, i)) { - break; - } - this.swap(i, j); - j = i; - } - } - - /** - * Moves the element at index i down the heap to restore the heap property. - * @param {number} i0 - The index of the element to move down. - * @param {number} n - The number of elements in the heap. - * @returns {boolean} True if the element was moved down; otherwise, false. - */ - private down(i0: number, n: number): boolean { - let i = i0; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const j1 = (i << 1) + 1; // left child index - if (j1 >= n || j1 < 0) { - // j1 < 0 after int overflow - break; - } - let j = j1; // left child - const j2 = j1 + 1; // right child index - if (j2 < n && this.less(j2, j1)) { - j = j2; // choose the smaller child - } - if (!this.less(j, i)) { - break; - } - this.swap(i, j); - i = j; - } - return i > i0; // returns true if the position of the element has changed - } - - /** - * Swaps the elements at indices i and j in the heap. - * @param {number} i - The index of the first element. - * @param {number} j - The index of the second element. - */ - private swap(i: number, j: number): void { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [this.heap[i], this.heap[j]] = [this.heap[j]!, this.heap[i]!]; - } -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ffa8b89..7cf8a4f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,24 @@ -export * from "./codec.js"; -export * from "./heap.js"; -export * from "./utils.js"; +/** + * Shared utility codecs, collection helpers, and value shapes used by iCKB Stack packages. + * + * @packageDocumentation + */ + +export { CheckedInt32LE } from "./codec.ts"; +export { + BufferedGenerator, + asyncBinarySearch, + binarySearch, + collect, + collectPagedScan, + compareBigInt, + defaultCellPageSize, + isPlainCapacityCell, + unique, +} from "./utils.ts"; +export type { + ExchangeRatio, + ScriptDeps, + TransactionHeader, + ValueComponents, +} from "./utils.ts"; diff --git a/packages/utils/src/utils.test.ts b/packages/utils/src/utils.test.ts deleted file mode 100644 index 9cb3ac3..0000000 --- a/packages/utils/src/utils.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - BufferedGenerator, - compareBigInt, - collectPagedScan, -} from "./utils.js"; - -describe("compareBigInt", () => { - it("orders bigint values", () => { - expect(compareBigInt(1n, 2n)).toBe(-1); - expect(compareBigInt(2n, 2n)).toBe(0); - expect(compareBigInt(3n, 2n)).toBe(1); - }); -}); - -describe("BufferedGenerator", () => { - it("keeps advancing the wrapped generator after the initial fill", () => { - function* numbers(): Generator { - yield 1; - yield 2; - yield 3; - } - - const buffered = new BufferedGenerator(numbers(), 2); - - expect(buffered.buffer).toEqual([1, 2]); - - buffered.next(1); - expect(buffered.buffer).toEqual([2, 3]); - - buffered.next(1); - expect(buffered.buffer).toEqual([3]); - }); -}); - -describe("scan collection", () => { - it("passes the cell page size through and collects all yielded items", async () => { - const seenPageSizes: number[] = []; - - await expect(collectPagedScan( - async function* (pageSize: number) { - seenPageSizes.push(pageSize); - yield 1; - yield 2; - await Promise.resolve(); - }, - { pageSize: 2 }, - )).resolves.toEqual([1, 2]); - expect(seenPageSizes).toEqual([2]); - }); -}); diff --git a/packages/utils/src/utils.ts b/packages/utils/src/utils.ts index 4deacb8..4434eda 100644 --- a/packages/utils/src/utils.ts +++ b/packages/utils/src/utils.ts @@ -1,4 +1,4 @@ -import { ccc } from "@ckb-ccc/core"; +import type { ccc } from "@ckb-ccc/core"; /** * The default page size used when querying cells from the chain. @@ -9,9 +9,19 @@ import { ccc } from "@ckb-ccc/core"; * @remarks * When searching for cells, callers may override this page size by passing a * custom `pageSize` in their options. This does not cap total results. + * + * @public */ export const defaultCellPageSize = 400; +/** + * Collects every item yielded by a paged async scan. + * + * @remarks `pageSize` is passed to the supplied scan factory as an RPC/indexer + * page size. It is not a total result cap. + * + * @public + */ export async function collectPagedScan( scan: (pageSize: number) => AsyncIterable, options: { @@ -26,92 +36,82 @@ export async function collectPagedScan( } /** - * Represents a transaction header that includes a block header and an optional transaction hash. + * Local transaction inclusion metadata. + * + * @public */ export interface TransactionHeader { /** - * The block header associated with the transaction, represented as `ccc.ClientBlockHeader`. + * The block header used for inclusion-dependent calculations. */ header: ccc.ClientBlockHeader; /** - * An optional transaction hash associated with the transaction, represented as `ccc.Hex`. - * This property may be undefined if the transaction hash is not applicable. + * The transaction hash when the caller has resolved it for the header. */ txHash?: ccc.Hex; } /** - * Represents the components of a value, including CKB and UDT amounts. + * CKB and UDT amounts carried by a cell, order, or planned value. + * + * @public */ export interface ValueComponents { - /** The CKB amount as a `ccc.FixedPoint`. */ + /** CKB-side amount as a `ccc.FixedPoint`. */ ckbValue: ccc.FixedPoint; - /** The UDT amount as a `ccc.FixedPoint`. */ + /** UDT-side amount as a `ccc.FixedPoint`. */ udtValue: ccc.FixedPoint; } /** - * Represents the exchange ratio between CKB and a UDT. - * This interface is usually used in conjunction with `ValueComponents` to understand the values of entities. + * Integer scale pair for comparing or converting CKB-side and UDT-side values. + * + * @remarks + * CKB-to-UDT conversions multiply by `ckbScale` and divide by `udtScale`. + * UDT-to-CKB conversions swap the scales. Callers choose the rounding policy. * - * For example, if `v` implements `ValueComponents` and `r` is an `ExchangeRatio`: - * - The absolute value of `v` is calculated as: - * `v.ckbValue * r.ckbScale + v.udtValue * r.udtScale` - * - The equivalent CKB value of `v` is calculated as: - * `v.ckbValue + (v.udtValue * r.udtScale + r.ckbScale - 1n) / r.ckbScale` - * - The equivalent UDT value of `v` is calculated as: - * `v.udtValue + (v.ckbValue * r.ckbScale + r.udtScale - 1n) / r.udtScale` + * @public */ export interface ExchangeRatio { - /** The CKB scale as a `ccc.Num`. */ + /** Numerator scale for CKB-side values. */ ckbScale: ccc.Num; - /** The UDT scale as a `ccc.Num`. */ + /** Numerator scale for UDT-side values. */ udtScale: ccc.Num; } /** - * Interface representing the full configuration needed for interacting with a Script + * Script plus cell dependencies needed to build transactions that use it. + * + * @public */ export interface ScriptDeps { /** - * The script for which additional information is being provided. - * @type {ccc.Script} + * The lock or type script. */ script: ccc.Script; /** - * An array of cell dependencies associated with the script. - * @type {ccc.CellDep[]} + * Cell dependencies required to resolve the script code. */ cellDeps: ccc.CellDep[]; } /** - * True when a cell is plain spendable CKB capacity with no type script and no data payload. + * True when a cell has no type script and no data payload. + * + * @remarks + * This is a structural filter for plain capacity cells. Spendability still + * depends on the lock script, live cell state, and transaction context. + * + * @public */ export function isPlainCapacityCell(cell: ccc.Cell): boolean { return cell.cellOutput.type === undefined && cell.outputData === "0x"; } -/** - * Shuffles in-place an array using the Durstenfeld shuffle algorithm. - * @link https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle - * - * @param array - The array to shuffle. - * @returns The same array containing the shuffled elements. - */ -export function shuffle(array: T[]): T[] { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - [array[i], array[j]] = [array[j]!, array[i]!]; - } - return array; -} - /** * Performs a binary search to find the smallest index `i` in the range [0, n) * such that the function `f(i)` returns true. It is assumed that for the range @@ -121,18 +121,17 @@ export function shuffle(array: T[]): T[] { * * The function `f` is only called for indices in the range [0, n). * - * @param n - The upper bound of the search range (exclusive). + * @param n - The non-negative integer upper bound of the search range (exclusive). * @param f - A function that takes an index `i` and returns a boolean value. * @returns The smallest index `i` such that `f(i)` is true, or `n` if no such index exists. * - * @credits go standard library authors, this implementation is just a translation: - * https://go.dev/src/sort/search.go + * @remarks Adapted from Go's standard library search implementation: + * {@link https://go.dev/src/sort/search.go} * * @example - * // Example usage: - * const isGreaterThanFive = (i: number) => i > 5; - * const index = binarySearch(10, isGreaterThanFive); // Returns 6 + * `binarySearch(10, (i) => i > 5)` returns `6`. * + * @public */ export function binarySearch(n: number, f: (i: number) => boolean): number { // Define f(-1) == false and f(n) == true. @@ -160,12 +159,14 @@ export function binarySearch(n: number, f: (i: number) => boolean): number { * * The function `f` is only called for indices in the range [0, n). * - * @param n - The upper bound of the search range (exclusive). + * @param n - The non-negative integer upper bound of the search range (exclusive). * @param f - An async function that takes an index `i` and returns a boolean value. * @returns The smallest index `i` such that `f(i)` is true, or `n` if no such index exists. * - * @credits go standard library authors, this implementation is just a translation of that code: - * https://go.dev/src/sort/search.go * + * @remarks Adapted from Go's standard library search implementation: + * {@link https://go.dev/src/sort/search.go} + * + * @public */ export async function asyncBinarySearch( n: number, @@ -193,9 +194,11 @@ export async function asyncBinarySearch( * This function takes an `AsyncIterable` as input and returns a promise that resolves * to an array containing all the elements yielded by the iterable. * - * @template T - The type of elements in the input iterable. - * @param {AsyncIterable} inputs - The asynchronous iterable to convert into an array. - * @returns {Promise} A promise that resolves to an array of elements. + * @typeParam T - The type of elements in the input iterable. + * @param inputs - The asynchronous iterable to convert into an array. + * @returns A promise that resolves to an array of elements. + * + * @public */ export async function collect(inputs: AsyncIterable): Promise { const res = []; @@ -205,6 +208,13 @@ export async function collect(inputs: AsyncIterable): Promise { return res; } +/** + * Compares two bigint values using sort-compatible ordering. + * + * @returns `-1` when `left` is smaller, `1` when `left` is larger, and `0` when equal. + * + * @public + */ export function compareBigInt(left: bigint, right: bigint): number { if (left < right) { return -1; @@ -219,22 +229,31 @@ export function compareBigInt(left: bigint, right: bigint): number { /** * A buffered generator that tries to maintain a fixed-size buffer of values. + * + * @public */ export class BufferedGenerator { + /** Current buffered window of values. */ public buffer: T[] = []; + /** Wrapped generator that supplies future values. */ + public generator: Generator; + + /** Target maximum number of buffered values. */ + public maxSize: number; + /** - * Creates an instance of Buffered. + * Creates a `BufferedGenerator` and fills the initial buffer. + * * @param generator - The generator to buffer values from. - * @param maxSize - The maximum size of the buffer. + * @param maxSize - The non-negative integer target maximum buffer size. */ - constructor( - public generator: Generator, - public maxSize: number, - ) { + constructor(generator: Generator, maxSize: number) { + this.generator = generator; + this.maxSize = maxSize; while (this.buffer.length < this.maxSize) { const { value, done } = this.generator.next(); - if (done) { + if (done === true) { break; } this.buffer.push(value); @@ -242,62 +261,24 @@ export class BufferedGenerator { } /** - * Advances the buffer by the specified number of steps. - * @param n - The number of steps to advance the buffer. + * Advances the buffer by discarding buffered values and reading replacements. + * + * @remarks + * The buffer can shrink below `maxSize` once the wrapped generator is exhausted. + * + * @param n - The non-negative integer number of buffered positions to advance. */ public next(n: number): void { for (let i = 0; i < n; i++) { this.buffer.shift(); const { value, done } = this.generator.next(); - if (!done) { + if (done !== true) { this.buffer.push(value); } } } } -/** - * Returns the sum of a list of values. - * - * This function adds together an initial value with a variable number of additional values. - * The operation is performed in a pairwise reduction manner to improve performance by reducing - * the number of allocations, while achieving on numbers better numerical stability than naive summation. - * It supports numbers (the main target) and bigints. - * - * @param res - The initial value used as the starting point for the sum. - * @param rest - A variable number of additional values to be added. - * @returns The sum of all provided values. - * - * @example - * // Example usage with numbers: - * const result = sum(1, 5, 3, 9, 2); // Returns 20 - * - * @example - * // Example usage with bigints: - * const resultBigInt = sum(1n, 5n, 3n, 9n, 2n); // Returns 20n - */ -export function sum(res: number, ...rest: number[]): number; -export function sum(res: bigint, ...rest: bigint[]): bigint; -export function sum(res: T, ...rest: T[]): T { - const elements = [res, ...rest] as number[]; - let n = elements.length; - - // Perform pairwise reduction until a single value remains. - while (n > 1) { - const half = n >> 1; - const isOdd = n % 2; - // If there is an odd element, elements[half] is already in the correct place. - for (let i = 0; i < half; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - elements[i]! += elements[n - i - 1]!; - } - n = half + isOdd; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return elements[0]! as T; -} - /** * Yields unique items from the given iterable based on their hex representation. * @@ -308,10 +289,10 @@ export function sum(res: T, ...rest: T[]): T { * @param items - An iterable collection of items of type T. * @returns A generator that yields items from the iterable, ensuring that each item's * hex representation (via toHex()) is unique. + * + * @public */ -export function* unique( - items: Iterable, -): Generator { +export function* unique(items: Iterable): Generator { const set = new Set(); for (const i of items) { const key = i.toHex(); diff --git a/packages/utils/src/codec.test.ts b/packages/utils/test/codec.ts similarity index 51% rename from packages/utils/src/codec.test.ts rename to packages/utils/test/codec.ts index 0434baf..028d57f 100644 --- a/packages/utils/src/codec.test.ts +++ b/packages/utils/test/codec.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { CheckedInt32LE } from "./codec.js"; +import { CheckedInt32LE } from "../src/codec.ts"; describe("CheckedInt32LE", () => { + it("rejects values outside signed int32 bounds", () => { + expect(() => CheckedInt32LE.encode(2147483648)).toThrow( + "NumLike out of int32 bounds", + ); + expect(() => CheckedInt32LE.encode(-2147483649)).toThrow( + "NumLike out of int32 bounds", + ); + }); + it("decodes from the provided byte view offset", () => { const backing = new Uint8Array(8); backing.set([0xaa, 0xbb, 0xcc, 0xdd], 0); diff --git a/packages/utils/test/index.ts b/packages/utils/test/index.ts new file mode 100644 index 0000000..3110f0a --- /dev/null +++ b/packages/utils/test/index.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import * as utils from "../src/index.ts"; + +describe("utils package barrel", () => { + it("exposes runtime behavior through the barrel", async () => { + expect(utils.CheckedInt32LE.decode(utils.CheckedInt32LE.encode(-42))).toBe(-42); + expect(utils.binarySearch(6, (index) => index >= 4)).toBe(4); + await expect( + utils.asyncBinarySearch(6, async (index) => { + await Promise.resolve(); + return index >= 5; + }), + ).resolves.toBe(5); + }); +}); diff --git a/packages/utils/test/utils.ts b/packages/utils/test/utils.ts new file mode 100644 index 0000000..b5c50dd --- /dev/null +++ b/packages/utils/test/utils.ts @@ -0,0 +1,176 @@ +import { ccc } from "@ckb-ccc/core"; +import { describe, expect, it } from "vitest"; +import { + asyncBinarySearch, + binarySearch, + BufferedGenerator, + collect, + collectPagedScan, + compareBigInt, + isPlainCapacityCell, + unique, +} from "../src/utils.ts"; + +describe("compareBigInt", () => { + it("orders bigint values", () => { + expect(compareBigInt(1n, 2n)).toBe(-1); + expect(compareBigInt(2n, 2n)).toBe(0); + expect(compareBigInt(3n, 2n)).toBe(1); + }); +}); + +describe("BufferedGenerator", () => { + it("keeps advancing the wrapped generator after the initial fill", () => { + function* numbers(): Generator { + yield 1; + yield 2; + yield 3; + } + + const buffered = new BufferedGenerator(numbers(), 2); + + expect(buffered.buffer).toEqual([1, 2]); + + buffered.next(1); + expect(buffered.buffer).toEqual([2, 3]); + + buffered.next(1); + expect(buffered.buffer).toEqual([3]); + }); + + it("stops initial buffering when the wrapped generator is exhausted", () => { + function* oneNumber(): Generator { + yield 1; + } + + const buffered = new BufferedGenerator(oneNumber(), 3); + + expect(buffered.buffer).toEqual([1]); + }); +}); + +describe("scan collection", () => { + it("passes the cell page size through and collects all yielded items", async () => { + const seenPageSizes: number[] = []; + + await expect( + collectPagedScan( + async function* (pageSize: number): AsyncGenerator { + seenPageSizes.push(pageSize); + yield 1; + yield 2; + await Promise.resolve(); + }, + { pageSize: 2 }, + ), + ).resolves.toEqual([1, 2]); + expect(seenPageSizes).toEqual([2]); + }); + + it("collects async iterable values", async () => { + await expect( + collect( + (async function* (): AsyncGenerator { + yield "a"; + await Promise.resolve(); + yield "b"; + })(), + ), + ).resolves.toEqual(["a", "b"]); + }); +}); + +describe("isPlainCapacityCell", () => { + it("accepts cells without a type script or data", () => { + const cell = testCell({ type: undefined, outputData: "0x" }); + + expect(isPlainCapacityCell(cell)).toBe(true); + }); + + it("rejects typed cells and data-carrying cells", () => { + const typed = testCell({ + type: { codeHash: "0x", hashType: "type", args: "0x" }, + outputData: "0x", + }); + const dataCarrying = testCell({ type: undefined, outputData: "0x01" }); + + expect(isPlainCapacityCell(typed)).toBe(false); + expect(isPlainCapacityCell(dataCarrying)).toBe(false); + }); +}); + +describe("binary search helpers", () => { + it("finds the first matching index", () => { + expect(binarySearch(8, (index) => index >= 5)).toBe(5); + }); + + it("returns the range end when no index matches", () => { + expect(binarySearch(4, () => false)).toBe(4); + }); + + it("finds the first matching index asynchronously", async () => { + await expect( + asyncBinarySearch(8, async (index) => { + await Promise.resolve(); + return index >= 3; + }), + ).resolves.toBe(3); + }); +}); + +describe("unique", () => { + it("yields only the first entity for each hex key", () => { + const first = hexEntity("0x01"); + const duplicate = hexEntity("0x01"); + const second = hexEntity("0x02"); + + expect([...unique([first, duplicate, second])]).toEqual([first, second]); + }); +}); + +function testCell({ + type, + outputData, +}: { + type: ccc.ScriptLike | undefined; + outputData: ccc.Hex; +}): ccc.Cell { + return ccc.Cell.from({ + outPoint: { txHash: `0x${"11".repeat(32)}`, index: 0n }, + cellOutput: { + capacity: 0n, + lock: { codeHash: `0x${"22".repeat(32)}`, hashType: "type", args: "0x" }, + type, + }, + outputData, + }); +} + +function hexEntity(value: ccc.Hex): ccc.Entity { + return new TestEntity(value); +} + +class TestEntity extends ccc.Entity { + private readonly value: ccc.Hex; + + constructor(value: ccc.Hex) { + super(); + this.value = value; + } + + public override hash(): ccc.Hex { + return ccc.hashCkb(this.value); + } + + public override toBytes(): ccc.Bytes { + return ccc.bytesFrom(this.value); + } + + public override toHex(): ccc.Hex { + return this.value; + } + + public override clone(): TestEntity { + return new TestEntity(this.value); + } +} diff --git a/packages/utils/tsconfig.build.json b/packages/utils/tsconfig.build.json new file mode 100644 index 0000000..3f817b1 --- /dev/null +++ b/packages/utils/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "removeComments": false, + "rewriteRelativeImportExtensions": true, + "rootDir": "src", + "outDir": "dist", + "sourceRoot": "../src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 84a843d..c6a53fe 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,11 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "noEmit": false, - "rootDir": "src", - "outDir": "dist", - "sourceRoot": "../src" + "noEmit": true }, - "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "test"] } diff --git a/packages/utils/vitest.config.mts b/packages/utils/vitest.config.mts index dc6a587..e5f423a 100644 --- a/packages/utils/vitest.config.mts +++ b/packages/utils/vitest.config.mts @@ -2,9 +2,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["{test,tests}/*.{ts,tsx}"], coverage: { - include: ["src/**/*.ts"], + include: ["src/**/*.{ts,tsx}"], }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b0db94..e5e9a88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,8 +11,14 @@ catalogs: version: 24.12.2 overrides: + '@babel/core@<=7.29.0': 7.29.7 brace-expansion@>=5.0.0 <5.0.6: 5.0.6 - ws@>=8.0.0 <8.20.1: 8.20.1 + esbuild@>=0.27.3 <0.28.1: 0.28.1 + form-data@>=4.0.0 <4.0.6: 4.0.6 + js-yaml@>=4.0.0 <=4.1.1: 4.2.0 + vite@<=6.4.2: 6.4.3 + vitest@<4.1.0: 4.1.8 + ws@>=8.0.0 <8.21.0: 8.21.0 '@ckb-ccc/ccc': workspace:* '@ckb-ccc/core': workspace:* '@ckb-ccc/udt': workspace:* @@ -27,9 +33,15 @@ importers: '@ickb/testkit': specifier: workspace:* version: link:packages/testkit + '@microsoft/api-extractor': + specifier: ^7.58.9 + version: 7.58.9(@types/node@24.12.2) + '@typescript/native-preview': + specifier: latest + version: 7.0.0-dev.20260704.1 '@vitest/coverage-v8': - specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + specifier: 4.1.8 + version: 4.1.8(vitest@4.1.8) eslint: specifier: ^9.39.3 version: 9.39.4(jiti@2.6.1) @@ -52,8 +64,8 @@ importers: specifier: ^8.56.1 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: ^4.1.8 + version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) apps/bot: dependencies: @@ -112,13 +124,13 @@ importers: devDependencies: '@babel/preset-react': specifier: ^7.27.1 - version: 7.28.5(@babel/core@7.29.0) + version: 7.28.5(@babel/core@7.29.7) '@ickb/testkit': specifier: workspace:* version: link:../../packages/testkit '@tailwindcss/vite': specifier: ^4.1.14 - version: 4.2.4(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.2.4(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) '@types/node': specifier: ^22.18.11 version: 22.19.17 @@ -130,10 +142,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-basic-ssl': specifier: ^1.2.0 - version: 1.2.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 1.2.0(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.7.0(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -144,8 +156,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^6.4.0 - version: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: 6.4.3 + version: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) apps/sampler: dependencies: @@ -403,10 +415,10 @@ importers: version: 6.16.0 isomorphic-ws: specifier: ^5.0.0 - version: 5.0.0(ws@8.20.1) + version: 5.0.0(ws@8.21.0) ws: - specifier: 8.20.1 - version: 8.20.1 + specifier: 8.21.0 + version: 8.21.0 devDependencies: '@eslint/js': specifier: ^9.34.0 @@ -442,8 +454,8 @@ importers: specifier: ^8.41.0 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: 4.1.8 + version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) forks/ccc/repo/packages/did-ckb: dependencies: @@ -480,7 +492,7 @@ importers: version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) tsdown: specifier: 0.19.0-beta.3 - version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(synckit@0.11.12)(typescript@5.9.3) + version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: ^5.9.2 version: 5.9.3 @@ -488,8 +500,8 @@ importers: specifier: ^8.41.0 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: 4.1.8 + version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) forks/ccc/repo/packages/eip6963: dependencies: @@ -841,8 +853,8 @@ importers: specifier: ^8.41.0 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: 4.1.8 + version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) forks/ccc/repo/packages/ssri: dependencies: @@ -913,7 +925,7 @@ importers: version: 4.3.0(prettier@3.8.3)(typescript@5.9.3) tsdown: specifier: 0.19.0-beta.3 - version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(synckit@0.11.12)(typescript@5.9.3) + version: 0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3) typescript: specifier: ^5.9.2 version: 5.9.3 @@ -921,8 +933,8 @@ importers: specifier: ^8.41.0 version: 8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + specifier: 4.1.8 + version: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) forks/ccc/repo/packages/udt: dependencies: @@ -1190,44 +1202,52 @@ packages: '@adraffy/ens-normalize@1.10.1': resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.3': - resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} '@babel/helper-create-class-features-plugin@7.29.3': resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.7 '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} @@ -1236,11 +1256,15 @@ packages: resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.7 '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} @@ -1254,7 +1278,7 @@ packages: resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} @@ -1264,16 +1288,28 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.3': @@ -1281,73 +1317,90 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-display-name@7.28.0': resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-development@7.27.1': resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-source@7.27.1': resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx@7.28.6': resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/plugin-transform-react-pure-annotations@7.27.1': resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/preset-react@7.28.5': resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.7 '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1619,14 +1672,6 @@ packages: resolution: {integrity: sha512-vZGJ84Em2jCVAS7td5gc08YTVN8/s4bTQxg4pU77PAXDAR/yLYOthOvkCu01fdl1lrZwz47RdUterxdkrs3p5A==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - '@joyid/ckb@1.1.4': resolution: {integrity: sha512-8WqcfFd/kfttuaIf2/XsRcmQkEwYLUnZc9UZRcGSVX6GkwzpOLVY5S6Hq+fDXY82lQo9upJGjjudrtcPKYPx3g==} @@ -1660,6 +1705,19 @@ packages: '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} + '@microsoft/api-extractor-model@7.33.8': + resolution: {integrity: sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==} + + '@microsoft/api-extractor@7.58.9': + resolution: {integrity: sha512-S2UF4yza5GoxCmf7hJQNxJNZN9ltOVuOQv8Dy+Z21aol5ERoBNMdWcQHm4MJMPPItW4H/4rZD906iaf4mUojJA==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.1': + resolution: {integrity: sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1696,10 +1754,6 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2030,6 +2084,39 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.23.1': + resolution: {integrity: sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.2.1': + resolution: {integrity: sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.7.3': + resolution: {integrity: sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==} + + '@rushstack/terminal@0.24.0': + resolution: {integrity: sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.3.10': + resolution: {integrity: sha512-fwI076HYknC0IrMXdY6UmjDv+PH7NHhNJX3/pY2UblSE5XrXgndXZPiOe/6ZtuFpn6DvVDVNhtkIzQ+Qu/MhVQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.4': resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} @@ -2122,7 +2209,7 @@ packages: '@tailwindcss/vite@4.2.4': resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 + vite: 6.4.3 '@tanstack/query-core@5.100.9': resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==} @@ -2151,6 +2238,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2269,55 +2359,102 @@ packages: resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-WHNMx8HTka+2w9ZifYbfkHHyQBzO+MCbOLQzNJE3K7A7mYJ1aq6ZEvBhRcNqixjKAIIO4U34KQvaQ6PXlwezkQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-IZJib4da/+3gRVSQFoQU1uFzsiSXnI02xBZljipDoPwahRcOdjH9gJ7DMBFbZ53pUtN5lvqbpES4oXiJZTneDA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-rjyHYAmDQ68NZIpU5i46D50hgQ63UJGGN/LJv3DAPWC8KraNonS5j4fN2bH/V+acQV9l/sS3+US1daV8VkQaFQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-Dkz8oqvg4nEIu2Acz/d+iBDhIIOqjaAJ6mo6a2diRrDI+K5NAHljTzvkclCxgz6WIa5JnUY3kWzy8jE5AtaCQg==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-y2L2eNwCgrTgXKMol9n/bSPeyNHwJbrUDAv6/J7/087yhPNCt61l3KbpiGAYs0KYcCt3V2lNSDtS3geHQfSWJQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-PzBVRs8UNSAqtoCp9BtT1jsG4SSOfuDT1yo8Z3gGevmNtKEFZjb92BTSnBZaMPPQkM22fq83zgrAEVOEENIicw==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-wb/C+fqWpXAoJZd8KQZdpjOq67v/cmLsApjL9oHjC1X6zqz7AzyOQ486uxWT6LvYzKkar4A+iq8h5h+PNgN38w==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260704.1': + resolution: {integrity: sha512-B8CxdI0CumQVV9/9COcanWCWJmu8Jm1kMuYvse3iGm5ay9QrKTD+qyb11x8SPKUMtiUqnhNxt0n4ESNt+LQMuQ==} + engines: {node: '>=16.20.0'} + hasBin: true + '@vitejs/plugin-basic-ssl@1.2.0': resolution: {integrity: sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==} engines: {node: '>=14.21.3'} peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: 6.4.3 '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: 6.4.3 - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 + '@vitest/browser': 4.1.8 + vitest: 4.1.8 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: 6.4.3 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@vue/compiler-core@3.5.34': resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} @@ -2360,25 +2497,36 @@ packages: aes-js@4.0.0-beta.5: resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2389,6 +2537,9 @@ packages: app-module-path@2.2.0: resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2404,8 +2555,8 @@ packages: resolution: {integrity: sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==} engines: {node: '>=18'} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2474,9 +2625,6 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -2523,18 +2671,14 @@ packages: resolution: {integrity: sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==} hasBin: true - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2607,10 +2751,6 @@ packages: supports-color: optional: true - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2683,6 +2823,10 @@ packages: peerDependencies: typescript: ^5.4.4 || ^6.0.2 + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -2700,9 +2844,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.349: resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} @@ -2712,9 +2853,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - empathic@2.0.0: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} @@ -2735,8 +2873,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -2882,6 +3020,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2920,14 +3061,14 @@ packages: debug: optional: true - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2969,11 +3110,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3017,6 +3153,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -3050,6 +3190,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + import-without-cache@0.2.5: resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} engines: {node: '>=20.19.0'} @@ -3116,7 +3260,7 @@ packages: isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: - ws: 8.20.1 + ws: 8.21.0 istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -3126,36 +3270,29 @@ packages: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-xxhash@1.0.4: resolution: {integrity: sha512-S/6Oo7ruxx5k8m4qlMnbpwQdJjRsvvfcIhIk1dA9c5y5GNhYHKYKu9krEK3QgBax6CxJuf4gRL2opgLkdzWIKg==} engines: {node: '>=8.0.0'} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true jsbi@3.1.3: @@ -3175,6 +3312,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3183,6 +3323,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3284,12 +3427,6 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.5: resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} @@ -3310,8 +3447,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -3339,6 +3476,10 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3346,10 +3487,6 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3460,10 +3597,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3471,10 +3604,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3492,10 +3621,6 @@ packages: peerDependencies: postcss: ^8.2.9 - postcss@8.5.13: - resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -3580,6 +3705,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requirejs-config-file@4.0.0: resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==} engines: {node: '>=10.13.0'} @@ -3682,10 +3811,6 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3694,23 +3819,26 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stream-to-array@2.3.0: resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string_decoder@0.10.31: resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} @@ -3725,10 +3853,6 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3741,9 +3865,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - stylus-lookup@6.1.2: resolution: {integrity: sha512-O+Q/SJ8s1X2aMLh4213fQ9X/bND9M3dhSsyTRe+O1OXPcewGLiYmAtKCrnP7FDvDBaXB2ZHPkCt3zi4cJXBlCQ==} engines: {node: '>=18'} @@ -3753,6 +3874,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -3768,19 +3893,12 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - test-exclude@7.0.2: - resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} - engines: {node: '>=18'} - through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -3789,16 +3907,8 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tr46@0.0.3: @@ -3899,6 +4009,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unrun@0.2.37: resolution: {integrity: sha512-AA7vDuYsgeSYVzJMm16UKA+aXFKhy7nFqW9z5l7q44K4ppFWZAMqYS58ePRZbugMLPH0fwwMzD5A8nP0avxwZQ==} engines: {node: '>=20.19.0'} @@ -3936,13 +4050,8 @@ packages: varuint-bitcoin@2.0.0: resolution: {integrity: sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@6.4.2: - resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -3981,26 +4090,39 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: 6.4.3 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -4040,15 +4162,11 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.1: - resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4095,30 +4213,31 @@ snapshots: '@adraffy/ens-normalize@1.10.1': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.3': {} + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4136,25 +4255,33 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.29.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.3 - '@babel/helper-validator-option': 7.27.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/traverse': 7.29.0 semver: 6.3.1 @@ -4163,6 +4290,8 @@ snapshots: '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 @@ -4177,12 +4306,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -4192,9 +4328,9 @@ snapshots: '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/traverse': 7.29.0 @@ -4210,80 +4346,90 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.29.2': + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 - '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.29.0)': + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-proposal-private-methods@7.18.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + '@babel/preset-react@7.28.5(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.7) transitivePeerDependencies: - supports-color @@ -4293,6 +4439,12 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -4305,11 +4457,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} '@ckb-lumos/base@0.24.0-next.2': @@ -4523,7 +4692,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.2.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4559,17 +4728,6 @@ snapshots: cborg: 5.1.1 multiformats: 13.4.2 - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/schema@0.1.6': {} - '@joyid/ckb@1.1.4(typescript@5.9.3)(zod@3.25.76)': dependencies: '@joyid/common': 0.2.2(typescript@5.9.3)(zod@3.25.76) @@ -4618,6 +4776,41 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.5.1 + '@microsoft/api-extractor-model@7.33.8(@types/node@24.12.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.2) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.58.9(@types/node@24.12.2)': + dependencies: + '@microsoft/api-extractor-model': 7.33.8(@types/node@24.12.2) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.1 + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.2) + '@rushstack/rig-package': 0.7.3 + '@rushstack/terminal': 0.24.0(@types/node@24.12.2) + '@rushstack/ts-command-line': 5.3.10(@types/node@24.12.2) + diff: 8.0.4 + minimatch: 10.2.3 + resolve: 1.22.12 + semver: 7.7.4 + source-map: 0.6.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.1': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.18.0 + jju: 1.4.0 + resolve: 1.22.12 + + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -4653,9 +4846,6 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@pkgr/core@0.2.9': {} '@quansync/fs@1.0.0': @@ -4836,6 +5026,47 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@rushstack/node-core-library@5.23.1(@types/node@24.12.2)': + dependencies: + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + fs-extra: 11.3.6 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.12 + semver: 7.7.4 + optionalDependencies: + '@types/node': 24.12.2 + + '@rushstack/problem-matcher@0.2.1(@types/node@24.12.2)': + optionalDependencies: + '@types/node': 24.12.2 + + '@rushstack/rig-package@0.7.3': + dependencies: + jju: 1.4.0 + resolve: 1.22.12 + + '@rushstack/terminal@0.24.0(@types/node@24.12.2)': + dependencies: + '@rushstack/node-core-library': 5.23.1(@types/node@24.12.2) + '@rushstack/problem-matcher': 0.2.1(@types/node@24.12.2) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.12.2 + + '@rushstack/ts-command-line@5.3.10(@types/node@24.12.2)': + dependencies: + '@rushstack/terminal': 0.24.0(@types/node@24.12.2) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4897,12 +5128,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': + '@tailwindcss/vite@4.2.4(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) '@tanstack/query-core@5.100.9': {} @@ -4931,6 +5162,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/argparse@1.0.38': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -5090,82 +5323,107 @@ snapshots: '@typescript-eslint/types': 8.59.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@1.2.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260704.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260704.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260704.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260704.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260704.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260704.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260704.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260704.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260704.1 + + '@vitejs/plugin-basic-ssl@1.2.0(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: - vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.7) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: - '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - transitivePeerDependencies: - - supports-color + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - '@vitest/expect@3.2.4': + '@vitest/expect@4.1.8': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitest/mocker@4.1.8(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.8': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.8': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@vue/compiler-core@3.5.34': dependencies: @@ -5217,6 +5475,14 @@ snapshots: aes-js@4.0.0-beta.5: {} + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5224,22 +5490,29 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ansi-regex@5.0.1: {} + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 - ansi-regex@6.2.2: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.3: {} - ansis@4.2.0: {} any-promise@1.3.0: {} app-module-path@2.2.0: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -5251,7 +5524,7 @@ snapshots: ast-module-types@6.0.1: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.4: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -5262,7 +5535,7 @@ snapshots: axios@1.16.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -5333,10 +5606,6 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -5387,21 +5656,13 @@ snapshots: cborg@5.1.1: {} - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - check-error@2.1.3: {} - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -5472,8 +5733,6 @@ snapshots: dependencies: ms: 2.1.3 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} deep-freeze-strict@1.1.1: {} @@ -5555,6 +5814,8 @@ snapshots: transitivePeerDependencies: - supports-color + diff@8.0.4: {} + dotenv@17.4.2: {} dts-resolver@2.1.3: {} @@ -5565,8 +5826,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.349: {} elliptic@6.6.1: @@ -5581,8 +5840,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - empathic@2.0.0: {} enhanced-resolve@5.21.0: @@ -5596,7 +5853,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.0: {} es-object-atoms@1.1.1: dependencies: @@ -5607,7 +5864,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 esbuild@0.25.12: optionalDependencies: @@ -5665,9 +5922,9 @@ snapshots: eslint-plugin-react-compiler@19.1.0-rc.2(eslint@9.39.4(jiti@2.6.1)): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.7 '@babel/parser': 7.29.3 - '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-methods': 7.18.6(@babel/core@7.29.7) eslint: 9.39.4(jiti@2.6.1) hermes-parser: 0.25.1 zod: 3.25.76 @@ -5769,7 +6026,7 @@ snapshots: '@types/node': 22.7.5 aes-js: 4.0.0-beta.5 tslib: 2.7.0 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5786,6 +6043,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -5822,19 +6081,20 @@ snapshots: follow-redirects@1.16.0: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.3 + hasown: 2.0.4 mime-types: 2.1.35 + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -5861,7 +6121,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-own-enumerable-property-symbols@3.0.2: {} @@ -5879,15 +6139,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -5930,6 +6181,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -5959,6 +6214,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-lazy@4.0.0: {} + import-without-cache@0.2.5: {} imurmurhash@0.1.4: {} @@ -6000,9 +6257,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.20.1): + isomorphic-ws@5.0.0(ws@8.21.0): dependencies: - ws: 8.20.1 + ws: 8.21.0 istanbul-lib-coverage@3.2.2: {} @@ -6012,36 +6269,22 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jiti@2.6.1: {} + jju@1.4.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} - js-tokens@9.0.1: {} - js-xxhash@1.0.4: {} - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -6055,10 +6298,18 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -6144,10 +6395,6 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - loupe@3.2.1: {} - - lru-cache@10.4.3: {} - lru-cache@11.3.5: {} lru-cache@5.1.1: @@ -6177,7 +6424,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.3.5: + magicast@0.5.3: dependencies: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 @@ -6201,6 +6448,10 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} + minimatch@10.2.3: + dependencies: + brace-expansion: 5.0.6 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -6209,10 +6460,6 @@ snapshots: dependencies: brace-expansion: 1.1.14 - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.0 - minimist@1.2.8: {} minipass@7.1.3: {} @@ -6310,11 +6557,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - path-scurry@2.0.2: dependencies: lru-cache: 11.3.5 @@ -6322,8 +6564,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -6337,12 +6577,6 @@ snapshots: postcss: 8.5.14 quote-unquote: 1.0.0 - postcss@8.5.13: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -6437,6 +6671,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requirejs-config-file@4.0.0: dependencies: esprima: 4.0.1 @@ -6467,7 +6703,7 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown-plugin-dts@0.20.0(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3): + rolldown-plugin-dts@0.20.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3): dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.3 @@ -6479,6 +6715,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optionalDependencies: + '@typescript/native-preview': 7.0.0-dev.20260704.1 typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -6580,33 +6817,28 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} stackback@0.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stream-to-array@2.3.0: dependencies: any-promise: 1.3.0 + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string_decoder@0.10.31: {} string_decoder@1.1.1: @@ -6623,20 +6855,12 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - strip-bom@3.0.0: {} strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - stylus-lookup@6.1.2: dependencies: commander: 12.1.0 @@ -6645,6 +6869,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} synckit@0.11.12: @@ -6655,12 +6883,6 @@ snapshots: tapable@2.3.3: {} - test-exclude@7.0.2: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 10.5.0 - minimatch: 10.2.5 - through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -6668,8 +6890,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@0.3.2: {} - tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -6677,11 +6897,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} + tinyrainbow@3.1.0: {} tr46@0.0.3: {} @@ -6704,7 +6920,7 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tsdown@0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(synckit@0.11.12)(typescript@5.9.3): + tsdown@0.19.0-beta.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@typescript/native-preview@7.0.0-dev.20260704.1)(synckit@0.11.12)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -6715,7 +6931,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - rolldown-plugin-dts: 0.20.0(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3) + rolldown-plugin-dts: 0.20.0(@typescript/native-preview@7.0.0-dev.20260704.1)(rolldown@1.0.0-beta.58(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(typescript@5.9.3) semver: 7.7.4 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -6775,6 +6991,8 @@ snapshots: undici-types@7.16.0: {} + universalify@2.0.1: {} + unrun@0.2.37(synckit@0.11.12): dependencies: rolldown: 1.0.0-rc.17 @@ -6803,33 +7021,12 @@ snapshots: dependencies: uint8array-tools: 0.0.8 - vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0): + vite@6.4.3(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.13 + postcss: 8.5.14 rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: @@ -6838,12 +7035,12 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): + vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.13 + postcss: 8.5.14 rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: @@ -6852,46 +7049,33 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 - vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): + vitest@4.1.8(@types/node@24.12.2)(@vitest/coverage-v8@4.1.8)(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.1.2 tinyglobby: 0.2.16 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.2 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml walkdir@0.4.1: {} @@ -6923,15 +7107,9 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrappy@1.0.2: {} - ws@8.20.1: {} + ws@8.21.0: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0947c31..c17d473 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,8 +30,14 @@ auditConfig: overrides: # Security pins for vulnerable transitive ranges not yet lifted by upstreams. + "@babel/core@<=7.29.0": 7.29.7 "brace-expansion@>=5.0.0 <5.0.6": 5.0.6 - "ws@>=8.0.0 <8.20.1": 8.20.1 + "esbuild@>=0.27.3 <0.28.1": 0.28.1 + "form-data@>=4.0.0 <4.0.6": 4.0.6 + "js-yaml@>=4.0.0 <=4.1.1": 4.2.0 + "vite@<=6.4.2": 6.4.3 + "vitest@<4.1.0": 4.1.8 + "ws@>=8.0.0 <8.21.0": 8.21.0 # Keep published manifests on `catalog:` while forcing the materialized # local CCC workspace to satisfy direct stack dependencies during installs. # Update this list alongside any new direct `@ckb-ccc/*` dependency. diff --git a/scripts/tooling/build/rewrite-dts-imports.ts b/scripts/tooling/build/rewrite-dts-imports.ts new file mode 100644 index 0000000..e963786 --- /dev/null +++ b/scripts/tooling/build/rewrite-dts-imports.ts @@ -0,0 +1,86 @@ +import type { Dirent, Stats } from "node:fs"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const args = process.argv.slice(2); +const check = args[0] === "--check"; +const roots = check ? args.slice(1) : args; + +if (roots.length === 0) { + throw new Error( + "Usage: node scripts/rewrite-dts-imports.ts [--check] [...]", + ); +} + +const changed: string[] = []; + +for (const root of roots) { + for (const file of await declarationFiles(root)) { + const original = await readText(file); + const rewritten = rewriteDeclarationImports(original); + if (rewritten === original) { + continue; + } + changed.push(file); + if (!check) { + await writeText(file, rewritten); + } + } +} + +if (check && changed.length > 0) { + throw new Error(`Declaration imports need rewriting:\n${changed.join("\n")}`); +} + +async function declarationFiles(filePath: string): Promise { + const stats = await statPath(filePath); + if (stats.isFile()) { + return filePath.endsWith(".d.ts") ? [filePath] : []; + } + if (!stats.isDirectory()) { + return []; + } + + const entries = await readDirectory(filePath); + const files: string[] = []; + for (const entry of entries) { + files.push(...(await declarationFiles(path.join(filePath, entry.name)))); + } + return files; +} + +async function readDirectory(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI roots are explicit user-selected build output paths. + return readdir(filePath, { encoding: "utf8", withFileTypes: true }); +} + +async function readText(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI roots are explicit user-selected build output paths. + return readFile(filePath, "utf8"); +} + +async function statPath(filePath: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI roots are explicit user-selected build output paths. + return stat(filePath); +} + +async function writeText(filePath: string, data: string): Promise { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- CLI roots are explicit user-selected build output paths. + await writeFile(filePath, data); +} + +function rewriteDeclarationImports(source: string): string { + return source + .replaceAll(/\b(from\s+)(["'])(\.{1,2}\/[^"'\n]*?)\.ts\2/gu, replaceSpecifier) + .replaceAll(/\b(import\s*\(\s*)(["'])(\.{1,2}\/[^"'\n]*?)\.ts\2/gu, replaceSpecifier) + .replaceAll(/\b(import\s+)(["'])(\.{1,2}\/[^"'\n]*?)\.ts\2/gu, replaceSpecifier); +} + +function replaceSpecifier( + match: string, + prefix: string, + quote: string, + specifier: string, +): string { + return specifier.endsWith(".d") ? match : `${prefix}${quote}${specifier}.js${quote}`; +} diff --git a/tsconfig.json b/tsconfig.json index d7e673e..53d32ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,12 @@ { "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022"], + "target": "ES2024", + "lib": ["ES2024"], "module": "NodeNext", "moduleResolution": "NodeNext", "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "isolatedModules": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, From f3a260d3dc66ddaf2d4dc71a442ba2c34adcf8a2 Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:01:52 +0000 Subject: [PATCH 2/2] fix(node-utils): preserve nullish log values --- packages/node-utils/src/logging.ts | 4 ++-- packages/node-utils/test/logging.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/node-utils/src/logging.ts b/packages/node-utils/src/logging.ts index 2efdeea..83aeaad 100644 --- a/packages/node-utils/src/logging.ts +++ b/packages/node-utils/src/logging.ts @@ -64,7 +64,7 @@ export function jsonLogReplacer(_: string, value: JsonLogValue | bigint): JsonLo } function errorToLog(error: unknown): JsonLogValue { - return toJsonLogValue(error, new WeakSet()); + return toJsonLogValue(error ?? "Empty Error", new WeakSet()); } /** @@ -87,7 +87,7 @@ export function toJsonLogValue(value: unknown, seen: WeakSet): JsonLogVa } else if (typeof value === "function") { logValue = UNSAFE_LOG_VALUE; } else if (isJsonLogPrimitive(value)) { - logValue = value ?? "Empty Error"; + logValue = value; } else if (value instanceof Date) { logValue = dateLogValue(value); } else if (isErrorLike(value)) { diff --git a/packages/node-utils/test/logging.ts b/packages/node-utils/test/logging.ts index 1a74488..dc3bc8e 100644 --- a/packages/node-utils/test/logging.ts +++ b/packages/node-utils/test/logging.ts @@ -214,6 +214,17 @@ describe("JSON line logging", () => { stdoutWrite.mockRestore(); }); + it("preserves nullish values in non-error JSON logs", () => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + writeJsonLine({ nullable: null, missing: undefined, values: [null, undefined] }); + + const parsed = jsonRecord(String(stdoutWrite.mock.calls[0]?.[0])); + expect(parsed).toEqual({ nullable: null, values: [null, null] }); + expect(Object.hasOwn(parsed, "missing")).toBe(false); + stdoutWrite.mockRestore(); + }); + it("preserves CKB debugging metadata", () => { const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const txHash = byte32FromByte("55");