From 04b80fc5ff5173523dee81c339be5f9fa20bad3a Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 8 Jun 2026 13:19:47 +0200 Subject: [PATCH 1/9] feat(deployer): add compact-deployer tool and CLI deploy command Introduce the `@openzeppelin/compact-deployer` package: a programmatic and CLI deployer for Compact contracts. Covers config loading, artifact and constructor-arg resolution, wallet/keystore handling, network and proof-server providers, and deployment-record bookkeeping. Wire the deployer into `@openzeppelin/compact-cli` via the new `compact-deploy` command (runDeploy), with passphrase prompting and JSON/verbose output modes. * Add root resolutions for the midnight stack and a `coverage` turbo task so the new package can report coverage. Part 1 of 2 splitting the original deployer PR. Examples and the integration-test suite follow in a stacked PR that builds on this one. Refs: OpenZeppelin/compact-tools#86 --- .gitignore | 31 + package.json | 11 + packages/cli/package.json | 19 +- packages/cli/src/logger.ts | 65 + packages/cli/src/prompt.ts | 48 + packages/cli/src/runBuilder.ts | 25 +- packages/cli/src/runCompiler.ts | 58 +- packages/cli/src/runDeploy.ts | 308 + packages/cli/test/logger.test.ts | 182 + packages/cli/test/prompt.test.ts | 150 + packages/cli/test/runBuilder.test.ts | 116 + packages/cli/test/runDeploy.test.ts | 531 ++ packages/cli/vitest.config.ts | 11 + packages/deployer/README.md | 169 + packages/deployer/package.json | 69 + .../src/config/compact-config.test.ts | 122 + .../deployer/src/config/compact-config.ts | 143 + packages/deployer/src/config/schema.test.ts | 249 + packages/deployer/src/config/schema.ts | 105 + packages/deployer/src/deployer.test.ts | 760 +++ packages/deployer/src/deployer.ts | 698 +++ packages/deployer/src/deployments.test.ts | 86 + packages/deployer/src/deployments.ts | 113 + packages/deployer/src/errors.test.ts | 99 + packages/deployer/src/errors.ts | 74 + packages/deployer/src/index.ts | 49 + packages/deployer/src/loaders/args.test.ts | 174 + packages/deployer/src/loaders/args.ts | 103 + .../deployer/src/loaders/artifact.test.ts | 314 + packages/deployer/src/loaders/artifact.ts | 191 + .../src/loaders/constructor-meta.test.ts | 75 + .../deployer/src/loaders/constructor-meta.ts | 109 + packages/deployer/src/loaders/context.test.ts | 78 + packages/deployer/src/loaders/context.ts | 50 + .../src/loaders/contract-resolve.test.ts | 181 + .../deployer/src/loaders/contract-resolve.ts | 92 + .../deployer/src/loaders/init-state.test.ts | 54 + packages/deployer/src/loaders/init-state.ts | 54 + .../deployer/src/loaders/ref-resolver.test.ts | 111 + packages/deployer/src/loaders/ref-resolver.ts | 44 + .../deployer/src/loaders/signing-key.test.ts | 34 + packages/deployer/src/loaders/signing-key.ts | 27 + packages/deployer/src/providers/build.test.ts | 241 + packages/deployer/src/providers/build.ts | 62 + .../deployer/src/providers/network.test.ts | 72 + packages/deployer/src/providers/network.ts | 53 + .../providers/private-state-password.test.ts | 61 + .../src/providers/private-state-password.ts | 27 + .../src/providers/proof-server.test.ts | 191 + .../deployer/src/providers/proof-server.ts | 111 + packages/deployer/src/runDeploy.test.ts | 359 ++ packages/deployer/src/runDeploy.ts | 405 ++ packages/deployer/src/wallet/handler.test.ts | 536 ++ packages/deployer/src/wallet/handler.ts | 456 ++ packages/deployer/src/wallet/keystore.test.ts | 151 + packages/deployer/src/wallet/keystore.ts | 202 + packages/deployer/src/wallet/seeds.test.ts | 264 + packages/deployer/src/wallet/seeds.ts | 141 + packages/deployer/tsconfig.json | 16 + packages/deployer/vitest.config.ts | 22 + turbo.json | 13 + yarn.lock | 5223 +++++++++++++++-- 62 files changed, 13937 insertions(+), 651 deletions(-) create mode 100644 packages/cli/src/logger.ts create mode 100644 packages/cli/src/prompt.ts create mode 100644 packages/cli/src/runDeploy.ts create mode 100644 packages/cli/test/logger.test.ts create mode 100644 packages/cli/test/prompt.test.ts create mode 100644 packages/cli/test/runBuilder.test.ts create mode 100644 packages/cli/test/runDeploy.test.ts create mode 100644 packages/deployer/README.md create mode 100644 packages/deployer/package.json create mode 100644 packages/deployer/src/config/compact-config.test.ts create mode 100644 packages/deployer/src/config/compact-config.ts create mode 100644 packages/deployer/src/config/schema.test.ts create mode 100644 packages/deployer/src/config/schema.ts create mode 100644 packages/deployer/src/deployer.test.ts create mode 100644 packages/deployer/src/deployer.ts create mode 100644 packages/deployer/src/deployments.test.ts create mode 100644 packages/deployer/src/deployments.ts create mode 100644 packages/deployer/src/errors.test.ts create mode 100644 packages/deployer/src/errors.ts create mode 100644 packages/deployer/src/index.ts create mode 100644 packages/deployer/src/loaders/args.test.ts create mode 100644 packages/deployer/src/loaders/args.ts create mode 100644 packages/deployer/src/loaders/artifact.test.ts create mode 100644 packages/deployer/src/loaders/artifact.ts create mode 100644 packages/deployer/src/loaders/constructor-meta.test.ts create mode 100644 packages/deployer/src/loaders/constructor-meta.ts create mode 100644 packages/deployer/src/loaders/context.test.ts create mode 100644 packages/deployer/src/loaders/context.ts create mode 100644 packages/deployer/src/loaders/contract-resolve.test.ts create mode 100644 packages/deployer/src/loaders/contract-resolve.ts create mode 100644 packages/deployer/src/loaders/init-state.test.ts create mode 100644 packages/deployer/src/loaders/init-state.ts create mode 100644 packages/deployer/src/loaders/ref-resolver.test.ts create mode 100644 packages/deployer/src/loaders/ref-resolver.ts create mode 100644 packages/deployer/src/loaders/signing-key.test.ts create mode 100644 packages/deployer/src/loaders/signing-key.ts create mode 100644 packages/deployer/src/providers/build.test.ts create mode 100644 packages/deployer/src/providers/build.ts create mode 100644 packages/deployer/src/providers/network.test.ts create mode 100644 packages/deployer/src/providers/network.ts create mode 100644 packages/deployer/src/providers/private-state-password.test.ts create mode 100644 packages/deployer/src/providers/private-state-password.ts create mode 100644 packages/deployer/src/providers/proof-server.test.ts create mode 100644 packages/deployer/src/providers/proof-server.ts create mode 100644 packages/deployer/src/runDeploy.test.ts create mode 100644 packages/deployer/src/runDeploy.ts create mode 100644 packages/deployer/src/wallet/handler.test.ts create mode 100644 packages/deployer/src/wallet/handler.ts create mode 100644 packages/deployer/src/wallet/keystore.test.ts create mode 100644 packages/deployer/src/wallet/keystore.ts create mode 100644 packages/deployer/src/wallet/seeds.test.ts create mode 100644 packages/deployer/src/wallet/seeds.ts create mode 100644 packages/deployer/tsconfig.json create mode 100644 packages/deployer/vitest.config.ts diff --git a/.gitignore b/.gitignore index 075ffa5..b1f6384 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,19 @@ *.local package-lock.json .pnp.* +**/.pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/sdks !.yarn/versions +# Nested yarn state (e.g. `examples//.yarn/cache`) shouldn't ship. +# Examples install standalone; their cache regenerates from yarn.lock. +**/.yarn/cache +**/.yarn/install-state.gz +**/.yarn/build-state.yml +**/.yarn/unplugged logs log @@ -22,10 +29,34 @@ result dist/ gen/ managed/ +# Compiler output. Regenerated by `compact-compiler` on every build, so it +# never gets committed — including under examples/, where the walkthrough +# expects you to compile the contract yourself before deploying. artifacts/ midnight-level-db compactc +# Deploy secrets — wallet seeds, signing keys, keystores. Match at any depth +# so nested deploy/ directories (e.g. under examples/) are covered too. +**/deploy/*.seed +**/deploy/*.signingkey +**/deploy/*.keystore.json + +# Deployment records — the JSON the deployer writes after a successful +# deploy. Includes the contract signing key, so treat as a secret. +deployments/ + +# compact-deployer wallet-state cache (per-seed, per-network shielded snapshots). +.states/ +**/.states/ + +# Third-party source pulled in for local experimentation (e.g. the +# midnight-node fork validation under vendor/midnight-node/ — see +# plans/tooling/compact-deploy-rust-fork.md). Never committed. +vendor/ +target/ +.toolkit-cache/ + coverage **/reports diff --git a/package.json b/package.json index 52160b2..bddb68d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "turbo run build --log-prefix=none", "test": "turbo run test --log-prefix=none", + "coverage": "turbo run coverage --log-prefix=none", "lint": "biome check .", "lint:fix": "biome check . --write", "lint:ci": "biome ci . --no-errors-on-unmatched", @@ -17,10 +18,20 @@ }, "devDependencies": { "@biomejs/biome": "2.4.16", + "@openzeppelin/compact-deployer": "workspace:^", "@types/node": "25.9.1", + "@vitest/coverage-v8": "4.1.8", + "pino": "^9.7.0", "ts-node": "^10.9.2", "turbo": "^2.9.14", "typescript": "^6.0.3", "vitest": "^4.1.6" + }, + "resolutions": { + "@midnight-ntwrk/ledger-v8": "8.0.3", + "@midnight-ntwrk/midnight-js-protocol": "4.1.0", + "undici": "^6.24.0", + "glob": "^11.0.0", + "uuid": "^13.0.0" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 0408b5d..c1b0486 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,13 @@ { "name": "@openzeppelin/compact-cli", - "description": "CLI for compiling and building Compact smart contracts", + "description": "CLI for compiling, building, and deploying Compact smart contracts", "version": "0.0.2", "keywords": [ "compact", "cli", "compiler", "builder", + "deployer", "testing" ], "author": "OpenZeppelin Community ", @@ -19,7 +20,8 @@ "type": "module", "exports": { "./run-builder": "./dist/runBuilder.js", - "./run-compiler": "./dist/runCompiler.js" + "./run-compiler": "./dist/runCompiler.js", + "./run-deploy": "./dist/runDeploy.js" }, "files": [ "dist", @@ -27,27 +29,34 @@ "LICENSE" ], "engines": { - "node": ">=20" + "node": ">=24" }, "bin": { "compact-builder": "dist/runBuilder.js", - "compact-compiler": "dist/runCompiler.js" + "compact-compiler": "dist/runCompiler.js", + "compact-deploy": "dist/runDeploy.js" }, "scripts": { "build": "tsc -p .", "types": "tsc -p tsconfig.json --noEmit", "test": "yarn vitest run", + "coverage": "yarn vitest run --coverage", "clean": "git clean -fXd" }, "devDependencies": { "@tsconfig/node24": "^24.0.3", "@types/node": "25.9.1", + "@types/ws": "^8.5.10", "typescript": "^6.0.3", "vitest": "^4.1.6" }, "dependencies": { "@openzeppelin/compact-builder": "workspace:^", + "@openzeppelin/compact-deployer": "workspace:^", "chalk": "^5.6.2", - "ora": "^9.0.0" + "ora": "^9.0.0", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", + "ws": "^8.16.0" } } diff --git a/packages/cli/src/logger.ts b/packages/cli/src/logger.ts new file mode 100644 index 0000000..4f6b267 --- /dev/null +++ b/packages/cli/src/logger.ts @@ -0,0 +1,65 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import pino, { type Logger } from 'pino'; + +/** + * Pino factory for the three CLI modes: `--json` (raw JSON, no transports), + * default (pretty `info+`), `--verbose` (pretty `info+` to stdout AND + * `debug+` mirrored to `.compact/logs/.log` so the transcript survives + * spinner overwrites). + */ +export interface CreateLoggerOptions { + verbose: boolean; + json: boolean; + logDir?: string; +} + +export function createLogger(opts: CreateLoggerOptions): Logger { + if (opts.json) { + return pino({ level: opts.verbose ? 'debug' : 'info' }); + } + + if (opts.verbose) { + const dir = opts.logDir ?? join(process.cwd(), '.compact', 'logs'); + mkdirSync(dir, { recursive: true }); + const file = join( + dir, + `${new Date().toISOString().replace(/[:.]/g, '-')}.log`, + ); + return pino( + { level: 'debug' }, + pino.transport({ + targets: [ + { + target: 'pino/file', + options: { destination: file }, + level: 'debug', + }, + { + target: 'pino-pretty', + options: { + destination: 1, + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }, + level: 'info', + }, + ], + }), + ); + } + + return pino( + { level: 'info' }, + pino.transport({ + target: 'pino-pretty', + options: { + destination: 1, + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }, + }), + ); +} diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts new file mode 100644 index 0000000..a1e1f3b --- /dev/null +++ b/packages/cli/src/prompt.ts @@ -0,0 +1,48 @@ +import { stdin, stdout } from 'node:process'; + +/** Prompt for a keystore passphrase with terminal echo suppressed; falls back to plain line-read off a TTY. */ +export async function promptPassphrase(label: string): Promise { + stdout.write(`Passphrase for ${label}: `); + return readMaskedLine(); +} + +function readMaskedLine(): Promise { + return new Promise((resolveFn, rejectFn) => { + let buffer = ''; + const isTTY = stdin.isTTY === true; + + const cleanup = () => { + if (isTTY) stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener('data', onData); + stdout.write('\n'); + }; + + const onData = (chunk: Buffer) => { + const s = chunk.toString('utf8'); + for (const ch of s) { + const code = ch.charCodeAt(0); + if (code === 0x03) { + cleanup(); + rejectFn(new Error('Aborted')); + return; + } + if (code === 0x0d || code === 0x0a) { + cleanup(); + resolveFn(buffer); + return; + } + if (code === 0x7f || code === 0x08) { + buffer = buffer.slice(0, -1); + continue; + } + buffer += ch; + } + }; + + if (isTTY) stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + stdin.on('data', onData); + }); +} diff --git a/packages/cli/src/runBuilder.ts b/packages/cli/src/runBuilder.ts index 3719973..a609265 100644 --- a/packages/cli/src/runBuilder.ts +++ b/packages/cli/src/runBuilder.ts @@ -4,30 +4,7 @@ import { CompactBuilder } from '@openzeppelin/compact-builder'; import chalk from 'chalk'; import ora from 'ora'; -/** - * Executes the Compact builder CLI. - * Builds projects using the `CompactBuilder` class with provided options, including compilation and additional steps. - * - * Compiler options (forwarded to `compact-compiler`): - * - `--dir ` - Compile specific subdirectory within srcDir - * - `--src ` - Source directory (default: src) - * - `--out ` - Output directory for artifacts (default: artifacts) - * - `--hierarchical` - Preserve source directory structure in BOTH the - * compiler artifacts output AND the builder's - * .compact copy into dist/ (default off: flat in both) - * - `--exclude ` - Skip .compact files matching pattern, in BOTH the - * compiler's file discovery AND the builder's - * .compact copy (repeatable). When unset, the builder - * falls back to ['Mock*', '*.mock.compact']; the - * compiler defaults to no excludes. - * - `+` - Use specific toolchain version - * - * Builder-only options (control dist/ layout): - * - `--clean-dist` - rm -rf dist before building (default off) - * - `--copy ` - copy an extra file into dist/ for distribution (repeatable; e.g. package.json) - * - * See `packages/cli/README.md` for usage examples. - */ +/** `compact-builder` CLI shell. See `packages/cli/README.md` for options. */ async function runBuilder(): Promise { const spinner = ora(chalk.blue('[BUILD] Compact Builder started')).info(); diff --git a/packages/cli/src/runCompiler.ts b/packages/cli/src/runCompiler.ts index 83e4622..6b766dc 100644 --- a/packages/cli/src/runCompiler.ts +++ b/packages/cli/src/runCompiler.ts @@ -8,41 +8,7 @@ import { import chalk from 'chalk'; import ora, { type Ora } from 'ora'; -/** - * Executes the Compact compiler CLI with improved error handling and user feedback. - * - * Error Handling Architecture: - * - * This CLI follows a layered error handling approach: - * - * - Business logic (Compiler.ts) throws structured errors with context. - * - CLI layer (runCompiler.ts) handles all user-facing error presentation. - * - Custom error types (types/errors.ts) provide semantic meaning and context. - * - * Benefits: Better testability, consistent UI, separation of concerns. - * - * Note: This compiler uses fail-fast error handling. - * Compilation stops on the first error encountered. - * This provides immediate feedback but doesn't attempt to compile remaining files after a failure. - * - * @example Individual module compilation - * ```bash - * npx compact-compiler --dir security --skip-zk - * turbo compact:access -- --skip-zk - * turbo compact:security -- --skip-zk --other-flag - * ``` - * - * @example Full compilation with environment variables - * ```bash - * SKIP_ZK=true turbo compact - * turbo compact - * ``` - * - * @example Version specification - * ```bash - * npx compact-compiler --dir security --skip-zk + - * ``` - */ +/** `compact-compiler` CLI shell — fail-fast, maps error types to user-facing messages via {@link handleError}. */ async function runCompiler(): Promise { const spinner = ora(chalk.blue('[COMPILE] Compact compiler started')).info(); @@ -56,21 +22,7 @@ async function runCompiler(): Promise { } } -/** - * Centralized error handling with specific error types and user-friendly messages. - * - * Handles different error types with appropriate user feedback: - * - * - `CompactCliNotFoundError`: Shows installation instructions. - * - `DirectoryNotFoundError`: Shows available directories. - * - `CompilationError`: Shows file-specific error details with context. - * - Environment validation errors: Shows troubleshooting tips. - * - Argument parsing errors: Shows usage help. - * - Generic errors: Shows general troubleshooting guidance. - * - * @param error - The error that occurred during compilation - * @param spinner - Ora spinner instance for consistent UI messaging - */ +/** Dispatch by error name → spinner output + actionable hint. */ function handleError(error: unknown, spinner: Ora): void { // CompactCliNotFoundError if (error instanceof Error && error.name === 'CompactCliNotFoundError') { @@ -154,9 +106,6 @@ function handleError(error: unknown, spinner: Ora): void { console.log(chalk.gray(' • File system permissions are correct')); } -/** - * Shows available directories when `DirectoryNotFoundError` occurs. - */ function showAvailableDirectories(): void { console.log(chalk.yellow('\nAvailable directories:')); console.log( @@ -168,9 +117,6 @@ function showAvailableDirectories(): void { console.log(chalk.yellow(' --dir utils # Compile utility contracts')); } -/** - * Shows usage help with examples for different scenarios. - */ function showUsageHelp(): void { console.log(chalk.yellow('\nUsage: compact-compiler [options]')); console.log(chalk.yellow('\nOptions:')); diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts new file mode 100644 index 0000000..c907fb4 --- /dev/null +++ b/packages/cli/src/runDeploy.ts @@ -0,0 +1,308 @@ +#!/usr/bin/env node +// biome-ignore-all lint/suspicious/noConsole: CLI writes user-facing diagnostics to stdout/stderr + +/** + * `compact-deploy` CLI shell over {@link Deployer}. The `globalThis.WebSocket` + * shim is required: midnight-js's indexer client uses the browser WebSocket + * interface, which Node only ships natively from v22. + */ +import { DeployError, Deployer } from '@openzeppelin/compact-deployer'; +import chalk from 'chalk'; +import ora from 'ora'; +import { WebSocket } from 'ws'; +import { createLogger } from './logger.ts'; +import { promptPassphrase } from './prompt.ts'; + +(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket; + +interface ParsedArgs { + contract?: string; + network?: string; + configPath?: string; + seedFile?: string; + proofServer?: string; + syncTimeoutSec?: number; + seedCacheFromDust?: string; + seedCacheFromShielded?: string; + noCache: boolean; + dryRun: boolean; + json: boolean; + verbose: boolean; + help: boolean; + version: boolean; + positional: string[]; +} + +function parseArgs(argv: string[]): ParsedArgs { + const out: ParsedArgs = { + noCache: false, + dryRun: false, + json: false, + verbose: false, + help: false, + version: false, + positional: [], + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case '-h': + case '--help': + out.help = true; + break; + case '--version': + out.version = true; + break; + case '-v': + case '--verbose': + out.verbose = true; + break; + case '--json': + out.json = true; + break; + case '--dry-run': + out.dryRun = true; + break; + case '--no-cache': + out.noCache = true; + break; + case '--seed-cache-from-dust': + out.seedCacheFromDust = expectValue( + argv, + ++i, + '--seed-cache-from-dust', + ); + break; + case '--seed-cache-from-shielded': + out.seedCacheFromShielded = expectValue( + argv, + ++i, + '--seed-cache-from-shielded', + ); + break; + case '--network': + out.network = expectValue(argv, ++i, '--network'); + break; + case '--config': + out.configPath = expectValue(argv, ++i, '--config'); + break; + case '--seed-file': + out.seedFile = expectValue(argv, ++i, '--seed-file'); + break; + case '--proof-server': + out.proofServer = expectValue(argv, ++i, '--proof-server'); + break; + case '--sync-timeout': { + const raw = expectValue(argv, ++i, '--sync-timeout'); + const seconds = Number.parseInt(raw, 10); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error( + `--sync-timeout requires a positive integer (seconds); got "${raw}"`, + ); + } + out.syncTimeoutSec = seconds; + break; + } + default: + if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); + out.positional.push(arg); + } + } + out.contract = out.positional[0]; + return out; +} + +function expectValue(argv: string[], i: number, flag: string): string { + const v = argv[i]; + if (v === undefined || v.startsWith('-')) { + throw new Error(`${flag} requires a value`); + } + return v; +} + +async function main(): Promise { + let args: ParsedArgs; + try { + args = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(chalk.red(`[DEPLOY] ${(e as Error).message}`)); + showUsage(); + process.exit(2); + return; + } + + if (args.help) { + showUsage(); + return; + } + if (args.version) { + console.log(packageVersion()); + return; + } + + if (!args.contract) { + console.error( + chalk.red('[DEPLOY] Missing required positional argument.'), + ); + showUsage(); + process.exit(2); + return; + } + + const logger = createLogger({ verbose: args.verbose, json: args.json }); + // Spinner narrates two phases: prepare() (proof-server start, wallet + // build, sync to tip — can take minutes on first preprod/preview run) + // then deploy() / dryRun() (proof generation + tx submit). Text is + // updated between phases so the spinner matches the actual stage. + const verbActive = args.dryRun ? 'Dry-running' : 'Deploying'; + const spinner = args.json + ? undefined + : ora( + chalk.blue( + `[DEPLOY] Preparing wallet for ${args.contract} (sync may take minutes)…`, + ), + ).start(); + + try { + await using deployer = await Deployer.prepare({ + contract: args.contract, + network: args.network, + configPath: args.configPath, + seedFile: args.seedFile, + proofServer: args.proofServer, + syncTimeoutMs: + args.syncTimeoutSec !== undefined + ? args.syncTimeoutSec * 1000 + : undefined, + skipWalletCache: args.noCache, + seedCacheDust: args.seedCacheFromDust, + seedCacheShielded: args.seedCacheFromShielded, + logger, + promptPassphrase: async (path) => { + if (spinner) spinner.stop(); + const pp = await promptPassphrase(path); + if (spinner) spinner.start(); + return pp; + }, + }); + // Wallet is ready, providers are up — now we're actually deploying. + if (spinner) { + spinner.text = chalk.blue( + `[DEPLOY] ${verbActive} ${args.contract} (proof gen + submit)…`, + ); + } + const result = args.dryRun + ? await deployer.dryRun() + : await deployer.deploy(); + + if (args.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (result.dryRun) { + spinner?.succeed( + chalk.green( + `[DEPLOY] Dry-run for ${result.contractName} on ${result.network} OK`, + ), + ); + return; + } + spinner?.succeed( + chalk.green( + `[DEPLOY] ${result.contractName} deployed on ${result.network}: ${result.address}`, + ), + ); + console.log(chalk.gray(` txId: ${result.txId}`)); + console.log(chalk.gray(` txHash: ${result.txHash}`)); + console.log(chalk.gray(` blockHeight: ${result.blockHeight}`)); + console.log(chalk.gray(` saved to: ${result.deploymentsFile}`)); + if (result.explorerUrl) { + console.log(chalk.gray(` explorer: ${result.explorerUrl}`)); + } + } catch (e) { + const code = e instanceof DeployError ? e.exitCode : 1; + const name = e instanceof Error ? e.name : 'Error'; + const message = e instanceof Error ? e.message : String(e); + if (args.json) { + process.stdout.write( + `${JSON.stringify({ error: name, message, exitCode: code })}\n`, + ); + } else { + spinner?.fail(chalk.red(`[DEPLOY] ${name}: ${message}`)); + if (args.verbose && e instanceof Error && e.stack) { + console.error(chalk.gray(e.stack)); + } + } + process.exit(code); + } +} + +function showUsage(): void { + console.log(chalk.yellow('\nUsage: compact-deploy [options]')); + console.log(chalk.yellow('\nOptions:')); + console.log( + chalk.yellow( + ' --network Target network (or set [profile].default_network)', + ), + ); + console.log( + chalk.yellow( + ' --config Path to compact.toml (default: walk up from CWD)', + ), + ); + console.log( + chalk.yellow( + ' --seed-file Seed override (raw hex or BIP39 mnemonic, one line)', + ), + ); + console.log( + chalk.yellow(' --proof-server Override [networks.X].proof_server'), + ); + console.log( + chalk.yellow( + ' --sync-timeout Max wallet-sync seconds before failing (default 600)', + ), + ); + console.log( + chalk.yellow( + ' --no-cache Ignore the on-disk wallet-state cache; force fresh sync', + ), + ); + console.log( + chalk.yellow( + ' --seed-cache-from-dust Import a pre-warmed dust state file into .states/', + ), + ); + console.log( + chalk.yellow( + ' --seed-cache-from-shielded Import a pre-warmed shielded state file into .states/', + ), + ); + console.log( + chalk.yellow(' --dry-run Load+validate, do NOT submit a tx'), + ); + console.log( + chalk.yellow(' --json Single JSON object on stdout'), + ); + console.log( + chalk.yellow(' -v, --verbose Pino debug logs to .compact/logs/'), + ); + console.log(chalk.yellow(' -h, --help Show this help')); + console.log(chalk.yellow(' --version Print package version')); + console.log(chalk.yellow('\nExamples:')); + console.log(chalk.yellow(' compact-deploy Token --network local')); + console.log( + chalk.yellow( + ' MN_DEPLOYER_SEED=$(cat seed.hex) compact-deploy Vault --network testnet', + ), + ); + console.log( + chalk.yellow(' compact-deploy Token --network preprod --dry-run --json'), + ); +} + +function packageVersion(): string { + return process.env.npm_package_version ?? 'dev'; +} + +main(); diff --git a/packages/cli/test/logger.test.ts b/packages/cli/test/logger.test.ts new file mode 100644 index 0000000..0420f38 --- /dev/null +++ b/packages/cli/test/logger.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockMkdirSync, mockPino, mockTransport, fakeLogger } = vi.hoisted( + () => { + const fakeLogger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }; + return { + mockMkdirSync: vi.fn(), + mockPino: vi.fn(() => fakeLogger), + mockTransport: vi.fn((cfg: unknown) => ({ __transport: cfg })), + fakeLogger, + }; + }, +); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, mkdirSync: mockMkdirSync }; +}); + +vi.mock('pino', () => { + const pinoFn = (...args: unknown[]) => mockPino(...(args as [])) as unknown; + (pinoFn as unknown as { transport: typeof mockTransport }).transport = + mockTransport; + return { default: pinoFn }; +}); + +import { createLogger } from '../src/logger.ts'; + +describe('createLogger', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('json mode', () => { + it('should return logger at info level when verbose false', () => { + const logger = createLogger({ verbose: false, json: true }); + + expect(mockPino).toHaveBeenCalledTimes(1); + expect(mockPino).toHaveBeenCalledWith({ level: 'info' }); + expect(mockTransport).not.toHaveBeenCalled(); + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(logger).toBe(fakeLogger); + }); + + it('should return logger at debug level when verbose true', () => { + const logger = createLogger({ verbose: true, json: true }); + + expect(mockPino).toHaveBeenCalledWith({ level: 'debug' }); + expect(mockTransport).not.toHaveBeenCalled(); + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(logger).toBe(fakeLogger); + }); + }); + + describe('pretty mode (default, non-json)', () => { + it('should configure single pino-pretty transport when not verbose', () => { + const logger = createLogger({ verbose: false, json: false }); + + expect(mockPino).toHaveBeenCalledTimes(1); + const [opts, transport] = mockPino.mock.calls[0] as [ + { level: string }, + unknown, + ]; + expect(opts).toEqual({ level: 'info' }); + expect(transport).toEqual({ + __transport: { + target: 'pino-pretty', + options: { + destination: 1, + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }, + }, + }); + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(logger).toBe(fakeLogger); + }); + }); + + describe('verbose pretty mode', () => { + it('should mkdir the default log dir and configure two transports', () => { + const logger = createLogger({ verbose: true, json: false }); + + expect(mockMkdirSync).toHaveBeenCalledTimes(1); + const [dirArg, mkdirOpts] = mockMkdirSync.mock.calls[0] as [ + string, + { recursive: boolean }, + ]; + expect(dirArg).toContain('.compact'); + expect(dirArg).toContain('logs'); + expect(mkdirOpts).toEqual({ recursive: true }); + + expect(mockPino).toHaveBeenCalledTimes(1); + const [opts, transport] = mockPino.mock.calls[0] as [ + { level: string }, + { __transport: { targets: Record[] } }, + ]; + expect(opts).toEqual({ level: 'debug' }); + expect(transport.__transport.targets).toHaveLength(2); + expect(transport.__transport.targets[0]).toMatchObject({ + target: 'pino/file', + level: 'debug', + }); + expect( + ( + transport.__transport.targets[0] as { + options: { destination: string }; + } + ).options.destination, + ).toMatch(/\.log$/); + expect(transport.__transport.targets[1]).toMatchObject({ + target: 'pino-pretty', + level: 'info', + options: { + destination: 1, + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname', + }, + }); + expect(logger).toBe(fakeLogger); + }); + + it('should honour a custom logDir override', () => { + createLogger({ verbose: true, json: false, logDir: '/tmp/custom-logs' }); + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/custom-logs', { + recursive: true, + }); + const transport = ( + mockPino.mock.calls[0] as [ + unknown, + { + __transport: { + targets: Array<{ options: { destination: string } }>; + }; + }, + ] + )[1]; + expect(transport.__transport.targets[0]?.options.destination).toContain( + '/tmp/custom-logs/', + ); + }); + }); + + describe('return value shape', () => { + it('should expose the pino logger contract for every mode combination', () => { + const matrix: Array<{ verbose: boolean; json: boolean }> = [ + { verbose: false, json: false }, + { verbose: true, json: false }, + { verbose: false, json: true }, + { verbose: true, json: true }, + ]; + for (const opts of matrix) { + const logger = createLogger({ + ...opts, + logDir: '/tmp/logger-shape-test', + }); + expect(typeof logger.trace).toBe('function'); + expect(typeof logger.debug).toBe('function'); + expect(typeof logger.info).toBe('function'); + expect(typeof logger.warn).toBe('function'); + expect(typeof logger.error).toBe('function'); + expect(typeof logger.fatal).toBe('function'); + expect(typeof logger.child).toBe('function'); + } + }); + }); +}); diff --git a/packages/cli/test/prompt.test.ts b/packages/cli/test/prompt.test.ts new file mode 100644 index 0000000..e3eb0e8 --- /dev/null +++ b/packages/cli/test/prompt.test.ts @@ -0,0 +1,150 @@ +import type { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockStdin, mockStdout } = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events'); + type FakeStdin = InstanceType & { + isTTY: boolean; + setRawMode: ReturnType; + pause: ReturnType; + resume: ReturnType; + setEncoding: ReturnType; + removeListener: (event: string, fn: (...args: unknown[]) => void) => void; + }; + const stdin = new EventEmitter() as FakeStdin; + stdin.isTTY = true; + stdin.setRawMode = vi.fn(); + stdin.pause = vi.fn(); + stdin.resume = vi.fn(); + stdin.setEncoding = vi.fn(); + stdin.removeListener = stdin.removeListener.bind( + stdin, + ) as FakeStdin['removeListener']; + + const stdout = { write: vi.fn() }; + return { mockStdin: stdin, mockStdout: stdout }; +}); + +vi.mock('node:process', () => ({ + stdin: mockStdin, + stdout: mockStdout, +})); + +import { promptPassphrase } from '../src/prompt.ts'; + +function resetStdin(opts: { tty: boolean } = { tty: true }): void { + mockStdin.removeAllListeners(); + (mockStdin as { isTTY: boolean }).isTTY = opts.tty; + (mockStdin.setRawMode as ReturnType).mockClear(); + (mockStdin.pause as ReturnType).mockClear(); + (mockStdin.resume as ReturnType).mockClear(); + (mockStdin.setEncoding as ReturnType).mockClear(); + mockStdout.write.mockClear(); +} + +describe('promptPassphrase', () => { + beforeEach(() => { + resetStdin({ tty: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('prompt label and stream setup', () => { + it('should write the label and switch stdin into raw + utf8 mode on a TTY', async () => { + const promise = promptPassphrase('Alice keystore'); + mockStdin.emit('data', Buffer.from('x\n')); + await promise; + + expect(mockStdout.write).toHaveBeenCalledWith( + 'Passphrase for Alice keystore: ', + ); + expect(mockStdin.setRawMode).toHaveBeenCalledWith(true); + expect(mockStdin.resume).toHaveBeenCalled(); + expect(mockStdin.setEncoding).toHaveBeenCalledWith('utf8'); + }); + + it('should NOT call setRawMode when stdin is not a TTY', async () => { + resetStdin({ tty: false }); + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('\n')); + await promise; + + expect(mockStdin.setRawMode).not.toHaveBeenCalled(); + expect(mockStdin.resume).toHaveBeenCalled(); + }); + }); + + describe('successful read paths', () => { + it('should resolve with the typed characters on CR (0x0d)', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('hunter2')); + mockStdin.emit('data', Buffer.from([0x0d])); + const pp = await promise; + + expect(pp).toBe('hunter2'); + expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false); + expect(mockStdin.pause).toHaveBeenCalled(); + expect(mockStdout.write).toHaveBeenLastCalledWith('\n'); + }); + + it('should resolve on LF (0x0a)', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('p4ss\n')); + const pp = await promise; + expect(pp).toBe('p4ss'); + }); + + it('should return an empty string when user presses Enter immediately', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('\n')); + const pp = await promise; + expect(pp).toBe(''); + }); + + it('should handle DEL (0x7f) as backspace', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('abc')); + mockStdin.emit('data', Buffer.from([0x7f])); + mockStdin.emit('data', Buffer.from('d\n')); + const pp = await promise; + expect(pp).toBe('abd'); + }); + + it('should handle BS (0x08) as backspace', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('xyz')); + mockStdin.emit('data', Buffer.from([0x08, 0x08])); + mockStdin.emit('data', Buffer.from('a\n')); + const pp = await promise; + expect(pp).toBe('xa'); + }); + + it('should drop a trailing backspace that empties the buffer', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from([0x7f])); + mockStdin.emit('data', Buffer.from('q\n')); + const pp = await promise; + expect(pp).toBe('q'); + }); + }); + + describe('abort path', () => { + it('should reject with "Aborted" on Ctrl+C (0x03)', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('partial')); + mockStdin.emit('data', Buffer.from([0x03])); + await expect(promise).rejects.toThrow('Aborted'); + expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false); + expect(mockStdin.pause).toHaveBeenCalled(); + }); + + it('should ignore characters after Ctrl+C within the same chunk', async () => { + const promise = promptPassphrase('label'); + // 0x03 short-circuits the loop; "abc\n" after it must not resolve. + mockStdin.emit('data', Buffer.from([0x03, 0x61, 0x62, 0x63, 0x0a])); + await expect(promise).rejects.toThrow('Aborted'); + }); + }); +}); diff --git a/packages/cli/test/runBuilder.test.ts b/packages/cli/test/runBuilder.test.ts new file mode 100644 index 0000000..06d47e5 --- /dev/null +++ b/packages/cli/test/runBuilder.test.ts @@ -0,0 +1,116 @@ +import { CompactBuilder } from '@openzeppelin/compact-builder'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the library so we can drive the CLI in isolation. +vi.mock('@openzeppelin/compact-builder', async () => { + const actual = await vi.importActual< + typeof import('@openzeppelin/compact-builder') + >('@openzeppelin/compact-builder'); + return { + ...actual, + CompactBuilder: { + fromArgs: vi.fn(), + }, + }; +}); + +// Mock chalk to a passthrough. +vi.mock('chalk', () => ({ + default: { + blue: (text: string) => text, + red: (text: string, extra?: string) => + extra === undefined ? text : `${text} ${extra}`, + }, +})); + +// Mock ora. +const mockSpinner = { + info: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), +}; +vi.mock('ora', () => ({ + default: vi.fn(() => mockSpinner), +})); + +const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + +describe('runBuilder CLI', () => { + let mockBuild: ReturnType; + let mockFromArgs: ReturnType; + let originalArgv: string[]; + + beforeEach(() => { + originalArgv = [...process.argv]; + + vi.clearAllMocks(); + vi.resetModules(); + + mockBuild = vi.fn(); + mockFromArgs = vi.mocked(CompactBuilder.fromArgs); + mockFromArgs.mockReturnValue({ build: mockBuild } as any); + + mockSpinner.info.mockClear(); + mockSpinner.fail.mockClear(); + mockSpinner.succeed.mockClear(); + mockExit.mockClear(); + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + describe('successful build', () => { + it('should build with no arguments', async () => { + process.argv = ['node', 'runBuilder.js']; + mockBuild.mockResolvedValue(undefined); + + await import('../src/runBuilder.ts'); + + expect(mockSpinner.info).toHaveBeenCalled(); + expect(mockFromArgs).toHaveBeenCalledWith([]); + expect(mockBuild).toHaveBeenCalledTimes(1); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should pass argv through to fromArgs', async () => { + const args = ['--watch', '--dir', 'token']; + process.argv = ['node', 'runBuilder.js', ...args]; + mockBuild.mockResolvedValue(undefined); + + await import('../src/runBuilder.ts'); + + expect(mockFromArgs).toHaveBeenCalledWith(args); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should fail the spinner and exit 1 on build failure', async () => { + const error = new Error('Build broke'); + mockBuild.mockRejectedValue(error); + + await import('../src/runBuilder.ts'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[BUILD] Unexpected error: Build broke', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should exit 1 on argument parsing failure', async () => { + mockFromArgs.mockImplementation(() => { + throw new Error('bad flag'); + }); + + await import('../src/runBuilder.ts'); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + '[BUILD] Unexpected error: bad flag', + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/packages/cli/test/runDeploy.test.ts b/packages/cli/test/runDeploy.test.ts new file mode 100644 index 0000000..85e5ef5 --- /dev/null +++ b/packages/cli/test/runDeploy.test.ts @@ -0,0 +1,531 @@ +import { + ArtifactNotFoundError, + DeployError, + Deployer, +} from '@openzeppelin/compact-deployer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks -------------------------------------------------------------- + +vi.mock('@openzeppelin/compact-deployer', async () => { + const actual = await vi.importActual< + typeof import('@openzeppelin/compact-deployer') + >('@openzeppelin/compact-deployer'); + return { + ...actual, + Deployer: { + prepare: vi.fn(), + }, + }; +}); + +vi.mock('chalk', () => ({ + default: { + blue: (text: string) => text, + red: (text: string) => text, + green: (text: string) => text, + gray: (text: string) => text, + yellow: (text: string) => text, + }, +})); + +const mockSpinner = { + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + text: '', +}; +const mockOra = vi.fn(() => mockSpinner); +vi.mock('ora', () => ({ + default: mockOra, +})); + +vi.mock('ws', () => ({ + WebSocket: class FakeWebSocket {}, +})); + +vi.mock('../src/logger.ts', () => ({ + createLogger: vi.fn(() => ({ + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + })), +})); + +const mockPromptPassphrase = vi.fn(async () => 'secret'); +vi.mock('../src/prompt.ts', () => ({ + promptPassphrase: mockPromptPassphrase, +})); + +// --- Process helpers ---------------------------------------------------- + +const mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); +const mockStdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); +const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); +const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + +// Fixture builder for the Deployer instance returned by prepare(). +interface FakeDeployerOpts { + result?: Record; + deployError?: unknown; + dryRunResult?: Record; + dryRunError?: unknown; +} +function fakeDeployer(opts: FakeDeployerOpts = {}) { + const deploy = vi.fn(async () => { + if (opts.deployError) throw opts.deployError; + return opts.result ?? defaultResult(); + }); + const dryRun = vi.fn(async () => { + if (opts.dryRunError) throw opts.dryRunError; + return opts.dryRunResult ?? defaultDryRunResult(); + }); + const dispose = vi.fn(async () => {}); + return { + deploy, + dryRun, + [Symbol.asyncDispose]: dispose, + }; +} + +function defaultResult(overrides: Record = {}) { + return { + contractName: 'Token', + network: 'local', + address: '0xabc', + txHash: '0xhash', + txId: 'tx-1', + blockHeight: 42, + deploymentsFile: '/tmp/deployments.json', + dryRun: false, + explorerUrl: '', + ...overrides, + }; +} + +function defaultDryRunResult(overrides: Record = {}) { + return { + contractName: 'Token', + network: 'local', + address: '', + txHash: '', + txId: '', + blockHeight: 0, + deploymentsFile: '', + dryRun: true, + explorerUrl: '', + ...overrides, + }; +} + +// --- parseArgs probe ---------------------------------------------------- +// +// parseArgs is module-private. We exercise it indirectly via main() by +// running with argv variants and asserting on either Deployer.prepare's +// args object (happy path) or on console.error + exit code 2 (parse-error +// path). + +async function runMain(argv: string[]): Promise { + process.argv = ['node', 'runDeploy.js', ...argv]; + vi.resetModules(); + await import('../src/runDeploy.ts'); + // main() is invoked at module top-level but is async. Await a microtask + // tick so its body finishes before assertions. + await new Promise((resolve) => setImmediate(resolve)); +} + +// --- Tests -------------------------------------------------------------- + +describe('runDeploy CLI', () => { + let originalArgv: string[]; + let mockPrepare: ReturnType; + + beforeEach(() => { + originalArgv = [...process.argv]; + vi.clearAllMocks(); + mockPrepare = vi.mocked(Deployer.prepare); + mockSpinner.start.mockClear(); + mockSpinner.stop.mockClear(); + mockSpinner.succeed.mockClear(); + mockSpinner.fail.mockClear(); + mockSpinner.text = ''; + }); + + afterEach(() => { + process.argv = originalArgv; + }); + + // ------------------------------------------------------------------ // + describe('--help / --version short-circuits', () => { + it('should print usage and return on --help', async () => { + await runMain(['--help']); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Usage: compact-deploy'), + ); + expect(mockExit).not.toHaveBeenCalled(); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it('should accept -h shorthand', async () => { + await runMain(['-h']); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('Usage: compact-deploy'), + ); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it('should print the package version on --version', async () => { + const prev = process.env.npm_package_version; + process.env.npm_package_version = '9.9.9'; + try { + await runMain(['--version']); + expect(mockConsoleLog).toHaveBeenCalledWith('9.9.9'); + } finally { + if (prev === undefined) delete process.env.npm_package_version; + else process.env.npm_package_version = prev; + } + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it('should fall back to "dev" when npm_package_version is unset', async () => { + const prev = process.env.npm_package_version; + delete process.env.npm_package_version; + try { + await runMain(['--version']); + expect(mockConsoleLog).toHaveBeenCalledWith('dev'); + } finally { + if (prev !== undefined) process.env.npm_package_version = prev; + } + }); + }); + + // ------------------------------------------------------------------ // + describe('parseArgs (via main)', () => { + beforeEach(() => { + mockPrepare.mockResolvedValue(fakeDeployer()); + }); + + it('should map every flag to the prepare() options', async () => { + await runMain([ + 'Token', + '--network', + 'local', + '--config', + '/c.toml', + '--seed-file', + '/seed.hex', + '--proof-server', + 'http://proof:6300', + '--sync-timeout', + '30', + '--no-cache', + '--seed-cache-from-dust', + '/dust.json', + '--seed-cache-from-shielded', + '/shielded.gz', + ]); + + expect(mockPrepare).toHaveBeenCalledTimes(1); + const opts = mockPrepare.mock.calls[0]?.[0] as Record; + expect(opts.contract).toBe('Token'); + expect(opts.network).toBe('local'); + expect(opts.configPath).toBe('/c.toml'); + expect(opts.seedFile).toBe('/seed.hex'); + expect(opts.proofServer).toBe('http://proof:6300'); + expect(opts.syncTimeoutMs).toBe(30_000); + expect(opts.skipWalletCache).toBe(true); + expect(opts.seedCacheDust).toBe('/dust.json'); + expect(opts.seedCacheShielded).toBe('/shielded.gz'); + }); + + it('should reject --seed-cache-from-dust with no follow-up value', async () => { + await runMain(['Token', '--seed-cache-from-dust']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--seed-cache-from-dust requires a value'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should leave syncTimeoutMs undefined when --sync-timeout is omitted', async () => { + await runMain(['Token']); + const opts = mockPrepare.mock.calls[0]?.[0] as Record; + expect(opts.syncTimeoutMs).toBeUndefined(); + }); + + it('should reject unknown flags with exit code 2', async () => { + await runMain(['Token', '--bogus']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Unknown flag: --bogus'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + + it('should reject --network with no follow-up value', async () => { + await runMain(['Token', '--network']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--network requires a value'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should reject --network when followed by another flag', async () => { + await runMain(['Token', '--network', '--json']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--network requires a value'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should reject non-numeric --sync-timeout', async () => { + await runMain(['Token', '--sync-timeout', 'abc']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--sync-timeout requires a positive integer'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should reject zero/negative --sync-timeout', async () => { + await runMain(['Token', '--sync-timeout', '0']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('--sync-timeout requires a positive integer'), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should accept -v as a shorthand for --verbose', async () => { + await runMain(['Token', '-v']); + expect(mockPrepare).toHaveBeenCalled(); + }); + + it('should accept --dry-run and call deployer.dryRun()', async () => { + const fake = fakeDeployer(); + mockPrepare.mockResolvedValue(fake); + + await runMain(['Token', '--dry-run']); + + expect(fake.dryRun).toHaveBeenCalledTimes(1); + expect(fake.deploy).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------ // + describe('missing contract positional', () => { + it('should exit 2 with a missing-contract message', async () => { + await runMain([]); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Missing required '), + ); + expect(mockExit).toHaveBeenCalledWith(2); + expect(mockPrepare).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------ // + describe('successful deploy (text output)', () => { + it('should succeed-spin and log the four metadata lines', async () => { + mockPrepare.mockResolvedValue(fakeDeployer()); + await runMain(['Token', '--network', 'local']); + + expect(mockSpinner.succeed).toHaveBeenCalledWith( + expect.stringContaining('Token deployed on local: 0xabc'), + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('txId:'), + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('txHash:'), + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('blockHeight:'), + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('saved to:'), + ); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should also print the explorer line when explorerUrl is set', async () => { + mockPrepare.mockResolvedValue( + fakeDeployer({ + result: defaultResult({ explorerUrl: 'https://explorer/0xabc' }), + }), + ); + await runMain(['Token']); + + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining('explorer: https://explorer/0xabc'), + ); + }); + }); + + // ------------------------------------------------------------------ // + describe('successful deploy (--json)', () => { + it('should write the result as one JSON line and skip the spinner', async () => { + mockPrepare.mockResolvedValue(fakeDeployer()); + await runMain(['Token', '--json']); + + expect(mockOra).not.toHaveBeenCalled(); + expect(mockStdoutWrite).toHaveBeenCalledWith( + expect.stringMatching(/^\{.*"contractName":"Token".*\}\n$/s), + ); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------ // + describe('dry-run path', () => { + it('should succeed-spin with the dry-run message', async () => { + mockPrepare.mockResolvedValue(fakeDeployer()); + await runMain(['Token', '--dry-run']); + + expect(mockSpinner.succeed).toHaveBeenCalledWith( + expect.stringContaining('Dry-run for Token on local OK'), + ); + // We should NOT see deploy-only metadata lines. + expect(mockConsoleLog).not.toHaveBeenCalledWith( + expect.stringContaining('txId:'), + ); + }); + }); + + // ------------------------------------------------------------------ // + describe('passphrase prompt wiring', () => { + it('should stop the spinner before the prompt and restart it after', async () => { + let captured: ((path: string) => Promise) | undefined; + mockPrepare.mockImplementation(async (opts: any) => { + captured = opts.promptPassphrase; + return fakeDeployer(); + }); + + await runMain(['Token']); + expect(captured).toBeDefined(); + + mockSpinner.stop.mockClear(); + mockSpinner.start.mockClear(); + const pp = await captured?.('/some/path'); + expect(pp).toBe('secret'); + expect(mockSpinner.stop).toHaveBeenCalledTimes(1); + expect(mockSpinner.start).toHaveBeenCalledTimes(1); + // Ordering: stop must come before start. + const stopOrder = (mockSpinner.stop.mock.invocationCallOrder[0] ?? + Number.POSITIVE_INFINITY) as number; + const startOrder = (mockSpinner.start.mock.invocationCallOrder[0] ?? + Number.NEGATIVE_INFINITY) as number; + expect(stopOrder).toBeLessThan(startOrder); + expect(mockPromptPassphrase).toHaveBeenCalledWith('/some/path'); + }); + + it('should NOT touch the spinner when running in --json mode', async () => { + let captured: ((path: string) => Promise) | undefined; + mockPrepare.mockImplementation(async (opts: any) => { + captured = opts.promptPassphrase; + return fakeDeployer(); + }); + + await runMain(['Token', '--json']); + mockSpinner.stop.mockClear(); + mockSpinner.start.mockClear(); + await captured?.('/p'); + expect(mockSpinner.stop).not.toHaveBeenCalled(); + expect(mockSpinner.start).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------ // + describe('error handling', () => { + it('should exit with DeployError.exitCode and log via spinner.fail', async () => { + mockPrepare.mockRejectedValue( + new ArtifactNotFoundError('artifact missing'), + ); + await runMain(['Token']); + + expect(mockSpinner.fail).toHaveBeenCalledWith( + expect.stringContaining('artifact missing'), + ); + // ArtifactNotFoundError has exitCode 4 (per errors.ts) but we just + // assert the exit happened; we re-check the value once below. + expect(mockExit).toHaveBeenCalledTimes(1); + const callArg = (mockExit.mock.calls[0] as [number])[0]; + expect(typeof callArg).toBe('number'); + }); + + it('should use exitCode 1 for non-DeployError exceptions', async () => { + mockPrepare.mockRejectedValue(new Error('boom')); + await runMain(['Token']); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockSpinner.fail).toHaveBeenCalledWith( + expect.stringContaining('Error: boom'), + ); + }); + + it('should use exitCode 1 for string throws', async () => { + mockPrepare.mockRejectedValue('weird'); + await runMain(['Token']); + expect(mockExit).toHaveBeenCalledWith(1); + expect(mockSpinner.fail).toHaveBeenCalledWith( + expect.stringContaining('weird'), + ); + }); + + it('should use the DeployError.exitCode verbatim', async () => { + class CustomError extends DeployError { + constructor() { + super('custom failure', 42); + this.name = 'CustomError'; + } + } + mockPrepare.mockRejectedValue(new CustomError()); + await runMain(['Token']); + expect(mockExit).toHaveBeenCalledWith(42); + }); + + it('should print the stack trace under --verbose', async () => { + const err = new Error('boom'); + err.stack = 'Error: boom\n at fake.ts:1:1'; + mockPrepare.mockRejectedValue(err); + + await runMain(['Token', '--verbose']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('at fake.ts:1:1'), + ); + }); + + it('should NOT print the stack trace without --verbose', async () => { + const err = new Error('boom'); + err.stack = 'Error: boom\n at fake.ts:1:1'; + mockPrepare.mockRejectedValue(err); + + await runMain(['Token']); + const wroteStack = mockConsoleError.mock.calls.some((c) => + String(c[0] ?? '').includes('at fake.ts:1:1'), + ); + expect(wroteStack).toBe(false); + }); + + it('should emit JSON error line in --json mode', async () => { + mockPrepare.mockRejectedValue(new DeployError('json-mode bad', 7)); + await runMain(['Token', '--json']); + + expect(mockStdoutWrite).toHaveBeenCalledWith( + expect.stringMatching( + /^\{"error":"DeployError","message":"json-mode bad","exitCode":7\}\n$/, + ), + ); + expect(mockExit).toHaveBeenCalledWith(7); + // No spinner in json mode. + expect(mockSpinner.fail).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index d57e53a..1d445df 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -6,5 +6,16 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.ts'], reporters: 'verbose', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json-summary'], + include: ['src/**/*.ts'], + thresholds: { + statements: 95, + branches: 90, + functions: 100, + lines: 95, + }, + }, }, }); diff --git a/packages/deployer/README.md b/packages/deployer/README.md new file mode 100644 index 0000000..21b682e --- /dev/null +++ b/packages/deployer/README.md @@ -0,0 +1,169 @@ +# @openzeppelin/compact-deployer + +```bash +compact-deploy Token --network local +``` + +> **Status: developer-preview, testnet only.** Verified on local devnet + preview. Preprod blocked ([Known issues](#known-issues-may-2026)). Mainnet unsupported: unaudited, no hardware signer, no multisig, no tx retry, no upgrade tooling. See [Roadmap](#roadmap--todo). + +## Quick start + +1. Compile your contract with `compact-compiler` so artifacts land under `src/artifacts//`. +2. Drop a `compact.toml` at your repo root (see [Sample config](#sample-config)). +3. Generate a signing key per contract: `head -c 32 /dev/urandom | xxd -p -c 32 > deploy/Token.signingkey`. +4. Run: + ```bash + compact-deploy Token --network local + ``` + +The deploy result lands in `deployments/compact/.json`. + +## CLI + +``` +compact-deploy + --network required unless [profile].default_network is set + --config default: walk up from CWD for compact.toml + --seed-file seed override (raw hex or BIP39 mnemonic, one line) + --proof-server override [networks.X].proof_server + --sync-timeout max wait for wallet to reach chain tip (default 600) + --no-cache ignore on-disk wallet-state cache; force fresh sync + --seed-cache-from-dust import a pre-warmed dust state file into .states/ + --seed-cache-from-shielded import a pre-warmed shielded state file into .states/ + --dry-run load, validate, build providers, log plan, DO NOT submit + --json single JSON object on stdout (machine-readable) + -v, --verbose pino debug logs to .compact/logs/.log + -h, --help --version +``` + +Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unreachable · `5` deploy tx failed · `1` unexpected. + +## Deploying to real networks (preprod, preview, testnet) + +> Preprod is blocked on an upstream wallet-SDK bug. Use `--network preview`. See [Known issues](#known-issues-may-2026). + +- **First sync is slow** (~3 min on preview, 30–60 min on preprod from genesis). Cache makes reruns near-instant. +- **Bump sync timeout**: `--sync-timeout 3600` (default 10 min). +- **Bump Node heap** for long-history chains: `NODE_OPTIONS="--max-old-space-size=8192"`. +- **Seed source**: `--seed-file`, `MN_DEPLOYER_SEED`, or `[wallet].keystore`. The `wallet = { source = "local" }` shorthand is dev-preset only. + +## Wallet cache + +After each successful sync the deployer writes `/.states/--.gz` (one file per shielded / dust sub-wallet). Next run restores from it instead of re-syncing from genesis. + +- Contents: gzipped sub-wallet state (UTXOs, checkpoint). No private keys (re-derived from seed each run). +- Keyed by SHA-256(seed) + network ID, so `local` vs `preprod` keep separate caches. +- Bust it: `--no-cache` (force fresh) or `rm -rf .states/`. Auto-falls-back on corrupt or version-mismatched files. +- Best-effort writes; never block a deploy. Concurrent runs against the same seed race. Don't. +- `.states/` is gitignored. + +### Importing a pre-warmed state file + +If cold sync OOMs on preprod (the known upstream bug) and you already have a `wallet.serializeState()` snapshot from a prior session, drop it in with: + +``` +compact-deploy --network preprod \ + --seed-cache-from-dust /path/to/state.json \ + --seed-cache-from-shielded /path/to/shielded.json # optional +``` + +- Accepts either raw JSON (the direct `serializeState()` output) or its gzipped copy. Gzip is detected by magic bytes. +- The file is renamed to the seed-derived cache name and dropped into `.states/`. +- **The previous cache (if any) is preserved at `.gz.bak`** — never deleted, never overwritten by the import. To roll back from a bad import, `mv .states/.gz.bak .states/.gz`. +- The write itself is atomic: payload lands in `.gz.tmp` first, then is renamed over `.gz`. A mid-write crash can never leave the live cache half-overwritten. +- Restore failure (e.g. schema mismatch) falls through to the normal "fresh sync from genesis" path with a `warn` log — so the deploy still completes if the import doesn't take. +- Ignored under `--no-cache` (with a warning), since load is disabled in that mode. + +## Wallet seed resolution + +Precedence, first non-null wins: + +1. `--seed-file ` +2. `MN_DEPLOYER_SEED` env var (hex or BIP39 mnemonic) +3. `[wallet].keystore` (encrypted JSON, passphrase prompted) +4. `--network local` only: built-in prefunded standalone seed at `[networks.local].wallet.index` (0..3) + +## Sample config + +```toml +[profile] +default_network = "local" +artifacts_dir = "src/artifacts" +deployments_dir = "deployments/compact" + +# ---------- Networks ---------- +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v3/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v3/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" +proof_server = "http://127.0.0.1:6300" +wallet = { source = "local", index = 0 } + +[networks.preview] +network_id = "preview" +indexer = "https://indexer.preview.midnight.network/api/v4/graphql" +indexer_ws = "wss://indexer.preview.midnight.network/api/v4/graphql/ws" +node = "https://rpc.preview.midnight.network" +node_ws = "wss://rpc.preview.midnight.network" +proof_server = "auto" +explorer = "https://preview.midnightexplorer.com" + +[networks.preprod] +network_id = "preprod" +indexer = "https://indexer.preprod.midnight.network/api/v4/graphql" +indexer_ws = "wss://indexer.preprod.midnight.network/api/v4/graphql/ws" +node = "https://rpc.preprod.midnight.network" +node_ws = "wss://rpc.preprod.midnight.network" +proof_server = "auto" +explorer = "https://preprod.midnightexplorer.com" + +# ---------- Wallet (non-local) ---------- +[wallet] +keystore = "./deployer.keystore.json" + +# ---------- Contracts ---------- +[contracts.Token] +artifact = "src/artifacts/Token/Token" +private_state_id = "tokenPrivateState" +init_private_state = { file = "./deploy/Token.private-state.json" } +args = ["MyToken", "MTK", 18] +signing_key_file = "./deploy/Token.signingkey" + +[contracts.Vault] +artifact = "src/artifacts/Vault/Vault" +args = [] +signing_key_file = "./deploy/Vault.signingkey" +``` + +`proof_server`: a URL pins the server; `"auto"` spawns a `testcontainers`-managed proof-server container for the duration of the deploy; omitting it falls back to the env var `PROOF_SERVER_PORT` then to `http://127.0.0.1:6300`. + +## Keystore format + +`compact-deploy` reads/writes a JSON keystore with the Ethereum V3 shape (scrypt + AES-128-CTR) but with `version: "midnight-1"` so other tooling does not silently mis-read it as an Ethereum key. The encrypted secret is a 32-byte Midnight wallet seed (hex). + +## Known issues (May 2026) + +1. **Preview endpoints null-routed.** `rpc.preview.midnight.network` and `indexer.preview.midnight.network` resolve to `0.0.0.0` on the authoritative AWS Route 53 nameservers for `midnight.network` (verified against Google, Cloudflare, and Quad9). Preview was alive on 2026-05-22, broken on 2026-05-24. Blocks every consumer of testkit-js's `PreviewTestEnvironment`. File at [midnightntwrk/servicedesk](https://github.com/midnightntwrk/servicedesk/issues/new?template=bug-report.yml). **Workaround:** none on public testnet. `make env-up` (local standalone) is the only working target until Midnight restores the endpoints. + +2. **Preprod blocked: `Wallet.Sync: Could not deserialize Ledger Event` on `DustSpendProcessed`.** Dust sync aborts mid-stream on a `DustSpendProcessed` event whose `midnight:event[v9]:`-prefixed `raw` bytes fail `effect/Schema` parsing. The thrown `Wallet.Sync` corrupts `DustLocalState`. The next `walletBalance()` call hits `RuntimeError: unreachable` in the ledger WASM. Two independent runs, two different event IDs: 2026-05-22 id **565,975** (confirmed in Midnight dev Discord by `Knife`); 2026-05-24 id **571,224** with `maxId` 676,018. Affected stack: `wallet-sdk-dust-wallet@4.0.0`, `ledger-v8@8.0.3`, `testkit-js@4.1.0`. File at [midnightntwrk/midnight-wallet](https://github.com/midnightntwrk/midnight-wallet/issues/new). Distinct from [#361 `InvalidDustSpendProof`](https://github.com/midnightntwrk/midnight-wallet/issues/361), which is a chain-side tx rejection (this bug is client-side event ingest). **Workaround:** none. Preview is also down (see #1). Local standalone is the only working target today. + +3. **Faucet is manual.** The deployer never hits a faucet. Fund the wallet's `unshielded` address (logged at startup) via the official Midnight faucet site or Discord bot before running. + +4. **Dust fee overhead default breaks faucet wallets.** testkit-js default `additionalFeeOverhead` is `5e20` vs a faucet wallet's `~3e15` dust → `Insufficient Funds: could not balance dust`. Deployer overrides to `5e14` for non-mainnet. Library users constructing their own provider must mirror this. + +5. **Long-history dust sync exhausts default Node heap.** Use `NODE_OPTIONS="--max-old-space-size=16384"` for the first sync. Cache fixes subsequent runs. + +## Programmatic API + +```ts +import { deploy } from "@openzeppelin/compact-deployer"; + +const result = await deploy({ + contract: "Token", + network: "local", + configPath: "./compact.toml", +}); +console.log(result.address); +``` diff --git a/packages/deployer/package.json b/packages/deployer/package.json new file mode 100644 index 0000000..579962d --- /dev/null +++ b/packages/deployer/package.json @@ -0,0 +1,69 @@ +{ + "name": "@openzeppelin/compact-deployer", + "description": "Forge-style deployer library for Midnight Compact contracts", + "version": "0.0.1", + "keywords": [ + "compact", + "midnight", + "deploy" + ], + "author": "OpenZeppelin Community ", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "tsc -p .", + "types": "tsc -p tsconfig.json --noEmit", + "test": "yarn vitest run", + "coverage": "yarn vitest run --coverage", + "clean": "git clean -fXd" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.3", + "@types/node": "24.10.1", + "typescript": "^5.9.3", + "vitest": "^4.1.6" + }, + "dependencies": { + "@midnight-ntwrk/compact-js": "2.5.1", + "@midnight-ntwrk/compact-runtime": "0.16.0", + "@midnight-ntwrk/ledger-v8": "8.0.3", + "@midnight-ntwrk/midnight-js-contracts": "4.1.0", + "@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.0", + "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.0", + "@midnight-ntwrk/midnight-js-level-private-state-provider": "4.1.0", + "@midnight-ntwrk/midnight-js-network-id": "4.1.0", + "@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.0", + "@midnight-ntwrk/midnight-js-types": "4.1.0", + "@midnight-ntwrk/midnight-js-utils": "4.1.0", + "@midnight-ntwrk/testkit-js": "4.1.0", + "@midnight-ntwrk/wallet-sdk-address-format": "3.1.1", + "@midnight-ntwrk/wallet-sdk-dust-wallet": "4.0.0", + "@midnight-ntwrk/wallet-sdk-facade": "4.0.0", + "@midnight-ntwrk/wallet-sdk-hd": "3.0.2", + "@midnight-ntwrk/wallet-sdk-shielded": "3.0.0", + "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "3.0.0", + "@scure/bip39": "^1.2.1", + "axios": "^1.12.0", + "pino": "^9.7.0", + "rxjs": "^7.8.1", + "smol-toml": "^1.3.4", + "testcontainers": "^10.28.0", + "zod": "^3.23.8" + } +} diff --git a/packages/deployer/src/config/compact-config.test.ts b/packages/deployer/src/config/compact-config.test.ts new file mode 100644 index 0000000..367a887 --- /dev/null +++ b/packages/deployer/src/config/compact-config.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { CompactConfig } from './compact-config.ts'; + +const MIN_VALID = ` +[profile] +default_network = "local" + +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v3/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v3/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" +proof_server = "http://127.0.0.1:6300" + +[contracts.Token] +artifact = "src/artifacts/Token/Token" +signing_key_file = "./deploy/Token.signingkey" +`; + +function tmpRepo(toml: string): string { + const dir = mkdtempSync(join(tmpdir(), 'compact-deploy-test-')); + writeFileSync(join(dir, 'compact.toml'), toml); + return dir; +} + +describe('CompactConfig', () => { + it('should parse a minimal valid config', async () => { + const dir = tmpRepo(MIN_VALID); + const config = await CompactConfig.load(undefined, dir); + expect(config.rootDir).toBe(dir); + expect(config.defaultNetwork).toBe('local'); + expect(config.network('local').network_id).toBe('undeployed'); + expect(config.contract('Token').artifact).toBe('src/artifacts/Token/Token'); + }); + + it('should throw with the available set when a lookup misses', async () => { + const dir = tmpRepo(MIN_VALID); + const config = await CompactConfig.load(undefined, dir); + expect(() => config.network('ghost')).toThrow(/Available: local/); + expect(() => config.contract('Vault')).toThrow(/Available: Token/); + }); + + it('should reject a config whose default_network does not exist', async () => { + const dir = tmpRepo(`${MIN_VALID}\n[profile]\ndefault_network = "ghost"\n`); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + ConfigError, + ); + }); + + it('should reject a contract missing signing_key_file', async () => { + const dir = tmpRepo(` +[networks.local] +network_id = "undeployed" +indexer = "http://x" +indexer_ws = "ws://x" +node = "http://x" +node_ws = "ws://x" +proof_server = "http://x" + +[contracts.Token] +artifact = "x" +`); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + ConfigError, + ); + }); + + it('should reject when init_private_state is set but private_state_id is not', async () => { + const dir = tmpRepo(` +[networks.local] +network_id = "undeployed" +indexer = "http://127.0.0.1:8088/api/v3/graphql" +indexer_ws = "ws://127.0.0.1:8088/api/v3/graphql/ws" +node = "http://127.0.0.1:9944" +node_ws = "ws://127.0.0.1:9944" +proof_server = "http://127.0.0.1:6300" + +[contracts.Token] +artifact = "x" +signing_key_file = "x.sk" +init_private_state = { file = "x.json" } +`); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + ConfigError, + ); + }); + + it('should expose hasNetwork / hasContract / listNetworks / listContracts', async () => { + const dir = tmpRepo(`${MIN_VALID} +[contracts.Vault] +artifact = "src/artifacts/Vault/Vault" +signing_key_file = "./deploy/Vault.signingkey" +`); + const config = await CompactConfig.load(undefined, dir); + expect(config.hasNetwork('local')).toBe(true); + expect(config.hasNetwork('ghost')).toBe(false); + expect(config.hasContract('Token')).toBe(true); + expect(config.hasContract('Vault')).toBe(true); + expect(config.hasContract('Ghost')).toBe(false); + expect(config.listNetworks()).toEqual(['local']); + expect(config.listContracts().sort()).toEqual(['Token', 'Vault']); + }); + + it('should throw ConfigError when --config path does not exist', async () => { + const missing = join(tmpdir(), `does-not-exist-${Date.now()}.toml`); + await expect(CompactConfig.load(missing)).rejects.toThrow( + /--config path does not exist/, + ); + }); + + it('should throw ConfigError when no compact.toml exists upward from cwd', async () => { + const dir = mkdtempSync(join(tmpdir(), 'no-compact-toml-')); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + /compact\.toml not found/, + ); + }); +}); diff --git a/packages/deployer/src/config/compact-config.ts b/packages/deployer/src/config/compact-config.ts new file mode 100644 index 0000000..c93fa1c --- /dev/null +++ b/packages/deployer/src/config/compact-config.ts @@ -0,0 +1,143 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; +import { parse as parseToml } from 'smol-toml'; +import { ConfigError } from '../errors.ts'; +import { + type CompactConfigData, + type ContractConfig, + configSchema, + type NetworkConfig, + type WalletConfig, +} from './schema.ts'; + +/** + * Parsed + validated `compact.toml` with the resolved project root. + * Single source of truth for the pipeline; `network` / `contract` + * lookups throw {@link ConfigError} with the available set on miss. + */ +export class CompactConfig { + readonly configPath: string; + readonly rootDir: string; + readonly #data: CompactConfigData; + + private constructor(data: CompactConfigData, configPath: string) { + this.#data = data; + this.configPath = configPath; + this.rootDir = dirname(configPath); + } + + /** Walks up from `cwd` Foundry-style when `explicitPath` is omitted. */ + static async load( + explicitPath?: string, + cwd: string = process.cwd(), + ): Promise { + const configPath = explicitPath + ? resolveExplicit(explicitPath, cwd) + : findUpward(cwd); + if (!configPath) { + throw new ConfigError( + `compact.toml not found (searched upward from ${cwd}). Pass --config or create one at the repo root.`, + ); + } + + let raw: string; + try { + raw = await readFile(configPath, 'utf8'); + } catch (e) { + throw new ConfigError( + `Failed to read ${configPath}: ${(e as Error).message}`, + ); + } + + let parsed: unknown; + try { + parsed = parseToml(raw); + } catch (e) { + throw new ConfigError( + `Invalid TOML in ${configPath}: ${(e as Error).message}`, + ); + } + + const result = configSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues + .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`) + .join('\n'); + throw new ConfigError(`compact.toml validation failed:\n${issues}`); + } + + return new CompactConfig(result.data, configPath); + } + + get defaultNetwork(): string | undefined { + return this.#data.profile.default_network; + } + + get artifactsDir(): string { + return this.#data.profile.artifacts_dir; + } + + get deploymentsDir(): string { + return this.#data.profile.deployments_dir; + } + + get wallet(): WalletConfig | undefined { + return this.#data.wallet; + } + + hasNetwork(name: string): boolean { + return Object.hasOwn(this.#data.networks, name); + } + + hasContract(name: string): boolean { + return Object.hasOwn(this.#data.contracts, name); + } + + listNetworks(): string[] { + return Object.keys(this.#data.networks); + } + + listContracts(): string[] { + return Object.keys(this.#data.contracts); + } + + network(name: string): NetworkConfig { + const n = this.#data.networks[name]; + if (!n) { + throw new ConfigError( + `Network "${name}" not defined. Available: ${this.listNetworks().join(', ')}`, + ); + } + return n; + } + + contract(name: string): ContractConfig { + const c = this.#data.contracts[name]; + if (!c) { + throw new ConfigError( + `Contract "${name}" not defined. Available: ${this.listContracts().join(', ')}`, + ); + } + return c; + } +} + +function resolveExplicit(p: string, cwd: string): string { + const abs = isAbsolute(p) ? p : resolve(cwd, p); + if (!existsSync(abs)) { + throw new ConfigError(`--config path does not exist: ${abs}`); + } + return abs; +} + +function findUpward(start: string): string | undefined { + let dir = resolve(start); + while (true) { + const candidate = resolve(dir, 'compact.toml'); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} diff --git a/packages/deployer/src/config/schema.test.ts b/packages/deployer/src/config/schema.test.ts new file mode 100644 index 0000000..7466e7d --- /dev/null +++ b/packages/deployer/src/config/schema.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { configSchema, isFileRef, isModuleRef } from './schema.ts'; + +const validNetwork = { + network_id: 'testnet', + indexer: 'https://indexer.example/api', + indexer_ws: 'wss://indexer.example/ws', + node: 'https://node.example', + node_ws: 'wss://node.example/ws', +}; + +const validContract = { + artifact: 'src/artifacts/Counter', + signing_key_file: 'keys/counter.signing', +}; + +const baseConfig = { + networks: { testnet: validNetwork }, + contracts: { Counter: validContract }, +}; + +describe('configSchema — profile', () => { + it('should default artifacts_dir and deployments_dir', () => { + const parsed = configSchema.parse(baseConfig); + expect(parsed.profile.artifacts_dir).toBe('src/artifacts'); + expect(parsed.profile.deployments_dir).toBe('deployments/compact'); + }); + + it('should accept an explicit profile block', () => { + const parsed = configSchema.parse({ + ...baseConfig, + profile: { + artifacts_dir: 'out', + deployments_dir: 'deploys', + }, + }); + expect(parsed.profile.artifacts_dir).toBe('out'); + expect(parsed.profile.deployments_dir).toBe('deploys'); + }); +}); + +describe('configSchema — networks', () => { + it('should reject a non-URL indexer', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { testnet: { ...validNetwork, indexer: 'not-a-url' } }, + }), + ).toThrow(); + }); + + it('should accept proof_server = "auto"', () => { + const parsed = configSchema.parse({ + ...baseConfig, + networks: { testnet: { ...validNetwork, proof_server: 'auto' } }, + }); + expect(parsed.networks.testnet.proof_server).toBe('auto'); + }); + + it('should accept proof_server as a URL', () => { + const parsed = configSchema.parse({ + ...baseConfig, + networks: { + testnet: { ...validNetwork, proof_server: 'http://localhost:6300' }, + }, + }); + expect(parsed.networks.testnet.proof_server).toBe('http://localhost:6300'); + }); + + it('should reject proof_server other than URL or "auto"', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { testnet: { ...validNetwork, proof_server: 'manual' } }, + }), + ).toThrow(); + }); + + it('should clamp wallet.index to 0..3 only', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { + testnet: { + ...validNetwork, + wallet: { source: 'local', index: 4 }, + }, + }, + }), + ).toThrow(); + }); + + it('should default wallet.index to 0', () => { + const parsed = configSchema.parse({ + ...baseConfig, + networks: { + testnet: { + ...validNetwork, + wallet: { source: 'local' }, + }, + }, + }); + expect(parsed.networks.testnet.wallet?.index).toBe(0); + }); +}); + +describe('configSchema — profile.default_network refine', () => { + it('should accept default_network pointing at a defined network', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + profile: { default_network: 'testnet' }, + }), + ).not.toThrow(); + }); + + it('should reject default_network pointing at an undefined network', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + profile: { default_network: 'mainnet' }, + }), + ).toThrow(/default_network.*defined.*networks/); + }); + + it('should allow default_network to be omitted', () => { + const parsed = configSchema.parse(baseConfig); + expect(parsed.profile.default_network).toBeUndefined(); + }); +}); + +describe('configSchema — contract refine (private state pairing)', () => { + it('should accept both private_state_id and init_private_state set together', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { + ...validContract, + private_state_id: 'counter-ps', + init_private_state: { file: 'state.json' }, + }, + }, + }), + ).not.toThrow(); + }); + + it('should accept both omitted', () => { + expect(() => configSchema.parse(baseConfig)).not.toThrow(); + }); + + it('should reject private_state_id without init_private_state', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { ...validContract, private_state_id: 'counter-ps' }, + }, + }), + ).toThrow(/private_state_id and init_private_state must be set together/); + }); + + it('should reject init_private_state without private_state_id', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { + ...validContract, + init_private_state: { file: 'state.json' }, + }, + }, + }), + ).toThrow(/private_state_id and init_private_state must be set together/); + }); +}); + +describe('configSchema — contract args', () => { + it('should accept args as an array', () => { + const parsed = configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { ...validContract, args: [1, 'two', { x: 3 }] }, + }, + }); + expect(parsed.contracts.Counter.args).toEqual([1, 'two', { x: 3 }]); + }); + + it('should accept args as a file ref', () => { + const parsed = configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { ...validContract, args: { file: 'args.json' } }, + }, + }); + expect(parsed.contracts.Counter.args).toEqual({ file: 'args.json' }); + }); + + it('should accept args as a module ref and default export to "default"', () => { + const parsed = configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { ...validContract, args: { module: 'args.ts' } }, + }, + }); + expect(parsed.contracts.Counter.args).toEqual({ + module: 'args.ts', + export: 'default', + }); + }); +}); + +describe('configSchema — required fields', () => { + it('should reject a contract missing signing_key_file', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { Counter: { artifact: 'src/artifacts/Counter' } }, + }), + ).toThrow(); + }); + + it('should reject a network missing network_id', () => { + const { network_id: _omit, ...withoutId } = validNetwork; + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { testnet: withoutId }, + }), + ).toThrow(); + }); +}); + +describe('isFileRef / isModuleRef', () => { + it('should distinguish a file ref', () => { + expect(isFileRef({ file: 'x' })).toBe(true); + expect(isFileRef({ module: 'x' })).toBe(false); + expect(isFileRef(undefined)).toBe(false); + expect(isFileRef(null)).toBe(false); + expect(isFileRef('plain string')).toBe(false); + }); + + it('should distinguish a module ref', () => { + expect(isModuleRef({ module: 'x', export: 'default' })).toBe(true); + expect(isModuleRef({ file: 'x' })).toBe(false); + expect(isModuleRef(undefined)).toBe(false); + expect(isModuleRef(null)).toBe(false); + }); +}); diff --git a/packages/deployer/src/config/schema.ts b/packages/deployer/src/config/schema.ts new file mode 100644 index 0000000..ea571b8 --- /dev/null +++ b/packages/deployer/src/config/schema.ts @@ -0,0 +1,105 @@ +/** + * Zod schema for `compact.toml`. Cross-field rules: `profile.default_network` + * must name a defined `[networks.X]`; `private_state_id` and + * `init_private_state` are both-or-neither. + */ + +import { z } from 'zod'; + +const url = z.string().url(); + +const profileSchema = z + .object({ + default_network: z.string().optional(), + artifacts_dir: z.string().default('src/artifacts'), + deployments_dir: z.string().default('deployments/compact'), + }) + .default({}); + +const localWalletSchema = z.object({ + source: z.literal('local'), + index: z.number().int().min(0).max(3).default(0), +}); + +const networkSchema = z.object({ + network_id: z.string().min(1), + indexer: url, + indexer_ws: url, + node: url, + node_ws: url, + proof_server: z.union([url, z.literal('auto')]).optional(), + wallet: localWalletSchema.optional(), + // Optional block-explorer base URL (e.g. `https://preview.midnightexplorer.com`). + // When set, the CLI prints `/contracts/0x
` on a successful + // deploy. Trailing slash is stripped at print time. + explorer: url.optional(), +}); + +const walletObjectSchema = z.object({ + keystore: z.string().optional(), +}); +const walletSchema = walletObjectSchema.optional(); + +const fileRefSchema = z.object({ file: z.string().min(1) }); +const moduleRefSchema = z.object({ + module: z.string().min(1), + export: z.string().default('default'), +}); +const fileOrModuleRefSchema = z.union([fileRefSchema, moduleRefSchema]); + +const argsSchema = z.union([z.array(z.unknown()), fileOrModuleRefSchema]); + +const contractSchema = z + .object({ + artifact: z.string().min(1), + private_state_id: z.string().optional(), + init_private_state: fileOrModuleRefSchema.optional(), + private_state_store_name: z.string().optional(), + args: argsSchema.optional(), + witnesses: fileOrModuleRefSchema.optional(), + signing_key_file: z.string().min(1), + }) + .refine( + (c) => + (c.private_state_id === undefined) === + (c.init_private_state === undefined), + { + message: + 'private_state_id and init_private_state must be set together (or both omitted)', + }, + ); + +export const configSchema = z + .object({ + profile: profileSchema, + networks: z.record(z.string(), networkSchema), + wallet: walletSchema, + contracts: z.record(z.string(), contractSchema), + }) + .refine( + (c) => + c.profile.default_network === undefined || + Object.hasOwn(c.networks, c.profile.default_network), + { + message: + 'profile.default_network must reference a defined [networks.X] block', + path: ['profile', 'default_network'], + }, + ); + +export type CompactConfigData = z.infer; +export type NetworkConfig = z.infer; +export type ContractConfig = z.infer; +export type Profile = z.infer; +export type WalletConfig = z.infer; +export type FileRef = z.infer; +export type ModuleRef = z.infer; +export type FileOrModuleRef = z.infer; + +export function isFileRef(v: unknown): v is FileRef { + return typeof v === 'object' && v !== null && 'file' in v; +} + +export function isModuleRef(v: unknown): v is ModuleRef { + return typeof v === 'object' && v !== null && 'module' in v; +} diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts new file mode 100644 index 0000000..2a39ae1 --- /dev/null +++ b/packages/deployer/src/deployer.test.ts @@ -0,0 +1,760 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import type { MidnightWalletProvider } from '@midnight-ntwrk/testkit-js'; +import pino from 'pino'; +import * as Rx from 'rxjs'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; +import { Deployer } from './deployer.ts'; +import { DeployTxFailedError, UnfundedWalletError } from './errors.ts'; +import { buildProviders } from './providers/build.ts'; +import { WalletHandler } from './wallet/handler.ts'; + +vi.mock('./loaders/artifact.ts', () => ({ + Artifact: { + load: vi.fn(async () => ({ + artifactPath: '/fake/artifact', + zkConfigPath: '/fake/artifact', + compiledContract: { fake: 'compiled' }, + circuitNames: ['increment'], + })), + }, +})); + +vi.mock('./providers/proof-server.ts', () => ({ + ProofServer: { + start: vi.fn(async () => ({ + url: 'http://localhost:6300', + [Symbol.asyncDispose]: async () => { + // no-op for static-URL stub + }, + })), + }, +})); + +vi.mock('./providers/build.ts', () => ({ + buildProviders: vi.fn(() => ({})), +})); + +vi.mock('./wallet/handler.ts', () => ({ + WalletHandler: { build: vi.fn() }, +})); + +vi.mock('@midnight-ntwrk/midnight-js-contracts', () => ({ + deployContract: vi.fn(), +})); + +// Identity-throttle so `syncAndVerifyFunds`'s progress + checkpoint +// subscriptions fire on the single state emission instead of waiting +// 30 s / 5 min in real wall-clock for the trailing tick. +vi.mock('rxjs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + throttleTime: + () => + (src: import('rxjs').Observable): import('rxjs').Observable => + src, + }; +}); + +vi.mock('@midnight-ntwrk/midnight-js-network-id', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@midnight-ntwrk/midnight-js-network-id') + >(); + return { + ...actual, + // logWalletAddresses passes whatever this returns to the codec + // mock which ignores the arg. Opaque value is fine. + getNetworkId: vi.fn(() => 0), + }; +}); + +// Stub the bech32 codec triplet so `logWalletAddresses` reaches its +// happy-path info logs instead of catching at the encode call. +vi.mock('@midnight-ntwrk/wallet-sdk-address-format', () => { + const codec = { + encode: vi.fn(() => ({ toString: () => 'addr1stub' })), + }; + return { + ShieldedAddress: { codec }, + UnshieldedAddress: { codec }, + DustAddress: { codec }, + }; +}); + +const silentLogger = pino({ level: 'silent' }); + +interface FakeProvider { + getCoinPublicKey: () => string; + start: Mock; + stop: Mock; + wallet: { + state: () => Rx.Observable; + shielded: { tag: string; state?: Rx.Observable }; + unshielded?: { state: Rx.Observable }; + dust?: { state: Rx.Observable }; + }; +} + +function fakeSubWalletStates() { + const addr = { address: 'addr-bytes' }; + return { + shielded: Rx.of(addr), + unshielded: Rx.of(addr), + dust: Rx.of(addr), + }; +} + +/** + * Emits one already-synced `FacadeState` with a `Proxy` balance map that + * returns `1n` for any token key, so `syncAndVerifyFunds` passes through + * without a real Rx pipeline (we don't mock ledger-v8 in this file). + */ +function fakeProvider(coinKey = '0xCOIN'): FakeProvider { + const anyKeyHasBalance = new Proxy({} as Record, { + get: () => 1n, + }); + const syncedState = { + isSynced: true, + shielded: { + balances: anyKeyHasBalance, + state: { progress: { isStrictlyComplete: () => true } }, + }, + unshielded: { + balances: anyKeyHasBalance, + progress: { isStrictlyComplete: () => true }, + }, + dust: { + state: { progress: { isStrictlyComplete: () => true } }, + balance: () => 1n, + }, + }; + const sub = fakeSubWalletStates(); + return { + getCoinPublicKey: () => coinKey, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + wallet: { + state: () => Rx.of(syncedState as unknown), + shielded: { tag: 'shielded', state: sub.shielded }, + unshielded: { state: sub.unshielded }, + dust: { state: sub.dust }, + }, + }; +} + +function asInjected(p: FakeProvider): MidnightWalletProvider { + return p as unknown as MidnightWalletProvider; +} + +interface FakeOwned { + owned: WalletHandler; + provider: FakeProvider; + dispose: Mock; + saveCache: Mock; +} + +function fakeOwnedWallet(coinKey = '0xCOIN'): FakeOwned { + return fakeOwnedFromProvider(fakeProvider(coinKey)); +} + +function fakeOwnedFromProvider(provider: FakeProvider): FakeOwned { + const dispose = vi.fn(async () => { + await provider.stop(); + }); + const saveCache = vi.fn(async () => undefined); + const owned = { + provider, + saveCache, + [Symbol.asyncDispose]: dispose, + } as unknown as WalletHandler; + return { owned, provider, dispose, saveCache }; +} + +/** + * Provider whose `wallet.state()` is fully caller-controlled. Used to drive + * timeout / unfunded / mixed-funds branches of `syncAndVerifyFunds`. + */ +function fakeProviderWithState( + state$: Rx.Observable, + coinKey = '0xCOIN', +): FakeProvider { + const sub = fakeSubWalletStates(); + return { + getCoinPublicKey: () => coinKey, + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + wallet: { + state: () => state$, + shielded: { tag: 'shielded', state: sub.shielded }, + unshielded: { state: sub.unshielded }, + dust: { state: sub.dust }, + }, + }; +} + +/** Build a single FacadeState with caller-supplied shielded/unshielded balance maps. */ +function syncedState( + shielded: Record, + unshielded: Record, +): unknown { + return { + isSynced: true, + shielded: { + balances: shielded, + state: { progress: { isStrictlyComplete: () => true } }, + }, + unshielded: { + balances: unshielded, + progress: { isStrictlyComplete: () => true }, + }, + dust: { + state: { progress: { isStrictlyComplete: () => true } }, + balance: () => 0n, + }, + }; +} + +type DeployTxResult = Awaited>; +function fakeDeployTxResult(address = '0xCONTRACT'): DeployTxResult { + return { + deployTxData: { + public: { + contractAddress: address, + txHash: '0xHASH', + txId: '0xTX', + blockHeight: 1234, + }, + }, + } as unknown as DeployTxResult; +} + +interface Fixture { + rootDir: string; + configPath: string; + cleanup: () => void; +} + +function writeFixture(opts: { explorer?: string } = {}): Fixture { + const rootDir = mkdtempSync(join(tmpdir(), 'deployer-test-')); + const explorerLine = opts.explorer ? `explorer = "${opts.explorer}"\n` : ''; + const toml = ` +[profile] +artifacts_dir = "artifacts" +deployments_dir = "deployments" + +[networks.local] +network_id = "undeployed" +indexer = "http://localhost:8088/api/v1/graphql" +indexer_ws = "ws://localhost:8088/api/v1/graphql/ws" +node = "http://localhost:9944" +node_ws = "ws://localhost:9944" +proof_server = "http://localhost:6300" +wallet = { source = "local", index = 0 } +${explorerLine} +[contracts.Counter] +artifact = "Counter" +signing_key_file = "signing-key.hex" +`; + writeFileSync(join(rootDir, 'compact.toml'), toml); + writeFileSync(join(rootDir, 'signing-key.hex'), `${'aa'.repeat(32)}\n`); + return { + rootDir, + configPath: join(rootDir, 'compact.toml'), + cleanup: () => rmSync(rootDir, { recursive: true, force: true }), + }; +} + +describe('Deployer', () => { + let fx: Fixture; + + beforeEach(() => { + fx = writeFixture(); + // Default owned-build returns a fresh fake; tests that need to + // introspect the built provider override with `mockResolvedValueOnce`. + vi.mocked(WalletHandler.build).mockImplementation( + async () => fakeOwnedWallet().owned, + ); + vi.mocked(deployContract).mockResolvedValue(fakeDeployTxResult()); + }); + + afterEach(() => { + fx.cleanup(); + vi.clearAllMocks(); + }); + + it('should return dryRun:true and not submit a tx on dryRun', async () => { + const injected = fakeProvider('0xINJECTED'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.dryRun(); + + expect(result.dryRun).toBe(true); + expect(result.address).toBe(''); + expect(result.txHash).toBe(''); + expect(result.deploymentsFile).toBe(''); + expect(result.contractName).toBe('Counter'); + expect(result.network).toBe('local'); + expect(result.deployer).toBe('0xINJECTED'); + expect(deployContract).not.toHaveBeenCalled(); + }); + + it('should submit the tx and return the populated success result on deploy', async () => { + const injected = fakeProvider('0xDEPLOYER'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + + expect(deployContract).toHaveBeenCalledTimes(1); + expect(buildProviders).toHaveBeenCalledTimes(1); + expect(result.dryRun).toBe(false); + expect(result.address).toBe('0xCONTRACT'); + expect(result.txHash).toBe('0xHASH'); + expect(result.txId).toBe('0xTX'); + expect(result.blockHeight).toBe(1234); + expect(result.deployer).toBe('0xDEPLOYER'); + expect(result.deploymentsFile).toContain('deployments'); + }); + + it('should adopt an injected walletProvider and not call WalletHandler.build', async () => { + const injected = fakeProvider(); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + expect(d.contractName).toBe('Counter'); + expect(WalletHandler.build).not.toHaveBeenCalled(); + expect(injected.start).not.toHaveBeenCalled(); + }); + + it('should build and start a wallet when none is injected', async () => { + const built = fakeOwnedWallet('0xBUILT'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + }); + expect(d.deployer).toBe('0xBUILT'); + expect(WalletHandler.build).toHaveBeenCalledTimes(1); + // Deployer calls start(false) and then runs its own sync gate + + // saveCache; assert the start arg and that saveCache fired (twice: + // once via the periodic checkpoint tick, once via the post-sync + // final snapshot). + expect(built.provider.start).toHaveBeenCalledWith(false); + expect(built.saveCache.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('should dispose the owned wallet on asyncDispose but not the injected one', async () => { + const built = fakeOwnedWallet('0xOWNED'); + const injected = fakeProvider('0xINJ'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + { + await using owned = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + }); + expect(owned.deployer).toBe('0xOWNED'); + } + { + await using adopted = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + expect(adopted.deployer).toBe('0xINJ'); + } + expect(built.dispose).toHaveBeenCalledTimes(1); + expect(built.provider.stop).toHaveBeenCalledTimes(1); + expect(injected.stop).not.toHaveBeenCalled(); + }); + + it('should wrap midnight-js deploy failures in DeployTxFailedError', async () => { + vi.mocked(deployContract).mockRejectedValueOnce( + new Error('chain rejected'), + ); + const injected = fakeProvider(); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + await expect(d.deploy()).rejects.toBeInstanceOf(DeployTxFailedError); + }); + + describe('syncAndVerifyFunds (owned-wallet branch)', () => { + it('should reject with a timeout error when the wallet never reports isSynced', async () => { + const built = fakeOwnedFromProvider( + fakeProviderWithState(Rx.NEVER, '0xSTUCK'), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await expect( + Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 50, + }), + ).rejects.toThrow(/Wallet sync timeout after 50ms/); + }); + + it('should throw UnfundedWalletError when shielded and unshielded balances are both empty', async () => { + const built = fakeOwnedFromProvider( + fakeProviderWithState(Rx.of(syncedState({}, {})), '0xEMPTY'), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await expect( + Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }), + ).rejects.toBeInstanceOf(UnfundedWalletError); + }); + + it('should NOT throw when only the unshielded side has a positive balance', async () => { + // Use a Proxy so any token key returns the expected balance. The + // ledger token raw key is opaque from this file. + const unshieldedAny = new Proxy({} as Record, { + get: () => 5n, + }); + const built = fakeOwnedFromProvider( + fakeProviderWithState( + Rx.of(syncedState({}, unshieldedAny)), + '0xUNSHIELDED-ONLY', + ), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xUNSHIELDED-ONLY'); + expect(built.saveCache.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it('should NOT throw when only the shielded side has a positive balance', async () => { + const shieldedAny = new Proxy({} as Record, { + get: () => 3n, + }); + const built = fakeOwnedFromProvider( + fakeProviderWithState( + Rx.of(syncedState(shieldedAny, {})), + '0xSHIELDED-ONLY', + ), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xSHIELDED-ONLY'); + }); + }); + + describe('explorer URL', () => { + it('should return an empty explorerUrl when no explorer is configured', async () => { + const injected = fakeProvider('0xDEP'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + expect(result.explorerUrl).toBe(''); + }); + + it('should return an empty explorerUrl when the address is empty', async () => { + const customFx = writeFixture({ explorer: 'https://explorer.example' }); + try { + vi.mocked(deployContract).mockResolvedValueOnce({ + deployTxData: { + public: { + contractAddress: '', + txHash: '0xH', + txId: '0xT', + blockHeight: 1, + }, + }, + } as unknown as DeployTxResult); + const injected = fakeProvider('0xDEP'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + expect(result.explorerUrl).toBe(''); + } finally { + customFx.cleanup(); + } + }); + + it('should NOT double-prefix when the address already starts with 0x', async () => { + const customFx = writeFixture({ explorer: 'https://explorer.example' }); + try { + // fakeDeployTxResult default address already includes the 0x prefix. + const injected = fakeProvider('0xDEP'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + expect(result.explorerUrl).toBe( + 'https://explorer.example/contracts/0xCONTRACT', + ); + } finally { + customFx.cleanup(); + } + }); + + it('should add the 0x prefix when the address lacks one', async () => { + const customFx = writeFixture({ explorer: 'https://explorer.example' }); + try { + vi.mocked(deployContract).mockResolvedValueOnce( + fakeDeployTxResult('BARE'), + ); + const injected = fakeProvider('0xDEP'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + expect(result.explorerUrl).toBe( + 'https://explorer.example/contracts/0xBARE', + ); + } finally { + customFx.cleanup(); + } + }); + + it('should strip a trailing slash from the explorer base', async () => { + const customFx = writeFixture({ explorer: 'https://explorer.example/' }); + try { + const injected = fakeProvider('0xDEP'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + expect(result.explorerUrl).toBe( + 'https://explorer.example/contracts/0xCONTRACT', + ); + } finally { + customFx.cleanup(); + } + }); + }); + + describe('resolveTargets', () => { + it('should throw ConfigError when no --network is passed and no [profile].default_network is set', async () => { + // fixture has no default_network, so omitting `network` triggers the throw + await expect( + Deployer.prepare({ + contract: 'Counter', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(fakeProvider()), + }), + ).rejects.toThrow(/No network selected/); + }); + }); + + describe('owned-wallet saveCache failure', () => { + it('should warn-log and continue when the post-sync saveCache throws', async () => { + const provider = fakeProvider('0xWARN'); + const dispose = vi.fn(async () => { + await provider.stop(); + }); + // First call comes from the checkpoint sub (best-effort, never + // awaited by the source) and we let it succeed to avoid leaking + // an unhandled rejection from the `onCheckpoint().finally(...)` + // in the source. The second call is the awaited post-sync save + // whose failure we DO want to assert is warn-logged. + let calls = 0; + const saveCache = vi.fn(async () => { + calls += 1; + if (calls === 1) return; + throw new Error('disk full'); + }); + const owned = { + provider, + saveCache, + [Symbol.asyncDispose]: dispose, + } as unknown as WalletHandler; + vi.mocked(WalletHandler.build).mockResolvedValueOnce(owned); + + const warn = vi.fn(); + const loggerWithWarn = pino({ level: 'silent' }); + (loggerWithWarn as any).warn = warn; + + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: loggerWithWarn, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xWARN'); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'disk full' }), + expect.stringContaining('Wallet cache save failed'), + ); + }); + }); + + describe('logWalletAddresses', () => { + it('should log the three bech32 addresses on the owned-wallet happy path', async () => { + const built = fakeOwnedWallet('0xADDR'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + + const info = vi.fn(); + const logger = pino({ level: 'silent' }); + (logger as any).info = info; + + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xADDR'); + expect(info).toHaveBeenCalledWith( + 'Wallet addresses (verify these match your seed):', + ); + expect(info).toHaveBeenCalledWith( + expect.stringMatching(/shielded:.*addr1stub/), + ); + }); + }); + + describe('describeProgress branches', () => { + it('should render the progress percentage when highest > 0', async () => { + // Mid-sync state (NOT yet `isSynced`) that drives the progress + // subscription's "else" branch (highest > 0). Then a follow-up + // synced state lets `firstValueFrom(filter(isSynced))` resolve so + // the prepare call terminates. + const midState = { + isSynced: false, + shielded: { + balances: {} as Record, + state: { + progress: { + isStrictlyComplete: () => false, + appliedIndex: 10n, + highestIndex: 100n, + isConnected: true, + }, + }, + }, + unshielded: { + balances: {} as Record, + progress: { + isStrictlyComplete: () => false, + appliedId: 5n, + highestTransactionId: 50n, + isConnected: true, + }, + }, + dust: { + state: { + progress: { + isStrictlyComplete: () => false, + appliedIndex: 1n, + highestIndex: 10n, + isConnected: true, + }, + }, + balance: () => 0n, + }, + }; + const anyKeyHasBalance = new Proxy({} as Record, { + get: () => 1n, + }); + const syncedState = { + isSynced: true, + shielded: { + balances: anyKeyHasBalance, + state: { progress: { isStrictlyComplete: () => true } }, + }, + unshielded: { + balances: anyKeyHasBalance, + progress: { isStrictlyComplete: () => true }, + }, + dust: { + state: { progress: { isStrictlyComplete: () => true } }, + balance: () => 1n, + }, + }; + const built = fakeOwnedFromProvider( + fakeProviderWithState( + Rx.of(midState as unknown, syncedState as unknown), + '0xPROGRESS', + ), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xPROGRESS'); + }); + }); +}); diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts new file mode 100644 index 0000000..4f76296 --- /dev/null +++ b/packages/deployer/src/deployer.ts @@ -0,0 +1,698 @@ +import { shieldedToken, unshieldedToken } from '@midnight-ntwrk/ledger-v8'; +import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; +import type { + EnvironmentConfiguration, + MidnightWalletProvider, +} from '@midnight-ntwrk/testkit-js'; +import { + DustAddress, + ShieldedAddress, + UnshieldedAddress, +} from '@midnight-ntwrk/wallet-sdk-address-format'; +import type { FacadeState } from '@midnight-ntwrk/wallet-sdk-facade'; +import type { Logger } from 'pino'; +import * as Rx from 'rxjs'; +import { CompactConfig } from './config/compact-config.ts'; +import type { ContractConfig, NetworkConfig } from './config/schema.ts'; +import { type DeploymentRecord, Deployments } from './deployments.ts'; +import { + ConfigError, + DeployTxFailedError, + UnfundedWalletError, +} from './errors.ts'; + +/** + * Default sync ceiling (10 min). Overrides testkit-js's hardcoded 90 s + * `waitForFunds` timeout, which is too short for real networks. + */ +const DEFAULT_SYNC_TIMEOUT_MS = 10 * 60 * 1000; + +import { ConstructorArgs } from './loaders/args.ts'; +import { Artifact } from './loaders/artifact.ts'; +import { InitialPrivateState } from './loaders/init-state.ts'; +import { SigningKey } from './loaders/signing-key.ts'; +import { buildProviders } from './providers/build.ts'; +import { applyNetwork } from './providers/network.ts'; +import { ProofServer } from './providers/proof-server.ts'; +import { WalletHandler } from './wallet/handler.ts'; +import { resolveSeed } from './wallet/seeds.ts'; + +/** Inputs to {@link Deployer.prepare}. */ +export interface DeployerOptions { + contract: string; + network?: string; + configPath?: string; + seedFile?: string; + proofServer?: string; + argsOverride?: string; + /** + * Programmatic constructor args. Highest precedence — overrides + * `argsOverride`, the TOML `args` field, and any file/module ref. + * Either a positional array (`[a, b, c]`) or a named object + * (`{ foo: a, bar: b }`); named objects are reordered to match the + * artifact's constructor signature. + */ + args?: readonly unknown[] | Record; + initPrivateStateOverride?: string; + logger: Logger; + promptPassphrase?: (path: string) => Promise; + /** + * Inject a shared wallet so back-to-back deploys reuse one UTXO view. + * When set, prepare skips seed resolution + lifecycle management. + * The caller owns `start()`/`stop()`. Avoids `DustDoubleSpend` from + * indexer lag between rapid deploys. + */ + walletProvider?: MidnightWalletProvider; + /** + * Pass `inMemoryPrivateStateProvider()` in tests; otherwise multiple + * deployers in one process hit fcntl LOCK contention on the default + * LevelDB directory. + */ + privateStateProvider?: PrivateStateProvider; + /** Sync ceiling (ms). Defaults to {@link DEFAULT_SYNC_TIMEOUT_MS}. Ignored when {@link walletProvider} is injected. */ + syncTimeoutMs?: number; + /** Force a fresh sync from genesis. Default `false` (cache reuse saves the 30–60 min first-preprod sync). */ + skipWalletCache?: boolean; + /** + * Import a pre-warmed dust wallet state file into `.states/` before + * the wallet builds. Use this to skip the first-run preprod cold + * sync when you already have a `serializeState()` output from a + * prior session. Argv: `--seed-cache-from-dust`. + */ + seedCacheDust?: string; + /** Like {@link seedCacheDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ + seedCacheShielded?: string; +} + +/** Result of {@link Deployer.deploy} / {@link Deployer.dryRun}. On-chain fields are empty when `dryRun: true`. */ +export interface DeployResult { + contractName: string; + network: string; + address: string; + txHash: string; + txId: string; + blockHeight: number; + signingKey: string; + deployer: string; + artifact: string; + deploymentsFile: string; + dryRun: boolean; + /** `[networks.X].explorer` + `/contracts/0x
`, or empty when no explorer is configured / in dry-run. */ + explorerUrl: string; +} + +interface PreparedState { + opts: DeployerOptions; + logger: Logger; + config: CompactConfig; + networkName: string; + network: NetworkConfig; + contract: ContractConfig; + signingKey: SigningKey; + artifact: Artifact; + args: ConstructorArgs; + initialPrivateState: InitialPrivateState | undefined; + wallet: MidnightWalletProvider; + deployer: string; + env: EnvironmentConfiguration; + resources: AsyncDisposableStack; +} + +/** + * Stateful handle for one contract's deploy lifecycle. Always acquire + * with `await using`: `[Symbol.asyncDispose]` releases the proof-server + * container (if `"auto"`) and the wallet (if built here, not injected). + */ +export class Deployer implements AsyncDisposable { + /** Contract name as specified in opts. */ + readonly contractName: string; + /** Resolved network name (`opts.network` or `[profile].default_network`). */ + readonly networkName: string; + /** Hex of the deployer's coin public key. */ + readonly deployer: string; + /** Loaded artifact: zk config path + compiled-contract handle. */ + readonly artifact: Artifact; + /** Per-contract signing key loaded from disk. */ + readonly signingKey: SigningKey; + + readonly #state: PreparedState; + + private constructor(state: PreparedState) { + this.#state = state; + this.contractName = state.opts.contract; + this.networkName = state.networkName; + this.deployer = state.deployer; + this.artifact = state.artifact; + this.signingKey = state.signingKey; + } + + /** + * Load config + artifact + signing key, start proof server, build or + * adopt a wallet. Throws typed errors that map to CLI exit codes via + * {@link DeployError.exitCode}. + */ + static async prepare(opts: DeployerOptions): Promise { + const { logger } = opts; + + const config = await CompactConfig.load(opts.configPath); + const { rootDir } = config; + const { networkName, network, contract } = resolveTargets(opts, config); + const signingKey = await SigningKey.load( + rootDir, + contract.signing_key_file, + ); + + const seedResolution = opts.walletProvider + ? undefined + : await resolveSeed({ + config, + networkName, + network, + seedFile: opts.seedFile, + promptPassphrase: opts.promptPassphrase, + }); + if (seedResolution) { + logger.debug(`Resolved deployer seed from: ${seedResolution.origin}`); + } + + // Stack owns every resource acquired below. On any throw before + // the final `stack.move()`, `await using` disposes them in reverse + // order; on success, ownership transfers to the returned Deployer + // and the local `await using` becomes a no-op. + await using stack = new AsyncDisposableStack(); + + const proofServer = await ProofServer.start({ + cliOverride: opts.proofServer, + network, + logger, + }); + stack.use(proofServer); + + const { env } = applyNetwork(network, proofServer.url); + logger.debug( + `Network ID: ${env.networkId}; proof server: ${env.proofServer}`, + ); + + const artifact = await Artifact.load({ + rootDir, + artifactsDir: config.artifactsDir, + artifact: contract.artifact, + contractName: opts.contract, + witnesses: contract.witnesses, + }); + logger.debug( + `Artifact: ${artifact.artifactPath} (${artifact.circuitNames.length} circuits)`, + ); + + let wallet: MidnightWalletProvider; + if (opts.walletProvider) { + wallet = opts.walletProvider; + } else { + if (!seedResolution) { + throw new Error('internal: seedResolution missing for owned wallet'); + } + const owned = await WalletHandler.build( + logger, + env, + seedResolution.seed, + { + skipWalletCache: opts.skipWalletCache, + seedCacheDust: opts.seedCacheDust, + seedCacheShielded: opts.seedCacheShielded, + }, + ); + stack.use(owned); + wallet = owned.provider; + // Kick off the wallet's internal indexer subscription without + // blocking on testkit-js's 90 s `waitForFunds` gate (which is too + // short for real networks). Then drive sync ourselves with a + // configurable ceiling and surface a clear `UnfundedWalletError` + // if we reach chain tip and still have no shielded balance. + await wallet.start(false); + // Surface the wallet's derived bech32m addresses right away so + // the user can sanity-check they match the seed they intended + // *before* settling in for a long shielded sync. + await logWalletAddresses(wallet, logger); + await syncAndVerifyFunds({ + wallet, + timeoutMs: opts.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS, + logger, + // Periodic checkpoint: every 5 min during sync, snapshot both + // sub-wallet caches. If the user interrupts a long first-run, + // the next attempt resumes from the most recent checkpoint. + onCheckpoint: () => owned.saveCache(), + }); + // Snapshot the shielded + dust sub-wallets now that sync is + // complete. Best-effort: failures are warn-logged in + // `saveCache`'s caller; never block the deploy on a cache write. + try { + await owned.saveCache(); + } catch (e) { + logger.warn( + { err: (e as Error).message }, + 'Wallet cache save failed; next run will re-sync', + ); + } + } + + const args = await ConstructorArgs.load( + contract, + rootDir, + opts.argsOverride, + opts.args, + artifact.artifactPath, + ); + const initialPrivateState = await InitialPrivateState.load( + contract.init_private_state, + rootDir, + ); + const deployer = wallet.getCoinPublicKey(); + + return new Deployer({ + opts, + logger, + config, + networkName, + network, + contract, + signingKey, + artifact, + args, + initialPrivateState, + wallet, + deployer, + env, + resources: stack.move(), + }); + } + + /** Submit the deploy tx, persist the record under `deployments/.json`, return the result. */ + async deploy(): Promise { + const s = this.#state; + const providers = buildProviders({ + env: s.env, + wallet: s.wallet, + contractName: s.opts.contract, + contract: s.contract, + zkConfigPath: s.artifact.zkConfigPath, + privateStateProvider: s.opts.privateStateProvider, + }); + const txResult = await executeDeploy({ + providers, + contractName: s.opts.contract, + contract: s.contract, + artifact: s.artifact, + signingKey: s.signingKey.hex, + args: s.args.values, + initialPrivateState: s.initialPrivateState?.value, + }); + + const record = toDeploymentRecord({ + deployTxData: txResult.deployTxData, + signingKey: s.signingKey.hex, + deployer: s.deployer, + artifact: s.contract.artifact, + }); + + const deployments = new Deployments({ + rootDir: s.config.rootDir, + deploymentsDir: s.config.deploymentsDir, + network: s.networkName, + }); + const persisted = await deployments.record(s.opts.contract, record); + + return { + contractName: s.opts.contract, + network: s.networkName, + address: record.address, + txHash: record.txHash, + txId: record.txId, + blockHeight: record.blockHeight, + signingKey: record.signingKey, + deployer: record.deployer, + artifact: record.artifact, + deploymentsFile: persisted.head, + dryRun: false, + explorerUrl: buildExplorerUrl(s.network.explorer, record.address), + }; + } + + /** Log a "would deploy" event and return a synthetic result. No tx, no file. */ + async dryRun(): Promise { + const s = this.#state; + s.logger.info( + { + contract: s.opts.contract, + network: s.networkName, + artifact: s.artifact.artifactPath, + argCount: s.args.length, + hasPrivateState: s.initialPrivateState !== undefined, + deployer: s.deployer, + }, + 'dry-run: would deploy', + ); + return { + contractName: s.opts.contract, + network: s.networkName, + address: '', + txHash: '', + txId: '', + blockHeight: 0, + signingKey: s.signingKey.hex, + deployer: s.deployer, + artifact: s.contract.artifact, + deploymentsFile: '', + dryRun: true, + explorerUrl: '', + }; + } + + async [Symbol.asyncDispose](): Promise { + await this.#state.resources.disposeAsync(); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface ResolvedTargets { + networkName: string; + network: NetworkConfig; + contract: ContractConfig; +} + +function resolveTargets( + opts: DeployerOptions, + config: CompactConfig, +): ResolvedTargets { + const networkName = opts.network ?? config.defaultNetwork; + if (!networkName) { + throw new ConfigError( + 'No network selected. Pass --network or set [profile].default_network.', + ); + } + return { + networkName, + network: config.network(networkName), + contract: config.contract(opts.contract), + }; +} + +/** + * Print the wallet's three bech32m addresses so the user can verify + * the seed before a long sync. Best-effort: warn-and-continue on + * failure. + */ +async function logWalletAddresses( + wallet: MidnightWalletProvider, + logger: Logger, +): Promise { + try { + const networkId = getNetworkId(); + const [shieldedState, unshieldedState, dustState] = await Promise.all([ + Rx.firstValueFrom(wallet.wallet.shielded.state), + Rx.firstValueFrom(wallet.wallet.unshielded.state), + Rx.firstValueFrom(wallet.wallet.dust.state), + ]); + const shielded = ShieldedAddress.codec + .encode(networkId, shieldedState.address) + .toString(); + const unshielded = UnshieldedAddress.codec + .encode(networkId, unshieldedState.address) + .toString(); + const dust = DustAddress.codec + .encode(networkId, dustState.address) + .toString(); + logger.info('Wallet addresses (verify these match your seed):'); + logger.info(` shielded: ${shielded}`); + logger.info(` unshielded: ${unshielded}`); + logger.info(` dust: ${dust}`); + } catch (e) { + logger.warn( + { err: (e as Error).message }, + 'Could not derive wallet addresses for display; continuing', + ); + } +} + +/** + * One-liner progress string for "Still syncing". Accepts both progress + * shapes (shielded/dust use `appliedIndex`/`highestIndex`; unshielded + * uses `appliedId`/`highestTransactionId`). + */ +function describeProgress(p: { isStrictlyComplete: () => boolean }): string { + const complete = p.isStrictlyComplete(); + const fields = p as unknown as { + appliedIndex?: bigint; + highestIndex?: bigint; + highestRelevantIndex?: bigint; + appliedId?: bigint; + highestTransactionId?: bigint; + isConnected?: boolean; + }; + const applied = fields.appliedIndex ?? fields.appliedId ?? 0n; + const highest = fields.highestIndex ?? fields.highestTransactionId ?? 0n; + const connected = fields.isConnected ?? false; + // Once the indexer has told the wallet its max event id, we can + // render a real progress percentage. Until then surface "applied, + // highest unknown" and the subscription's connection state so the + // user can tell "still connecting" from "connected but no events yet" + // from "events flowing". + if (highest === 0n) { + return `applied=${applied} highest=? connected=${connected} complete=${complete}`; + } + const pct = Number((applied * 100n) / highest); + return `${applied}/${highest} (${pct}%) connected=${connected} complete=${complete}`; +} + +/** + * Drive the wallet to chain tip and assert spendable funds. Uses + * `state.isSynced` (strict-complete on all three sub-wallets) as the + * gate. Looser gates regressed on local with `Invalid Transaction + * (custom error 170)`. Throttles progress logs to once per 30 s. + * Throws {@link UnfundedWalletError} on empty wallet. + */ +async function syncAndVerifyFunds(args: { + wallet: MidnightWalletProvider; + timeoutMs: number; + logger: Logger; + /** Periodic checkpoint so a Ctrl+C mid-sync survives. Owned-wallet branch only. */ + onCheckpoint?: () => Promise; +}): Promise { + const { wallet, timeoutMs, logger, onCheckpoint } = args; + logger.info( + `Syncing wallet to chain tip (timeout ${Math.round(timeoutMs / 1000)}s)…`, + ); + const start = Date.now(); + + // Two subscriptions to the same observable: one logs throttled + // progress lines for UX, the other waits for completion. The progress + // tap deliberately runs through `Rx.throttleTime(30_000)` so the + // shielded-sync flood doesn't drown the terminal; the completion gate + // doesn't throttle, so the deploy proceeds the instant sync flips. + const state$ = wallet.wallet.state(); + const progressSub = state$ + .pipe( + Rx.throttleTime(30_000, undefined, { leading: false, trailing: true }), + ) + .subscribe((s) => { + const elapsedSec = Math.round((Date.now() - start) / 1000); + const elapsedHms = + elapsedSec < 60 + ? `${elapsedSec}s` + : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`; + // Pull running balance projections each tick so the user can + // see funds materialise mid-sync (NIGHT becomes visible the + // moment unshielded completes; dust accumulates as the wallet + // processes events even before its sync is strictly complete). + const shieldedBal = s.shielded.balances[shieldedToken().raw] ?? 0n; + const unshieldedBal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; + const dustBal = s.dust.balance(new Date()); + logger.info( + `Still syncing (${elapsedHms} elapsed). ` + + `shielded ${describeProgress(s.shielded.state.progress)} balance=${shieldedBal}; ` + + `unshielded ${describeProgress(s.unshielded.progress)} balance=${unshieldedBal}; ` + + `dust ${describeProgress(s.dust.state.progress)} balance=${dustBal}`, + ); + }); + + // Periodic checkpoint: snapshot the wallet caches every 5 min so a + // user who Ctrl+C's a long preprod first-run can resume from the + // latest snapshot instead of starting at id=0 again. Best-effort: + // a failed save logs a warning and the sync keeps going. Skipped + // when `onCheckpoint` is not provided (i.e. injected-wallet callers + // where the deployer doesn't own persistence). + let checkpointInFlight = false; + const checkpointSub = onCheckpoint + ? state$ + .pipe( + Rx.throttleTime(5 * 60 * 1000, undefined, { + leading: false, + trailing: true, + }), + ) + .subscribe(() => { + if (checkpointInFlight) return; + checkpointInFlight = true; + onCheckpoint().finally(() => { + checkpointInFlight = false; + }); + }) + : undefined; + + // Per-sub-wallet edge-trigger: the first time each sub-wallet flips + // to `complete=true`, log its current balance immediately. This lets + // a user with NIGHT+dust (the typical preprod-faucet wallet shape) + // see their unshielded balance after ~30 s instead of waiting for + // the full shielded sync (30 – 60 min) to surface anything. + const seenComplete = { shielded: false, unshielded: false, dust: false }; + const balanceSub = state$.subscribe((s) => { + if ( + !seenComplete.unshielded && + s.unshielded.progress.isStrictlyComplete() + ) { + seenComplete.unshielded = true; + const bal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; + logger.info(`Unshielded sync complete — NIGHT balance: ${bal}`); + } + if (!seenComplete.dust && s.dust.state.progress.isStrictlyComplete()) { + seenComplete.dust = true; + const bal = s.dust.balance(new Date()); + logger.info(`Dust sync complete — dust balance: ${bal}`); + } + if ( + !seenComplete.shielded && + s.shielded.state.progress.isStrictlyComplete() + ) { + seenComplete.shielded = true; + const bal = s.shielded.balances[shieldedToken().raw] ?? 0n; + logger.info(`Shielded sync complete — shielded balance: ${bal}`); + } + }); + + let synced: FacadeState; + try { + synced = await Rx.firstValueFrom( + state$.pipe( + Rx.filter((s: FacadeState) => s.isSynced), + Rx.timeout({ + each: timeoutMs, + with: () => + Rx.throwError( + () => new Error(`Wallet sync timeout after ${timeoutMs}ms`), + ), + }), + ), + ); + } finally { + progressSub.unsubscribe(); + balanceSub.unsubscribe(); + checkpointSub?.unsubscribe(); + } + + const totalSec = Math.round((Date.now() - start) / 1000); + const totalHms = + totalSec < 60 + ? `${totalSec}s` + : `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`; + logger.info(`Sync complete after ${totalHms}`); + + // Accept funds in either shielded or unshielded. Preprod faucets + // hand out unshielded NIGHT, while a freshly bridged wallet may sit + // entirely in the shielded layer. Both are deployable: dust for + // fees auto-generates from either NIGHT or shielded holdings. + // Mirrors midnight-apps's `waitForUnshieldedFunds` semantics. + const shieldedBal = synced.shielded.balances[shieldedToken().raw]; + const unshieldedBal = synced.unshielded.balances[unshieldedToken().raw]; + const hasShielded = shieldedBal !== undefined && shieldedBal > 0n; + const hasUnshielded = unshieldedBal !== undefined && unshieldedBal > 0n; + if (!hasShielded && !hasUnshielded) { + throw new UnfundedWalletError(wallet.getCoinPublicKey()); + } + logger.info( + `Wallet balance: shielded=${shieldedBal ?? 0n}, unshielded=${unshieldedBal ?? 0n}`, + ); +} + +interface ExecuteDeployArgs { + providers: Parameters[0]; + contractName: string; + contract: ContractConfig; + artifact: Artifact; + signingKey: string; + args: readonly unknown[]; + initialPrivateState: unknown; +} + +/** Submit the deploy tx; wrap failures in {@link DeployTxFailedError}. */ +async function executeDeploy({ + providers, + contractName, + contract, + artifact, + signingKey, + args, + initialPrivateState, +}: ExecuteDeployArgs): Promise>> { + const compiled = artifact.compiledContract as Parameters< + typeof deployContract + >[1]['compiledContract']; + const base = { + compiledContract: compiled, + signingKey, + args, + } as Parameters[1]; + const deployOptions = + contract.private_state_id !== undefined + ? { + ...base, + privateStateId: contract.private_state_id, + initialPrivateState, + } + : base; + + try { + return await deployContract(providers, deployOptions); + } catch (e) { + throw new DeployTxFailedError( + `Deploy of "${contractName}" failed: ${(e as Error).message}`, + { cause: e }, + ); + } +} + +type ContractDeployResult = Awaited>; + +/** Build `/contracts/0x
`, or `''` when no explorer / no address. */ +function buildExplorerUrl(base: string | undefined, address: string): string { + if (!base || !address) return ''; + const trimmed = base.endsWith('/') ? base.slice(0, -1) : base; + const hex = address.startsWith('0x') ? address : `0x${address}`; + return `${trimmed}/contracts/${hex}`; +} + +function toDeploymentRecord({ + deployTxData, + signingKey, + deployer, + artifact, +}: { + deployTxData: ContractDeployResult['deployTxData']; + signingKey: string; + deployer: string; + artifact: string; +}): DeploymentRecord { + return { + address: deployTxData.public.contractAddress, + txHash: deployTxData.public.txHash, + txId: deployTxData.public.txId, + blockHeight: deployTxData.public.blockHeight, + signingKey, + deployer, + artifact, + timestamp: new Date().toISOString(), + }; +} diff --git a/packages/deployer/src/deployments.test.ts b/packages/deployer/src/deployments.test.ts new file mode 100644 index 0000000..1ee9dd5 --- /dev/null +++ b/packages/deployer/src/deployments.test.ts @@ -0,0 +1,86 @@ +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { type DeploymentRecord, Deployments } from './deployments.ts'; + +function rec(address: string): DeploymentRecord { + return { + address, + txHash: '0xhash', + txId: '0xtx', + blockHeight: 42, + signingKey: 'aa'.repeat(32), + deployer: '0xdep', + artifact: 'src/artifacts/Token/Token', + timestamp: new Date('2026-05-15T00:00:00Z').toISOString(), + }; +} + +function make(root: string): Deployments { + return new Deployments({ + rootDir: root, + deploymentsDir: 'deployments/compact', + network: 'local', + }); +} + +describe('Deployments', () => { + it('should write a fresh deployments/.json', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const { head } = await make(root).record('Token', rec('0xaddr1')); + const parsed = JSON.parse(readFileSync(head, 'utf8')); + expect(parsed.Token.address).toBe('0xaddr1'); + }); + + it('should rotate the previous head into history on overwrite', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const d = make(root); + await d.record('Token', rec('0xfirst')); + const { head, history } = await d.record('Token', rec('0xsecond')); + + const headJson = JSON.parse(readFileSync(head, 'utf8')); + const historyJson = JSON.parse(readFileSync(history, 'utf8')); + + expect(headJson.Token.address).toBe('0xsecond'); + expect(historyJson.Token).toHaveLength(1); + expect(historyJson.Token[0].address).toBe('0xfirst'); + }); + + it('should preserve other contracts when one is updated', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const d = make(root); + await d.record('Token', rec('0xT1')); + const { head } = await d.record('Vault', rec('0xV1')); + const headJson = JSON.parse(readFileSync(head, 'utf8')); + expect(headJson.Token.address).toBe('0xT1'); + expect(headJson.Vault.address).toBe('0xV1'); + }); + + it('should honour an absolute deploymentsDir and expose paths', async () => { + const absDir = mkdtempSync(join(tmpdir(), 'persist-abs-')); + const d = new Deployments({ + rootDir: '/unused/root', + deploymentsDir: absDir, + network: 'local', + }); + expect(d.paths.head).toBe(join(absDir, 'local.json')); + expect(d.paths.history).toBe(join(absDir, 'local.history.json')); + }); + + it('should let getHead/getHistory/listContracts read what record wrote', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const d = make(root); + await d.record('Token', rec('0xT1')); + await d.record('Token', rec('0xT2')); + await d.record('Vault', rec('0xV1')); + + expect((await d.getHead('Token'))?.address).toBe('0xT2'); + expect(await d.getHead('Missing')).toBeUndefined(); + expect((await d.getHistory('Token')).map((r) => r.address)).toEqual([ + '0xT1', + ]); + expect(await d.getHistory('Vault')).toEqual([]); + expect(await d.listContracts()).toEqual(['Token', 'Vault']); + }); +}); diff --git a/packages/deployer/src/deployments.ts b/packages/deployer/src/deployments.ts new file mode 100644 index 0000000..701ad26 --- /dev/null +++ b/packages/deployer/src/deployments.ts @@ -0,0 +1,113 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, resolve } from 'node:path'; + +/** + * Two-file per-network deployment ledger: + * `.json` — head map (contract → latest deploy) + * `.history.json` — superseded records (contract → list) + * Each deploy rotates the prior head into history. + */ + +/** A single confirmed deploy. Persisted under the contract name in the head map. */ +export interface DeploymentRecord { + address: string; + txHash: string; + txId: string; + blockHeight: number; + signingKey: string; + deployer: string; + artifact: string; + timestamp: string; +} + +/** Head map: contract name → latest deploy. */ +export type DeploymentsFile = Record; + +/** History map: contract name → past deploys (newest first). */ +export type DeploymentsHistory = Record; + +export interface DeploymentsOptions { + rootDir: string; + deploymentsDir: string; + network: string; +} + +/** + * Per-network deployment ledger. Head file is written last so a crash + * mid-rotate leaves the prior head intact. + */ +export class Deployments { + readonly #headPath: string; + readonly #historyPath: string; + + constructor(opts: DeploymentsOptions) { + const dir = isAbsolute(opts.deploymentsDir) + ? opts.deploymentsDir + : resolve(opts.rootDir, opts.deploymentsDir); + this.#headPath = resolve(dir, `${opts.network}.json`); + this.#historyPath = resolve(dir, `${opts.network}.history.json`); + } + + /** Absolute on-disk paths for the two ledger files. */ + get paths(): { head: string; history: string } { + return { head: this.#headPath, history: this.#historyPath }; + } + + /** Rotate the prior head for `contractName` into history; write `record` as new head. */ + async record( + contractName: string, + record: DeploymentRecord, + ): Promise<{ head: string; history: string }> { + await mkdir(dirname(this.#headPath), { recursive: true }); + + const head = await this.#readHead(); + const previous = head[contractName]; + if (previous) { + const history = await this.#readHistory(); + const bucket = history[contractName] ?? []; + bucket.unshift(previous); + history[contractName] = bucket; + await writeJson(this.#historyPath, history); + } + + head[contractName] = record; + await writeJson(this.#headPath, head); + + return { head: this.#headPath, history: this.#historyPath }; + } + + /** Latest deploy for `contractName`, or `undefined` if none. */ + async getHead(contractName: string): Promise { + return (await this.#readHead())[contractName]; + } + + /** Per-contract history (newest first); empty array if none. */ + async getHistory(contractName: string): Promise { + return (await this.#readHistory())[contractName] ?? []; + } + + /** Names of every contract with a current head record on this network. */ + async listContracts(): Promise { + return Object.keys(await this.#readHead()).sort(); + } + + #readHead(): Promise { + return readJson(this.#headPath, {}); + } + + #readHistory(): Promise { + return readJson(this.#historyPath, {}); + } +} + +async function readJson(path: string, fallback: T): Promise { + if (!existsSync(path)) return fallback; + const raw = await readFile(path, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw) as T; +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/packages/deployer/src/errors.test.ts b/packages/deployer/src/errors.test.ts new file mode 100644 index 0000000..32f4886 --- /dev/null +++ b/packages/deployer/src/errors.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + ArtifactNotFoundError, + ConfigError, + DeployError, + DeployTxFailedError, + IndexerUnreachableError, + ProofServerUnreachableError, + UnfundedWalletError, + WalletError, +} from './errors.ts'; + +describe('DeployError', () => { + it('should default to exit code 1', () => { + const e = new DeployError('boom'); + expect(e.exitCode).toBe(1); + expect(e.name).toBe('DeployError'); + expect(e).toBeInstanceOf(Error); + }); + + it('should accept a custom exit code', () => { + const e = new DeployError('boom', 99); + expect(e.exitCode).toBe(99); + }); + + it('should preserve cause via ErrorOptions', () => { + const cause = new Error('underlying'); + const e = new DeployError('wrapper', 1, { cause }); + expect(e.cause).toBe(cause); + }); +}); + +describe('subclass exit codes', () => { + it('should pin ConfigError to 2', () => { + const e = new ConfigError('bad toml'); + expect(e.exitCode).toBe(2); + expect(e.name).toBe('ConfigError'); + expect(e).toBeInstanceOf(DeployError); + }); + + it('should pin ArtifactNotFoundError to 2', () => { + const e = new ArtifactNotFoundError('/x/y'); + expect(e.exitCode).toBe(2); + expect(e.name).toBe('ArtifactNotFoundError'); + expect(e.message).toContain('/x/y'); + expect(e).toBeInstanceOf(DeployError); + }); + + it('should pin WalletError to 3', () => { + const e = new WalletError('decrypt failed'); + expect(e.exitCode).toBe(3); + expect(e.name).toBe('WalletError'); + }); + + it('should pin UnfundedWalletError to 3 and include the address', () => { + const e = new UnfundedWalletError('mn_addr1...'); + expect(e.exitCode).toBe(3); + expect(e.name).toBe('UnfundedWalletError'); + expect(e.message).toContain('mn_addr1...'); + }); + + it('should pin ProofServerUnreachableError to 4', () => { + const e = new ProofServerUnreachableError('http://ps'); + expect(e.exitCode).toBe(4); + expect(e.name).toBe('ProofServerUnreachableError'); + expect(e.message).toContain('http://ps'); + }); + + it('should pin IndexerUnreachableError to 4', () => { + const e = new IndexerUnreachableError('http://idx'); + expect(e.exitCode).toBe(4); + expect(e.name).toBe('IndexerUnreachableError'); + expect(e.message).toContain('http://idx'); + }); + + it('should pin DeployTxFailedError to 5', () => { + const e = new DeployTxFailedError('rejected'); + expect(e.exitCode).toBe(5); + expect(e.name).toBe('DeployTxFailedError'); + }); +}); + +describe('instanceof chain', () => { + it('should let callers branch on DeployError once for any pipeline failure', () => { + const cases: DeployError[] = [ + new ConfigError('x'), + new WalletError('x'), + new ArtifactNotFoundError('x'), + new ProofServerUnreachableError('x'), + new IndexerUnreachableError('x'), + new UnfundedWalletError('x'), + new DeployTxFailedError('x'), + ]; + for (const c of cases) { + expect(c).toBeInstanceOf(DeployError); + expect(c).toBeInstanceOf(Error); + } + }); +}); diff --git a/packages/deployer/src/errors.ts b/packages/deployer/src/errors.ts new file mode 100644 index 0000000..6f9066f --- /dev/null +++ b/packages/deployer/src/errors.ts @@ -0,0 +1,74 @@ +/** + * Typed errors with stable `exitCode` per failure mode so `bin/compact-deploy` + * (and CI scripts) can branch without parsing messages. + */ + +/** Base deploy-pipeline failure. Default exit code `1`. */ +export class DeployError extends Error { + readonly exitCode: number; + constructor(message: string, exitCode = 1, options?: ErrorOptions) { + super(message, options); + this.name = 'DeployError'; + this.exitCode = exitCode; + } +} + +/** Config / TOML / schema. Exit code `2`. */ +export class ConfigError extends DeployError { + constructor(message: string, options?: ErrorOptions) { + super(message, 2, options); + this.name = 'ConfigError'; + } +} + +/** Seed, keystore, or wallet construction. Exit code `3`. */ +export class WalletError extends DeployError { + constructor(message: string, options?: ErrorOptions) { + super(message, 3, options); + this.name = 'WalletError'; + } +} + +/** Proof server unreachable. Exit code `4`. */ +export class ProofServerUnreachableError extends DeployError { + constructor(url: string, options?: ErrorOptions) { + super(`Proof server unreachable at ${url}`, 4, options); + this.name = 'ProofServerUnreachableError'; + } +} + +/** Indexer GraphQL endpoint unreachable. Exit code `4`. */ +export class IndexerUnreachableError extends DeployError { + constructor(url: string, options?: ErrorOptions) { + super(`Indexer unreachable at ${url}`, 4, options); + this.name = 'IndexerUnreachableError'; + } +} + +/** Deployer wallet has zero balance. Exit code `3`. */ +export class UnfundedWalletError extends DeployError { + constructor(address: string, options?: ErrorOptions) { + super(`Wallet ${address} has zero balance`, 3, options); + this.name = 'UnfundedWalletError'; + } +} + +/** Compiled artifact directory or required subfiles missing. Exit code `2`. */ +export class ArtifactNotFoundError extends DeployError { + constructor(path: string, options?: ErrorOptions) { + super( + `Compiled artifact not found at ${path}. Run \`compact-compiler\` to produce it.`, + 2, + options, + ); + this.name = 'ArtifactNotFoundError'; + } +} + +/** On-chain submission rejected the tx. Exit code `5`. */ +export class DeployTxFailedError extends DeployError { + constructor(message: string, options?: ErrorOptions) { + super(message, 5, options); + this.name = 'DeployTxFailedError'; + } +} diff --git a/packages/deployer/src/index.ts b/packages/deployer/src/index.ts new file mode 100644 index 0000000..c590e34 --- /dev/null +++ b/packages/deployer/src/index.ts @@ -0,0 +1,49 @@ +/** Programmatic API for `@openzeppelin/compact-deployer`; `compact-deploy` is an opinionated shell over this. */ +// biome-ignore-all lint/performance/noBarrelFile: this file is the programmatic API surface for consumers of @openzeppelin/compact-deployer +export { CompactConfig } from './config/compact-config.ts'; +export type { + ContractConfig, + NetworkConfig, + Profile, + WalletConfig, +} from './config/schema.ts'; +export type { DeployerOptions, DeployResult } from './deployer.ts'; +export { Deployer } from './deployer.ts'; +export type { + DeploymentRecord, + DeploymentsFile, + DeploymentsHistory, +} from './deployments.ts'; +export { Deployments } from './deployments.ts'; +export { + ArtifactNotFoundError, + ConfigError, + DeployError, + DeployTxFailedError, + IndexerUnreachableError, + ProofServerUnreachableError, + UnfundedWalletError, + WalletError, +} from './errors.ts'; +export type { ArgsSource } from './loaders/args.ts'; +export { ConstructorArgs } from './loaders/args.ts'; +export type { LoadArtifactOptions } from './loaders/artifact.ts'; +export { Artifact } from './loaders/artifact.ts'; +export { InitialPrivateState } from './loaders/init-state.ts'; +export { SigningKey } from './loaders/signing-key.ts'; +export { ProofServer } from './providers/proof-server.ts'; +export type { + CompactContractClass, + ConstructorArgsOf, + RunDeployOptions, +} from './runDeploy.ts'; +export { constructorArgs, runDeploy } from './runDeploy.ts'; +export { WalletHandler } from './wallet/handler.ts'; +export type { MidnightKeystore } from './wallet/keystore.ts'; +export { Keystore } from './wallet/keystore.ts'; +export type { WalletSeed } from './wallet/seeds.ts'; +export { + classifySeed, + LOCAL_PREFUNDED_SEEDS, + localPrefundedSeed, +} from './wallet/seeds.ts'; diff --git a/packages/deployer/src/loaders/args.test.ts b/packages/deployer/src/loaders/args.test.ts new file mode 100644 index 0000000..8b99ede --- /dev/null +++ b/packages/deployer/src/loaders/args.test.ts @@ -0,0 +1,174 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { ContractConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import { ConstructorArgs } from './args.ts'; + +function makeFakeArtifact(paramSig: string): string { + const dir = mkdtempSync(join(tmpdir(), 'args-artifact-')); + mkdirSync(join(dir, 'contract')); + writeFileSync( + join(dir, 'contract', 'index.d.ts'), + [ + "import type * as __compactRuntime from '@midnight-ntwrk/compact-runtime';", + 'export declare class Contract {', + ' initialState(context: __compactRuntime.ConstructorContext,', + ` ${paramSig}): __compactRuntime.ConstructorResult;`, + '}', + '', + ].join('\n'), + ); + return dir; +} + +const baseContract = (extra: Partial = {}): ContractConfig => + ({ + artifact: 'x', + signing_key_file: 'x.sk', + ...extra, + }) as ContractConfig; + +describe('ConstructorArgs', () => { + it('should return empty values when args is unset', async () => { + const args = await ConstructorArgs.load(baseContract(), '/tmp'); + expect(args.values).toEqual([]); + expect(args.source).toBe('empty'); + }); + + it('should pass inline arrays through', async () => { + const args = await ConstructorArgs.load( + baseContract({ args: ['MyToken', 'MTK', 18] }), + '/tmp', + ); + expect(args.values).toEqual(['MyToken', 'MTK', 18]); + expect(args.source).toBe('inline'); + }); + + it('should read a JSON file ref and revive bigints', async () => { + const dir = mkdtempSync(join(tmpdir(), 'args-test-')); + writeFileSync(join(dir, 'a.json'), '["x", "100n"]'); + const args = await ConstructorArgs.load( + baseContract({ args: { file: 'a.json' } }), + dir, + ); + expect(args.values).toEqual(['x', 100n]); + expect(args.source).toBe('file'); + }); + + it('should parse a --args override JSON string', async () => { + const args = await ConstructorArgs.load(baseContract(), '/tmp', '[1,2,3]'); + expect(args.values).toEqual([1, 2, 3]); + expect(args.source).toBe('cli'); + }); + + it('should reject a non-array --args override', async () => { + await expect( + ConstructorArgs.load(baseContract(), '/tmp', '{"x":1}'), + ).rejects.toThrow(ConfigError); + }); + + it('should resolve a { module, export } ref to an exported array', async () => { + const dir = mkdtempSync(join(tmpdir(), 'args-test-')); + writeFileSync(join(dir, 'm.mjs'), 'export const values = [1, "two", 3n];'); + const args = await ConstructorArgs.load( + baseContract({ args: { module: 'm.mjs', export: 'values' } }), + dir, + ); + expect(args.values).toEqual([1, 'two', 3n]); + expect(args.source).toBe('module'); + }); + + it('should reject a { module, export } ref whose export is not an array', async () => { + const dir = mkdtempSync(join(tmpdir(), 'args-test-')); + writeFileSync(join(dir, 'm.mjs'), 'export const notArr = { a: 1 };'); + await expect( + ConstructorArgs.load( + baseContract({ args: { module: 'm.mjs', export: 'notArr' } }), + dir, + ), + ).rejects.toThrow(/must be an array/); + }); + + it('should use programmatic apiArgs and win over every other source', async () => { + const dir = mkdtempSync(join(tmpdir(), 'args-test-')); + writeFileSync(join(dir, 'a.json'), '["from-file"]'); + const args = await ConstructorArgs.load( + baseContract({ args: { file: 'a.json' } }), + dir, + '["from-cli"]', + ['from-api', 42n, new Uint8Array([0xab])], + ); + expect(args.values).toEqual(['from-api', 42n, new Uint8Array([0xab])]); + expect(args.source).toBe('api'); + }); + + it('should accept an empty apiArgs array', async () => { + const args = await ConstructorArgs.load( + baseContract(), + '/tmp', + undefined, + [], + ); + expect(args.values).toEqual([]); + expect(args.source).toBe('api'); + }); + + it('should reject a { file } ref containing malformed JSON', async () => { + const dir = mkdtempSync(join(tmpdir(), 'args-test-')); + writeFileSync(join(dir, 'bad.json'), 'not json'); + await expect( + ConstructorArgs.load(baseContract({ args: { file: 'bad.json' } }), dir), + ).rejects.toThrow(/invalid JSON at/); + }); + + it('should reorder a named-object apiArgs to match the artifact constructor', async () => { + const artifactPath = makeFakeArtifact( + '_name_2: string, _decimals_2: bigint, _isMintable_0: boolean', + ); + const args = await ConstructorArgs.load( + baseContract(), + '/tmp', + undefined, + { _isMintable: true, _name: 'OZE', _decimals: 18n }, + artifactPath, + ); + expect(args.values).toEqual(['OZE', 18n, true]); + expect(args.source).toBe('api'); + }); + + it('should reject a named-object apiArgs missing a constructor parameter', async () => { + const artifactPath = makeFakeArtifact( + '_name_2: string, _decimals_2: bigint', + ); + await expect( + ConstructorArgs.load( + baseContract(), + '/tmp', + undefined, + { _name: 'OZE' }, + artifactPath, + ), + ).rejects.toThrow(/missing constructor parameter\(s\): _decimals/); + }); + + it('should reject a named-object apiArgs with unknown keys', async () => { + const artifactPath = makeFakeArtifact('_name_2: string'); + await expect( + ConstructorArgs.load( + baseContract(), + '/tmp', + undefined, + { _name: 'OZE', _bogus: 1 }, + artifactPath, + ), + ).rejects.toThrow(/unknown constructor parameter\(s\): _bogus/); + }); + + it('should reject a named-object apiArgs when artifactPath is missing', async () => { + await expect( + ConstructorArgs.load(baseContract(), '/tmp', undefined, { foo: 1 }), + ).rejects.toThrow(/named-object args require the artifact path/); + }); +}); diff --git a/packages/deployer/src/loaders/args.ts b/packages/deployer/src/loaders/args.ts new file mode 100644 index 0000000..b376e23 --- /dev/null +++ b/packages/deployer/src/loaders/args.ts @@ -0,0 +1,103 @@ +import { type ContractConfig, isFileRef } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import { + loadConstructorParamNames, + reorderNamedArgs, +} from './constructor-meta.ts'; +import { LoaderContext } from './context.ts'; +import { RefResolver } from './ref-resolver.ts'; + +export type ArgsSource = 'cli' | 'inline' | 'file' | 'module' | 'api' | 'empty'; + +/** Constructor args hydrated from CLI / TOML. `source` records the winning origin. */ +export class ConstructorArgs { + readonly values: readonly unknown[]; + readonly source: ArgsSource; + + private constructor(values: readonly unknown[], source: ArgsSource) { + this.values = values; + this.source = source; + } + + /** + * Precedence: programmatic `DeployerOptions.args` > `--args '[…]'` + * (JSON) > inline TOML array > `args = { file }` (JSON, `"123n"` + * revived as bigint) > `args = { module, export }` (value or + * zero-arg function). Empty result yields `source = 'empty'`. + * + * Programmatic args may be either a positional array or a named + * object. Named objects are reordered to match the artifact's + * constructor by parsing `/contract/index.d.ts` for + * the parameter order; `artifactPath` must be supplied when the + * caller may pass a named-object form. + */ + static async load( + contract: ContractConfig, + rootDir: string, + override?: string, + apiArgs?: readonly unknown[] | Record, + artifactPath?: string, + ): Promise { + if (apiArgs !== undefined) { + if (Array.isArray(apiArgs)) { + return new ConstructorArgs(apiArgs, 'api'); + } + if (artifactPath === undefined) { + throw new ConfigError( + 'named-object args require the artifact path; pass it via Deployer.prepare or use a positional array', + ); + } + const paramNames = loadConstructorParamNames(artifactPath); + const reordered = reorderNamedArgs( + apiArgs as Record, + paramNames, + ); + return new ConstructorArgs(reordered, 'api'); + } + if (override !== undefined) { + return new ConstructorArgs(parseJsonArray(override, '--args'), 'cli'); + } + const raw = contract.args; + if (raw === undefined) return new ConstructorArgs([], 'empty'); + if (Array.isArray(raw)) return new ConstructorArgs(raw, 'inline'); + + const resolver = new RefResolver( + new LoaderContext(rootDir), + 'args', + ); + const values = await resolver.resolve( + raw, + (text, path) => parseJsonArray(text, path), + (value, path, exp) => { + if (!Array.isArray(value)) { + throw new ConfigError( + `args: module ${path} export "${exp}" must be an array`, + ); + } + return value; + }, + ); + return new ConstructorArgs(values, isFileRef(raw) ? 'file' : 'module'); + } + + get length(): number { + return this.values.length; + } +} + +function parseJsonArray(text: string, label: string): unknown[] { + let parsed: unknown; + try { + parsed = JSON.parse(text, (_k, v) => + typeof v === 'string' && /^-?\d+n$/.test(v) ? BigInt(v.slice(0, -1)) : v, + ); + } catch (e) { + throw new ConfigError( + `args: invalid JSON at ${label}: ${(e as Error).message}`, + ); + } + if (!Array.isArray(parsed)) { + throw new ConfigError(`args at ${label} must be a JSON array`); + } + return parsed; +} diff --git a/packages/deployer/src/loaders/artifact.test.ts b/packages/deployer/src/loaders/artifact.test.ts new file mode 100644 index 0000000..dbf3969 --- /dev/null +++ b/packages/deployer/src/loaders/artifact.test.ts @@ -0,0 +1,314 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; + +vi.mock('@midnight-ntwrk/compact-js', () => ({ + CompiledContract: { + make: vi.fn((name: string, Ctor: unknown) => ({ name, Ctor })), + withWitnesses: vi.fn((base: unknown, w: unknown) => ({ + ...(base as object), + w, + })), + withVacantWitnesses: vi.fn((base: unknown) => ({ + ...(base as object), + vacant: true, + })), + withCompiledFileAssets: vi.fn((c: unknown, dir: string) => ({ + ...(c as object), + contractDir: dir, + })), + }, +})); + +const { Artifact } = await import('./artifact.ts'); + +function makeArtifactDir( + root: string, + name: string, + opts: { + contractEntry?: 'cjs' | 'js' | 'top-level-cjs' | 'top-level-js' | 'none'; + keys?: boolean; + zkir?: boolean; + circuits?: string[]; + contractExport?: 'named' | 'default' | 'none'; + } = {}, +): string { + const { + contractEntry = 'cjs', + keys = true, + zkir = true, + circuits = ['inc', 'dec'], + contractExport = 'named', + } = opts; + + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + + if (contractEntry !== 'none') { + const isTopLevel = contractEntry.startsWith('top-level'); + const ext = contractEntry.endsWith('cjs') ? 'cjs' : 'js'; + const subDir = isTopLevel ? dir : join(dir, 'contract'); + mkdirSync(subDir, { recursive: true }); + + let body = ''; + if (contractExport === 'named') { + body = 'module.exports.Contract = function Counter() {};'; + } else if (contractExport === 'default') { + body = 'module.exports.default = { Contract: function Counter() {} };'; + } else { + body = 'module.exports.somethingElse = 1;'; + } + writeFileSync(join(subDir, `index.${ext}`), body); + } + + if (keys) mkdirSync(join(dir, 'keys'), { recursive: true }); + + if (zkir) { + mkdirSync(join(dir, 'zkir'), { recursive: true }); + for (const c of circuits) { + writeFileSync(join(dir, 'zkir', `${c}.bzkir`), ''); + } + } + + return dir; +} + +describe('Artifact.load — path resolution', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-test-')); + }); + + it('should resolve a relative artifact under rootDir directly', async () => { + makeArtifactDir(root, 'Counter'); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'Counter', + contractName: 'Counter', + }); + expect(art.artifactPath).toBe(join(root, 'Counter')); + }); + + it('should fall back to artifactsDir when the direct path is missing', async () => { + const artifactsRel = 'build/out'; + makeArtifactDir(join(root, artifactsRel), 'Counter'); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: artifactsRel, + artifact: 'Counter', + contractName: 'Counter', + }); + expect(art.artifactPath).toBe(join(root, artifactsRel, 'Counter')); + }); + + it('should treat an absolute artifact path as-is', async () => { + const abs = makeArtifactDir(root, 'AbsCounter'); + const art = await Artifact.load({ + rootDir: '/elsewhere', + artifactsDir: 'unused/', + artifact: abs, + contractName: 'AbsCounter', + }); + expect(art.artifactPath).toBe(abs); + }); +}); + +describe('Artifact.load — error paths', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-err-')); + }); + + it('should throw ArtifactNotFoundError when the directory is missing', async () => { + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'NopeMissing', + contractName: 'NopeMissing', + }), + ).rejects.toThrow(ArtifactNotFoundError); + }); + + it('should throw ArtifactNotFoundError when contract/index entry is missing', async () => { + makeArtifactDir(root, 'NoEntry', { contractEntry: 'none' }); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'NoEntry', + contractName: 'NoEntry', + }), + ).rejects.toThrow(/no contract\/index/); + }); + + it('should throw ArtifactNotFoundError when keys/ is missing', async () => { + makeArtifactDir(root, 'NoKeys', { keys: false }); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'NoKeys', + contractName: 'NoKeys', + }), + ).rejects.toThrow(/missing keys\/ or zkir\//); + }); + + it('should throw ArtifactNotFoundError when zkir/ is missing', async () => { + makeArtifactDir(root, 'NoZkir', { zkir: false }); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'NoZkir', + contractName: 'NoZkir', + }), + ).rejects.toThrow(/missing keys\/ or zkir\//); + }); + + it('should throw ConfigError when index does not export a Contract class', async () => { + makeArtifactDir(root, 'NoExport', { contractExport: 'none' }); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'NoExport', + contractName: 'NoExport', + }), + ).rejects.toThrow(ConfigError); + }); + + it('should throw ConfigError when witnesses ref is a file ref (functions only via module)', async () => { + makeArtifactDir(root, 'WitnessFile'); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'WitnessFile', + contractName: 'WitnessFile', + witnesses: { file: 'w.json' }, + }), + ).rejects.toThrow( + /witnesses.*module.*export.*JSON file refs are not supported/, + ); + }); + + it('should throw ConfigError when witnesses module export does not resolve to an object', async () => { + makeArtifactDir(root, 'WitnessNonObject'); + writeFileSync( + join(root, 'w.mjs'), + 'export const witnesses = "not-an-object";', + ); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'WitnessNonObject', + contractName: 'WitnessNonObject', + witnesses: { module: 'w.mjs', export: 'witnesses' }, + }), + ).rejects.toThrow(/must resolve to an object/); + }); +}); + +describe('Artifact.load — witnesses module ref', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-wit-')); + }); + + it('should accept a { module, export } witnesses ref that resolves to an object', async () => { + makeArtifactDir(root, 'WithWitnesses'); + writeFileSync( + join(root, 'w.mjs'), + 'export const witnesses = { add: () => 1 };', + ); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'WithWitnesses', + contractName: 'WithWitnesses', + witnesses: { module: 'w.mjs', export: 'witnesses' }, + }); + expect(art.artifactPath).toBe(join(root, 'WithWitnesses')); + }); +}); + +describe('Artifact.load — entry-file fallbacks', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-entry-')); + }); + + it('should accept contract/index.js when contract/index.cjs is missing', async () => { + makeArtifactDir(root, 'CounterJs', { contractEntry: 'js' }); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'CounterJs', + contractName: 'CounterJs', + }); + expect(art.artifactPath).toBe(join(root, 'CounterJs')); + }); + + it('should fall back to top-level index.cjs when contract/ has no entry', async () => { + makeArtifactDir(root, 'TopLevel', { contractEntry: 'top-level-cjs' }); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'TopLevel', + contractName: 'TopLevel', + }); + expect(art.artifactPath).toBe(join(root, 'TopLevel')); + }); +}); + +describe('Artifact.load — circuit collection', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-circuits-')); + }); + + it('should collect and sort circuit names from .bzkir files', async () => { + makeArtifactDir(root, 'C', { circuits: ['zeta', 'alpha', 'mu'] }); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'C', + contractName: 'C', + }); + expect(art.circuitNames).toEqual(['alpha', 'mu', 'zeta']); + }); + + it('should produce an empty circuit list when zkir/ has no .bzkir files', async () => { + makeArtifactDir(root, 'Empty', { circuits: [] }); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'Empty', + contractName: 'Empty', + }); + expect(art.circuitNames).toEqual([]); + }); +}); + +describe('Artifact.load — default export Contract', () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'artifact-default-')); + }); + + it('should pick Contract from module.default when not on the top namespace', async () => { + makeArtifactDir(root, 'Default', { contractExport: 'default' }); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'unused/', + artifact: 'Default', + contractName: 'Default', + }); + expect(art.artifactPath).toBe(join(root, 'Default')); + }); +}); diff --git a/packages/deployer/src/loaders/artifact.ts b/packages/deployer/src/loaders/artifact.ts new file mode 100644 index 0000000..68d471c --- /dev/null +++ b/packages/deployer/src/loaders/artifact.ts @@ -0,0 +1,191 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { CompiledContract, type Contract } from '@midnight-ntwrk/compact-js'; +import type { Types } from 'effect'; +import { + type FileOrModuleRef, + isFileRef, + isModuleRef, +} from '../config/schema.ts'; +import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; + +/** + * A compactc artifact bundle on disk: + * /contract/index.{cjs,js} — Contract class + * /keys/.{prover,verifier} + * /zkir/.bzkir + * Witnesses live outside the bundle, referenced via `[contracts.X].witnesses`. + */ + +type AnyContract = Contract.Any; +type AnyWitnesses = Contract.Witnesses; +type AnyCompiledContract = CompiledContract.CompiledContract< + AnyContract, + unknown, + never +>; + +export interface LoadArtifactOptions { + rootDir: string; + artifactsDir: string; + artifact: string; + contractName: string; + witnesses?: FileOrModuleRef; +} + +export class Artifact { + readonly compiledContract: AnyCompiledContract; + readonly artifactPath: string; + readonly zkConfigPath: string; + readonly circuitNames: readonly string[]; + + private constructor(input: { + compiledContract: AnyCompiledContract; + artifactPath: string; + zkConfigPath: string; + circuitNames: readonly string[]; + }) { + this.compiledContract = input.compiledContract; + this.artifactPath = input.artifactPath; + this.zkConfigPath = input.zkConfigPath; + this.circuitNames = input.circuitNames; + } + + /** Resolve, validate, and import the bundle. Throws {@link ArtifactNotFoundError} on missing dir/entry/keys/zkir. */ + static async load(opts: LoadArtifactOptions): Promise { + const { rootDir, artifactsDir, artifact, contractName, witnesses } = opts; + const ctx = new LoaderContext(rootDir); + const artifactPath = resolveUnderRoot(rootDir, artifact, artifactsDir); + + if (!existsSync(artifactPath)) { + throw new ArtifactNotFoundError(artifactPath); + } + + const contractDir = resolve(artifactPath, 'contract'); + const entry = findEntry(contractDir, artifactPath); + if (!entry) { + throw new ArtifactNotFoundError( + `${artifactPath} (no contract/index.{cjs,js} or index.{cjs,js} found)`, + ); + } + + const keysDir = resolve(artifactPath, 'keys'); + const zkirDir = resolve(artifactPath, 'zkir'); + if (!existsSync(keysDir) || !existsSync(zkirDir)) { + throw new ArtifactNotFoundError( + `${artifactPath} (missing keys/ or zkir/ subdirectory)`, + ); + } + + const circuitNames = collectCircuitNames(zkirDir); + const Ctor = await importContractCtor(ctx, entry); + const witnessImpls = witnesses + ? await importWitnesses(ctx, witnesses) + : undefined; + + const compiledContract = buildCompiledContract({ + contractName, + Ctor, + witnessImpls, + contractDir, + }); + + return new Artifact({ + compiledContract, + artifactPath, + zkConfigPath: artifactPath, + circuitNames, + }); + } +} + +async function importContractCtor( + ctx: LoaderContext, + entry: string, +): Promise> { + const { mod, path } = await ctx.importModule(entry, 'artifact'); + const m = mod as ArtifactModule; + const Ctor = m.Contract ?? m.default?.Contract; + if (!Ctor) { + throw new ConfigError( + `Artifact at ${path} does not export a \`Contract\` class (got keys: ${Object.keys(m).join(', ')})`, + ); + } + return Ctor; +} + +async function importWitnesses( + ctx: LoaderContext, + ref: FileOrModuleRef, +): Promise { + if (isFileRef(ref)) { + throw new ConfigError( + 'witnesses must be a { module, export } reference; JSON file refs are not supported (witnesses are functions)', + ); + } + if (!isModuleRef(ref)) { + throw new ConfigError('witnesses must be { module, export }'); + } + const { mod, path } = await ctx.importModule(ref.module, 'witnesses'); + const exported = mod[ref.export]; + const resolved = + typeof exported === 'function' + ? await (exported as () => unknown)() + : exported; + if (typeof resolved !== 'object' || resolved === null) { + throw new ConfigError( + `witnesses: module ${path} export "${ref.export}" must resolve to an object`, + ); + } + return resolved as AnyWitnesses; +} + +function buildCompiledContract(input: { + contractName: string; + Ctor: Types.Ctor; + witnessImpls: AnyWitnesses | undefined; + contractDir: string; +}): AnyCompiledContract { + const base = CompiledContract.make(input.contractName, input.Ctor); + const withWit = input.witnessImpls + ? CompiledContract.withWitnesses(base, input.witnessImpls) + : CompiledContract.withVacantWitnesses(base); + return CompiledContract.withCompiledFileAssets(withWit, input.contractDir); +} + +interface ArtifactModule { + Contract?: Types.Ctor; + default?: { Contract?: Types.Ctor }; +} + +function resolveUnderRoot( + rootDir: string, + artifact: string, + artifactsDir: string, +): string { + if (isAbsolute(artifact)) return artifact; + const direct = resolve(rootDir, artifact); + if (existsSync(direct)) return direct; + return resolve(rootDir, artifactsDir, artifact); +} + +function findEntry( + contractDir: string, + artifactDir: string, +): string | undefined { + const candidates = [ + resolve(contractDir, 'index.cjs'), + resolve(contractDir, 'index.js'), + resolve(artifactDir, 'index.cjs'), + resolve(artifactDir, 'index.js'), + ]; + return candidates.find(existsSync); +} + +function collectCircuitNames(zkirDir: string): string[] { + return readdirSync(zkirDir) + .filter((f) => f.endsWith('.bzkir')) + .map((f) => f.slice(0, -'.bzkir'.length)) + .sort(); +} diff --git a/packages/deployer/src/loaders/constructor-meta.test.ts b/packages/deployer/src/loaders/constructor-meta.test.ts new file mode 100644 index 0000000..55ab6cc --- /dev/null +++ b/packages/deployer/src/loaders/constructor-meta.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { + parseConstructorParamNames, + reorderNamedArgs, +} from './constructor-meta.ts'; + +const dts = (params: string) => + [ + "import type * as __compactRuntime from '@midnight-ntwrk/compact-runtime';", + 'export declare class Contract {', + ' initialState(context: __compactRuntime.ConstructorContext,', + ` ${params}): __compactRuntime.ConstructorResult;`, + '}', + ].join('\n'); + +describe('parseConstructorParamNames', () => { + it('strips the trailing SSA suffix the compiler appends', () => { + expect( + parseConstructorParamNames( + dts('_name_2: string, _symbol_2: string, init_0: boolean'), + ), + ).toEqual(['_name', '_symbol', 'init']); + }); + + it('handles generics with commas inside angle brackets', () => { + expect( + parseConstructorParamNames( + dts('owner_0: Either, isInit_0: boolean'), + ), + ).toEqual(['owner', 'isInit']); + }); + + it('handles Vector / array types', () => { + expect( + parseConstructorParamNames( + dts( + 'salt_0: Uint8Array, commitments_0: Uint8Array[], thresh_0: bigint', + ), + ), + ).toEqual(['salt', 'commitments', 'thresh']); + }); + + it('keeps the SSA suffix when stripping would cause a name collision', () => { + expect( + parseConstructorParamNames(dts('foo_0: bigint, foo_1: bigint')), + ).toEqual(['foo_0', 'foo_1']); + }); + + it('returns [] for a no-arg constructor', () => { + expect(parseConstructorParamNames(dts(''))).toEqual([]); + }); + + it('returns [] when initialState is not present', () => { + expect(parseConstructorParamNames('// nothing here')).toEqual([]); + }); +}); + +describe('reorderNamedArgs', () => { + it('maps a named record to the positional tuple', () => { + expect(reorderNamedArgs({ b: 2, a: 1, c: 3 }, ['a', 'b', 'c'])).toEqual([ + 1, 2, 3, + ]); + }); + + it('rejects when a required name is missing', () => { + expect(() => reorderNamedArgs({ a: 1 }, ['a', 'b'])).toThrow(ConfigError); + }); + + it('rejects when an extra unknown name is present', () => { + expect(() => reorderNamedArgs({ a: 1, x: 9 }, ['a'])).toThrow( + /unknown constructor parameter\(s\): x/, + ); + }); +}); diff --git a/packages/deployer/src/loaders/constructor-meta.ts b/packages/deployer/src/loaders/constructor-meta.ts new file mode 100644 index 0000000..484fff7 --- /dev/null +++ b/packages/deployer/src/loaders/constructor-meta.ts @@ -0,0 +1,109 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; + +/** + * Parses an artifact's `contract/index.d.ts` and returns the ordered + * constructor parameter names (with the trailing `_` SSA + * suffix the Compact compiler appends stripped — `_name_2` → `_name`). + * Used to reorder a named-object `args: { ... }` into the positional + * tuple the contract's `initialState` expects. + */ +export function loadConstructorParamNames(artifactPath: string): string[] { + const dtsPath = join(artifactPath, 'contract', 'index.d.ts'); + if (!existsSync(dtsPath)) { + throw new ArtifactNotFoundError( + `${artifactPath} (no contract/index.d.ts — cannot reorder named args)`, + ); + } + const source = readFileSync(dtsPath, 'utf8'); + const names = parseConstructorParamNames(source); + if (names.length === 0) { + throw new ConfigError( + `Contract ${artifactPath} has a no-arg constructor; named args object should be empty`, + ); + } + return names; +} + +/** Reorders a named-object args record into a positional tuple. */ +export function reorderNamedArgs( + named: Record, + paramNames: readonly string[], +): unknown[] { + const missing = paramNames.filter((n) => !(n in named)); + if (missing.length > 0) { + throw new ConfigError( + `args object is missing constructor parameter(s): ${missing.join(', ')}`, + ); + } + const extra = Object.keys(named).filter((k) => !paramNames.includes(k)); + if (extra.length > 0) { + throw new ConfigError( + `args object has unknown constructor parameter(s): ${extra.join(', ')}. Expected: ${paramNames.join(', ')}`, + ); + } + return paramNames.map((n) => named[n]); +} + +/** + * Pulls the constructor parameter names out of a Compact artifact's + * `index.d.ts`. The trailing `_` SSA suffix is stripped; if + * stripping causes a collision in the same constructor, the original + * names are kept. + */ +export function parseConstructorParamNames(dtsSource: string): string[] { + const block = sliceInitialStateParams(dtsSource); + if (block === null) return []; + const params = splitTopLevelParams(block).slice(1); // drop `context: ...` + if (params.length === 0) return []; + const names = params.map(extractParamName); + const stripped = names.map(stripSsaSuffix); + return new Set(stripped).size === stripped.length ? stripped : names; +} + +function sliceInitialStateParams(source: string): string | null { + const head = source.indexOf('initialState('); + if (head === -1) return null; + const open = head + 'initialState('.length; + let depth = 1; + for (let i = open; i < source.length; i++) { + const ch = source[i]; + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return source.slice(open, i); + } + } + return null; +} + +function splitTopLevelParams(block: string): string[] { + const out: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < block.length; i++) { + const ch = block[i]; + if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth--; + else if (ch === ',' && depth === 0) { + out.push(block.slice(start, i).trim()); + start = i + 1; + } + } + const tail = block.slice(start).trim(); + if (tail.length > 0) out.push(tail); + return out; +} + +function extractParamName(raw: string): string { + const colon = raw.indexOf(':'); + if (colon === -1) { + throw new ConfigError(`Cannot parse constructor param: "${raw}"`); + } + return raw.slice(0, colon).trim(); +} + +function stripSsaSuffix(name: string): string { + return name.replace(/_\d+$/, ''); +} diff --git a/packages/deployer/src/loaders/context.test.ts b/packages/deployer/src/loaders/context.test.ts new file mode 100644 index 0000000..e20e075 --- /dev/null +++ b/packages/deployer/src/loaders/context.test.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; + +describe('LoaderContext.abs', () => { + it('should leave absolute paths unchanged', () => { + const ctx = new LoaderContext('/some/root'); + expect(ctx.abs('/abs/path/file.json')).toBe('/abs/path/file.json'); + }); + + it('should resolve relative paths against rootDir', () => { + const ctx = new LoaderContext('/some/root'); + const resolved = ctx.abs('inner/file.json'); + expect(isAbsolute(resolved)).toBe(true); + expect(resolved).toBe('/some/root/inner/file.json'); + }); +}); + +describe('LoaderContext.readText', () => { + it('should read a file and return text + absolute path', async () => { + const dir = mkdtempSync(join(tmpdir(), 'loader-ctx-')); + writeFileSync(join(dir, 'hello.txt'), 'world'); + const ctx = new LoaderContext(dir); + + const { text, path } = await ctx.readText('hello.txt', 'label'); + expect(text).toBe('world'); + expect(path).toBe(join(dir, 'hello.txt')); + }); + + it('should wrap ENOENT in ConfigError with the label and path', async () => { + const ctx = new LoaderContext('/tmp'); + await expect( + ctx.readText('does-not-exist-zzz.txt', 'my-label'), + ).rejects.toThrow(ConfigError); + await expect( + ctx.readText('does-not-exist-zzz.txt', 'my-label'), + ).rejects.toThrow(/my-label.*failed to read/); + }); +}); + +describe('LoaderContext.importModule', () => { + it('should dynamic-import a module from a relative path', async () => { + const dir = mkdtempSync(join(tmpdir(), 'loader-ctx-imp-')); + writeFileSync( + join(dir, 'sample.mjs'), + 'export const value = 42; export default { value: 7 };', + ); + const ctx = new LoaderContext(dir); + + const { mod, path } = await ctx.importModule('sample.mjs', 'label'); + expect(mod.value).toBe(42); + expect(path).toBe(join(dir, 'sample.mjs')); + }); + + it('should wrap import failures in ConfigError', async () => { + const ctx = new LoaderContext('/tmp'); + await expect( + ctx.importModule('nope-not-there.mjs', 'mods'), + ).rejects.toThrow(ConfigError); + await expect( + ctx.importModule('nope-not-there.mjs', 'mods'), + ).rejects.toThrow(/mods.*failed to import/); + }); + + it('should accept absolute paths unchanged', async () => { + const dir = mkdtempSync(join(tmpdir(), 'loader-ctx-abs-')); + const abs = join(dir, 'abs.mjs'); + writeFileSync(abs, 'export const ok = true;'); + const ctx = new LoaderContext('/unused/root'); + + const { mod, path } = await ctx.importModule(abs, 'l'); + expect(mod.ok).toBe(true); + expect(path).toBe(abs); + }); +}); diff --git a/packages/deployer/src/loaders/context.ts b/packages/deployer/src/loaders/context.ts new file mode 100644 index 0000000..0ceabf4 --- /dev/null +++ b/packages/deployer/src/loaders/context.ts @@ -0,0 +1,50 @@ +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { ConfigError } from '../errors.ts'; + +/** Per-call I/O bundle for loaders. Centralises the `ConfigError`-wrapping boilerplate. */ +export class LoaderContext { + readonly rootDir: string; + + constructor(rootDir: string) { + this.rootDir = rootDir; + } + + abs(p: string): string { + return isAbsolute(p) ? p : resolve(this.rootDir, p); + } + + async readText( + p: string, + label: string, + ): Promise<{ text: string; path: string }> { + const path = this.abs(p); + try { + const text = await readFile(path, 'utf8'); + return { text, path }; + } catch (e) { + throw new ConfigError( + `${label}: failed to read ${path}: ${(e as Error).message}`, + ); + } + } + + async importModule( + p: string, + label: string, + ): Promise<{ mod: Record; path: string }> { + const path = this.abs(p); + try { + const mod = (await import(pathToFileURL(path).href)) as Record< + string, + unknown + >; + return { mod, path }; + } catch (e) { + throw new ConfigError( + `${label}: failed to import ${path}: ${(e as Error).message}`, + ); + } + } +} diff --git a/packages/deployer/src/loaders/contract-resolve.test.ts b/packages/deployer/src/loaders/contract-resolve.test.ts new file mode 100644 index 0000000..917dad5 --- /dev/null +++ b/packages/deployer/src/loaders/contract-resolve.test.ts @@ -0,0 +1,181 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CompactConfig } from '../config/compact-config.ts'; +import { ConfigError } from '../errors.ts'; +import { resolveContractName } from './contract-resolve.ts'; + +const rootDirs: string[] = []; + +afterEach(() => { + // Module cache holds the imported artifacts. Tests use unique + // tmpdirs so no cleanup is needed. + rootDirs.length = 0; +}); + +function makeProject(entries: Record): string { + const root = mkdtempSync(join(tmpdir(), 'contract-resolve-')); + rootDirs.push(root); + mkdirSync(join(root, 'artifacts')); + const contractsToml = Object.keys(entries) + .map( + (name) => ` +[contracts.${name}] +artifact = "${name}" +signing_key_file = "${name}.sk" +`, + ) + .join('\n'); + writeFileSync( + join(root, 'compact.toml'), + ` +[profile] +default_network = "local" +artifacts_dir = "artifacts" + +[networks.local] +network_id = "local" +indexer = "http://localhost:8088/api/v1/graphql" +indexer_ws = "ws://localhost:8088/api/v1/graphql/ws" +node = "http://localhost:9944" +node_ws = "ws://localhost:9944" +proof_server = "http://localhost:6300" + +${contractsToml} +`, + ); + for (const [name, { Contract }] of Object.entries(entries)) { + const contractDir = join(root, 'artifacts', name, 'contract'); + mkdirSync(contractDir, { recursive: true }); + // The exported class instance is shared via the module cache, so + // the loaded module's `Contract` === the test's reference. + const g = globalThis as unknown as Record; + const key = `__test_contract_${name}_${Date.now()}_${Math.random()}`; + g[key] = Contract; + writeFileSync( + join(contractDir, 'index.js'), + `export const Contract = globalThis['${key}'];\n`, + ); + } + return root; +} + +describe('resolveContractName', () => { + it('returns the entry name whose artifact exports the same Contract class', async () => { + class TokenContract { + initialState() {} + } + class OtherContract { + initialState() {} + } + const root = makeProject({ + TokenExample: { Contract: TokenContract }, + OtherExample: { Contract: OtherContract }, + }); + const config = await CompactConfig.load(join(root, 'compact.toml')); + expect(await resolveContractName(TokenContract, config, root)).toBe( + 'TokenExample', + ); + }); + + it('throws when no entry matches the Contract class', async () => { + class A { + initialState() {} + } + class B { + initialState() {} + } + const root = makeProject({ A: { Contract: A } }); + const config = await CompactConfig.load(join(root, 'compact.toml')); + await expect(resolveContractName(B, config, root)).rejects.toThrow( + /did not match any \[contracts\.X\] entry/, + ); + }); + + it('throws when two entries match the same Contract class (ambiguous)', async () => { + class Shared { + initialState() {} + } + const root = makeProject({ + A: { Contract: Shared }, + B: { Contract: Shared }, + }); + const config = await CompactConfig.load(join(root, 'compact.toml')); + await expect(resolveContractName(Shared, config, root)).rejects.toThrow( + ConfigError, + ); + await expect(resolveContractName(Shared, config, root)).rejects.toThrow( + /Ambiguous Contract/, + ); + }); + + it('lists skipped entries when an artifact dir has no contract module', async () => { + class Target { + initialState() {} + } + const root = makeProject({ A: { Contract: Target } }); + // Inject a second entry whose artifact dir is empty (no + // contract/index.{cjs,js} files). + writeFileSync( + join(root, 'compact.toml'), + readFileSync(join(root, 'compact.toml'), 'utf8') + + ` +[contracts.Empty] +artifact = "Empty" +signing_key_file = "Empty.sk" +`, + ); + mkdirSync(join(root, 'artifacts', 'Empty')); + class Other { + initialState() {} + } + const config = await CompactConfig.load(join(root, 'compact.toml')); + await expect(resolveContractName(Other, config, root)).rejects.toThrow( + /Skipped: Empty \(no contract\/index/, + ); + }); + + it('skips entries whose artifact module import throws', async () => { + class Target { + initialState() {} + } + const root = makeProject({ Good: { Contract: Target } }); + writeFileSync( + join(root, 'compact.toml'), + readFileSync(join(root, 'compact.toml'), 'utf8') + + ` +[contracts.Broken] +artifact = "Broken" +signing_key_file = "Broken.sk" +`, + ); + mkdirSync(join(root, 'artifacts', 'Broken', 'contract'), { + recursive: true, + }); + writeFileSync( + join(root, 'artifacts', 'Broken', 'contract', 'index.js'), + 'throw new Error("boom on import");\n', + ); + const config = await CompactConfig.load(join(root, 'compact.toml')); + // Target still matches "Good" (good entry has the right Contract); + // the broken entry is just skipped silently in the match path. + expect(await resolveContractName(Target, config, root)).toBe('Good'); + }); + + it('honours an absolute `artifact` path in compact.toml', async () => { + class Target { + initialState() {} + } + const root = makeProject({ Token: { Contract: Target } }); + // Rewrite the [contracts.Token] entry to use an absolute artifact path. + const absPath = join(root, 'artifacts', 'Token'); + const toml = readFileSync(join(root, 'compact.toml'), 'utf8').replace( + 'artifact = "Token"', + `artifact = "${absPath}"`, + ); + writeFileSync(join(root, 'compact.toml'), toml); + const config = await CompactConfig.load(join(root, 'compact.toml')); + expect(await resolveContractName(Target, config, root)).toBe('Token'); + }); +}); diff --git a/packages/deployer/src/loaders/contract-resolve.ts b/packages/deployer/src/loaders/contract-resolve.ts new file mode 100644 index 0000000..091b9f4 --- /dev/null +++ b/packages/deployer/src/loaders/contract-resolve.ts @@ -0,0 +1,92 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { CompactConfig } from '../config/compact-config.ts'; +import { ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; + +/** + * Walks `compact.toml`'s `[contracts.X]` entries and returns the name + * whose compiled `Contract` class is identity-equal to the one + * imported by the caller's deploy script. Used by the curried + * `runDeploy(Contract)(...)` form so the deploy script names the + * contract once. + * + * Throws when: + * - no entry resolves to the same Contract class (the script likely + * imported from a path that isn't referenced by `compact.toml`) + * - two entries match (ambiguous — likely two TOML entries pointing + * at the same artifact directory). + */ +export async function resolveContractName( + Contract: unknown, + config: CompactConfig, + rootDir: string, +): Promise { + const ctx = new LoaderContext(rootDir); + const matches: string[] = []; + const tried: Array<{ name: string; reason: string }> = []; + + for (const name of config.listContracts()) { + const cfg = config.contract(name); + const entry = findContractEntry(rootDir, config.artifactsDir, cfg.artifact); + if (!entry) { + tried.push({ + name, + reason: `no contract/index.{cjs,js} or index.{cjs,js} under ${cfg.artifact}`, + }); + continue; + } + try { + const { mod } = await ctx.importModule(entry, 'artifact'); + const Loaded = + (mod as { Contract?: unknown }).Contract ?? + (mod as { default?: { Contract?: unknown } }).default?.Contract; + if (Loaded === Contract) { + matches.push(name); + } + } catch (e) { + tried.push({ name, reason: (e as Error).message }); + } + } + + if (matches.length === 1) return matches[0] as string; + if (matches.length > 1) { + throw new ConfigError( + `Ambiguous Contract: matches ${matches.length} entries in compact.toml (${matches.join(', ')}). Use the string form: runDeploy({ contract: 'X' }).`, + ); + } + const tail = + tried.length > 0 + ? `\nSkipped: ${tried.map((t) => `${t.name} (${t.reason})`).join('; ')}` + : ''; + throw new ConfigError( + `Contract class did not match any [contracts.X] entry in compact.toml. Make sure the import path resolves to the same artifact directory referenced by the TOML.${tail}`, + ); +} + +function findContractEntry( + rootDir: string, + artifactsDir: string, + artifact: string, +): string | undefined { + const artifactPath = resolveUnderRoot(rootDir, artifact, artifactsDir); + const contractDir = resolve(artifactPath, 'contract'); + const candidates = [ + resolve(contractDir, 'index.cjs'), + resolve(contractDir, 'index.js'), + resolve(artifactPath, 'index.cjs'), + resolve(artifactPath, 'index.js'), + ]; + return candidates.find(existsSync); +} + +function resolveUnderRoot( + rootDir: string, + artifact: string, + artifactsDir: string, +): string { + if (artifact.startsWith('/')) return artifact; + const direct = resolve(rootDir, artifact); + if (existsSync(direct)) return direct; + return resolve(rootDir, artifactsDir, artifact); +} diff --git a/packages/deployer/src/loaders/init-state.test.ts b/packages/deployer/src/loaders/init-state.test.ts new file mode 100644 index 0000000..b8abd6d --- /dev/null +++ b/packages/deployer/src/loaders/init-state.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { InitialPrivateState } from './init-state.ts'; + +describe('InitialPrivateState', () => { + it('should return undefined when ref is absent', async () => { + expect(await InitialPrivateState.load(undefined, '/tmp')).toBeUndefined(); + }); + + it('should parse a { file } JSON ref with bigint revival', async () => { + const dir = mkdtempSync(join(tmpdir(), 'initstate-test-')); + writeFileSync(join(dir, 's.json'), '{"counter":"100n","name":"x"}'); + const state = await InitialPrivateState.load({ file: 's.json' }, dir); + expect(state?.value).toEqual({ counter: 100n, name: 'x' }); + }); + + it('should throw ConfigError for missing files', async () => { + await expect( + InitialPrivateState.load({ file: 'does-not-exist.json' }, '/tmp'), + ).rejects.toThrow(ConfigError); + }); + + it('should throw ConfigError for invalid JSON', async () => { + const dir = mkdtempSync(join(tmpdir(), 'initstate-test-')); + writeFileSync(join(dir, 'bad.json'), 'not json'); + await expect( + InitialPrivateState.load({ file: 'bad.json' }, dir), + ).rejects.toThrow(ConfigError); + }); + + it('should resolve a { module, export } ref to its exported value', async () => { + const dir = mkdtempSync(join(tmpdir(), 'initstate-test-')); + writeFileSync( + join(dir, 'm.mjs'), + 'export const state = { counter: 5n, name: "from-mod" };', + ); + const state = await InitialPrivateState.load( + { module: 'm.mjs', export: 'state' }, + dir, + ); + expect(state?.value).toEqual({ counter: 5n, name: 'from-mod' }); + }); + + it('should throw ConfigError when the module export is missing', async () => { + const dir = mkdtempSync(join(tmpdir(), 'initstate-test-')); + writeFileSync(join(dir, 'm.mjs'), 'export const present = 1;'); + await expect( + InitialPrivateState.load({ module: 'm.mjs', export: 'missing' }, dir), + ).rejects.toThrow(/has no export "missing"/); + }); +}); diff --git a/packages/deployer/src/loaders/init-state.ts b/packages/deployer/src/loaders/init-state.ts new file mode 100644 index 0000000..ec6d04f --- /dev/null +++ b/packages/deployer/src/loaders/init-state.ts @@ -0,0 +1,54 @@ +import type { FileOrModuleRef } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; +import { RefResolver } from './ref-resolver.ts'; + +/** Initial private state for the contract constructor. `load` returns `undefined` when omitted in TOML. */ +export class InitialPrivateState { + readonly value: unknown; + + private constructor(value: unknown) { + this.value = value; + } + + /** Source: `{ file }` (JSON with `"123n"` bigint strings) or `{ module, export }` (value or zero-arg function). */ + static async load( + ref: FileOrModuleRef | undefined, + rootDir: string, + ): Promise { + if (!ref) return undefined; + + const resolver = new RefResolver( + new LoaderContext(rootDir), + 'init_private_state', + ); + const value = await resolver.resolve( + ref, + (text, path) => { + try { + return JSON.parse(text, bigintReviver); + } catch (e) { + throw new ConfigError( + `init_private_state: invalid JSON at ${path}: ${(e as Error).message}`, + ); + } + }, + (v, path, exp) => { + if (v === undefined) { + throw new ConfigError( + `init_private_state: module ${path} has no export "${exp}"`, + ); + } + return v; + }, + ); + return new InitialPrivateState(value); + } +} + +function bigintReviver(_key: string, value: unknown): unknown { + if (typeof value === 'string' && /^-?\d+n$/.test(value)) { + return BigInt(value.slice(0, -1)); + } + return value; +} diff --git a/packages/deployer/src/loaders/ref-resolver.test.ts b/packages/deployer/src/loaders/ref-resolver.test.ts new file mode 100644 index 0000000..0a4c3ad --- /dev/null +++ b/packages/deployer/src/loaders/ref-resolver.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; +import { RefResolver } from './ref-resolver.ts'; + +const parseJsonNumber = (text: string): number => Number.parseInt(text, 10); +const expectNumber = (value: unknown): number => { + if (typeof value !== 'number') throw new ConfigError('not a number'); + return value; +}; + +describe('RefResolver.resolve — file branch', () => { + it('should read the file and run parseFile', async () => { + const dir = mkdtempSync(join(tmpdir(), 'refres-file-')); + writeFileSync(join(dir, 'n.txt'), '42'); + const r = new RefResolver(new LoaderContext(dir), 'args'); + + const out = await r.resolve( + { file: 'n.txt' }, + parseJsonNumber, + expectNumber, + ); + expect(out).toBe(42); + }); + + it('should propagate ConfigError from a missing file', async () => { + const r = new RefResolver(new LoaderContext('/tmp'), 'args'); + await expect( + r.resolve( + { file: 'does-not-exist-xx.txt' }, + parseJsonNumber, + expectNumber, + ), + ).rejects.toThrow(ConfigError); + }); +}); + +describe('RefResolver.resolve — module branch', () => { + it('should import a module and pick the named export', async () => { + const dir = mkdtempSync(join(tmpdir(), 'refres-mod-')); + writeFileSync( + join(dir, 'm.mjs'), + 'export const seven = 7; export default 99;', + ); + const r = new RefResolver(new LoaderContext(dir), 'args'); + + const out = await r.resolve( + { module: 'm.mjs', export: 'seven' }, + parseJsonNumber, + expectNumber, + ); + expect(out).toBe(7); + }); + + it('should call a function-shaped export and use its return value', async () => { + const dir = mkdtempSync(join(tmpdir(), 'refres-fn-')); + writeFileSync( + join(dir, 'm.mjs'), + 'export const factory = async () => 123;', + ); + const r = new RefResolver(new LoaderContext(dir), 'args'); + + const out = await r.resolve( + { module: 'm.mjs', export: 'factory' }, + parseJsonNumber, + expectNumber, + ); + expect(out).toBe(123); + }); + + it('should let validateExport throw to reject bad export shapes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'refres-bad-')); + writeFileSync(join(dir, 'm.mjs'), 'export const value = "not a number";'); + const r = new RefResolver(new LoaderContext(dir), 'args'); + + await expect( + r.resolve( + { module: 'm.mjs', export: 'value' }, + parseJsonNumber, + expectNumber, + ), + ).rejects.toThrow(ConfigError); + }); + + it('should propagate ConfigError when the module path is unimportable', async () => { + const r = new RefResolver(new LoaderContext('/tmp'), 'args'); + await expect( + r.resolve( + { module: 'nope-zz.mjs', export: 'default' }, + parseJsonNumber, + expectNumber, + ), + ).rejects.toThrow(ConfigError); + }); +}); + +describe('RefResolver.resolve — invalid ref', () => { + it('should throw a ConfigError carrying the label for unknown ref shapes', async () => { + const r = new RefResolver(new LoaderContext('/tmp'), 'my-label'); + await expect( + r.resolve( + { unknown: 'thing' } as unknown as Parameters[0], + parseJsonNumber, + expectNumber, + ), + ).rejects.toThrow(/my-label/); + }); +}); diff --git a/packages/deployer/src/loaders/ref-resolver.ts b/packages/deployer/src/loaders/ref-resolver.ts new file mode 100644 index 0000000..3964a56 --- /dev/null +++ b/packages/deployer/src/loaders/ref-resolver.ts @@ -0,0 +1,44 @@ +import { + type FileOrModuleRef, + isFileRef, + isModuleRef, +} from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import type { LoaderContext } from './context.ts'; + +/** Resolve a `{ file }` / `{ module, export }` ref to a typed value via caller-supplied parse + validate callbacks. */ +export class RefResolver { + readonly #ctx: LoaderContext; + readonly #label: string; + + constructor(ctx: LoaderContext, label: string) { + this.#ctx = ctx; + this.#label = label; + } + + async resolve( + ref: FileOrModuleRef, + parseFile: (text: string, path: string) => T, + validateExport: (value: unknown, path: string, exportName: string) => T, + ): Promise { + if (isFileRef(ref)) { + const { text, path } = await this.#ctx.readText(ref.file, this.#label); + return parseFile(text, path); + } + if (isModuleRef(ref)) { + const { mod, path } = await this.#ctx.importModule( + ref.module, + this.#label, + ); + const exported = mod[ref.export]; + const resolved = + typeof exported === 'function' + ? await (exported as () => unknown)() + : exported; + return validateExport(resolved, path, ref.export); + } + throw new ConfigError( + `${this.#label}: must be { file } or { module, export }`, + ); + } +} diff --git a/packages/deployer/src/loaders/signing-key.test.ts b/packages/deployer/src/loaders/signing-key.test.ts new file mode 100644 index 0000000..c434a39 --- /dev/null +++ b/packages/deployer/src/loaders/signing-key.test.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ConfigError } from '../errors.ts'; +import { SigningKey } from './signing-key.ts'; + +const VALID = 'a'.repeat(64); + +describe('SigningKey', () => { + it('should read and lowercase a 32-byte hex key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sk-test-')); + writeFileSync(join(dir, 'sk'), `${VALID.toUpperCase()}\n`); + expect((await SigningKey.load(dir, 'sk')).hex).toBe(VALID); + }); + + it('should strip an optional 0x prefix', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sk-test-')); + writeFileSync(join(dir, 'sk'), `0x${VALID}\n`); + expect((await SigningKey.load(dir, 'sk')).hex).toBe(VALID); + }); + + it('should reject a wrong-length key', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sk-test-')); + writeFileSync(join(dir, 'sk'), 'abcd'); + await expect(SigningKey.load(dir, 'sk')).rejects.toThrow(ConfigError); + }); + + it('should reject a missing file', async () => { + await expect(SigningKey.load('/tmp', 'no-such-file')).rejects.toThrow( + ConfigError, + ); + }); +}); diff --git a/packages/deployer/src/loaders/signing-key.ts b/packages/deployer/src/loaders/signing-key.ts new file mode 100644 index 0000000..7a73710 --- /dev/null +++ b/packages/deployer/src/loaders/signing-key.ts @@ -0,0 +1,27 @@ +import { ConfigError } from '../errors.ts'; +import { LoaderContext } from './context.ts'; + +/** + * Maintenance-authority signing key. Canonical form: 64 lowercase hex + * chars, no `0x`. Fuzzy input is rejected so midnight-js can't silently + * auto-sample a key the user then can't recover. + */ +export class SigningKey { + readonly hex: string; + + private constructor(hex: string) { + this.hex = hex; + } + + static async load(rootDir: string, path: string): Promise { + const ctx = new LoaderContext(rootDir); + const { text, path: abs } = await ctx.readText(path, 'signing_key_file'); + const trimmed = text.trim().replace(/^0x/i, ''); + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new ConfigError( + `signing_key_file ${abs}: expected 32 bytes hex-encoded (64 hex chars)`, + ); + } + return new SigningKey(trimmed.toLowerCase()); + } +} diff --git a/packages/deployer/src/providers/build.test.ts b/packages/deployer/src/providers/build.test.ts new file mode 100644 index 0000000..eaa6a4c --- /dev/null +++ b/packages/deployer/src/providers/build.test.ts @@ -0,0 +1,241 @@ +import type { + EnvironmentConfiguration, + MidnightWalletProvider, +} from '@midnight-ntwrk/testkit-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ContractConfig } from '../config/schema.ts'; + +vi.mock('@midnight-ntwrk/midnight-js-http-client-proof-provider', () => ({ + httpClientProofProvider: vi.fn((url: string) => ({ kind: 'proof', url })), +})); + +vi.mock('@midnight-ntwrk/midnight-js-indexer-public-data-provider', () => ({ + indexerPublicDataProvider: vi.fn((indexer: string, ws: string) => ({ + kind: 'public', + indexer, + ws, + })), +})); + +vi.mock('@midnight-ntwrk/midnight-js-level-private-state-provider', () => ({ + levelPrivateStateProvider: vi.fn( + (opts: { privateStateStoreName: string; accountId: string }) => ({ + kind: 'private', + storeName: opts.privateStateStoreName, + accountId: opts.accountId, + }), + ), +})); + +vi.mock('@midnight-ntwrk/midnight-js-node-zk-config-provider', () => ({ + NodeZkConfigProvider: vi.fn(function NodeZkConfigProvider( + this: { kind: string; path: string }, + path: string, + ) { + this.kind = 'zk'; + this.path = path; + }), +})); + +const { buildProviders } = await import('./build.ts'); +const { httpClientProofProvider } = await import( + '@midnight-ntwrk/midnight-js-http-client-proof-provider' +); +const { indexerPublicDataProvider } = await import( + '@midnight-ntwrk/midnight-js-indexer-public-data-provider' +); +const { levelPrivateStateProvider } = await import( + '@midnight-ntwrk/midnight-js-level-private-state-provider' +); +const { NodeZkConfigProvider } = await import( + '@midnight-ntwrk/midnight-js-node-zk-config-provider' +); + +const env: EnvironmentConfiguration = { + walletNetworkId: 'testnet', + networkId: 'testnet', + indexer: 'https://indexer.example/api', + indexerWS: 'wss://indexer.example/ws', + node: 'https://node.example', + nodeWS: 'wss://node.example/ws', + proofServer: 'http://proof:6300', +} as EnvironmentConfiguration; + +const wallet = { + getEncryptionPublicKey: vi.fn(() => 'enc-pubkey-abc'), + getCoinPublicKey: vi.fn(() => 'coin-pubkey-def'), +} as unknown as MidnightWalletProvider; + +const baseContract: ContractConfig = { + artifact: 'src/artifacts/Counter', + signing_key_file: 'keys/counter.signing', +}; + +describe('buildProviders', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should default the private-state store name to -private-state', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; + expect(opts?.privateStateStoreName).toBe('Counter-private-state'); + }); + + it('should honor a contract-provided private_state_store_name', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: { ...baseContract, private_state_store_name: 'custom-store' }, + zkConfigPath: '/artifacts/Counter', + }); + + const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; + expect(opts?.privateStateStoreName).toBe('custom-store'); + }); + + it('should bind the private-state account to the wallet coin pubkey', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; + expect(opts?.accountId).toBe('coin-pubkey-def'); + }); + + it('should derive the private-state password from the wallet encryption pubkey', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + expect(wallet.getEncryptionPublicKey).toHaveBeenCalledOnce(); + }); + + it('should expose a privateStoragePasswordProvider that returns the derived password', async () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0] as { + privateStoragePasswordProvider: () => string | Promise; + }; + const pw = await opts.privateStoragePasswordProvider(); + expect(typeof pw).toBe('string'); + expect(pw.length).toBeGreaterThan(0); + }); + + it('should construct NodeZkConfigProvider with the zkConfigPath', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + expect(NodeZkConfigProvider).toHaveBeenCalledWith('/artifacts/Counter'); + }); + + it('should wire the indexer URLs into the public data provider', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + expect(indexerPublicDataProvider).toHaveBeenCalledWith( + env.indexer, + env.indexerWS, + ); + }); + + it('should wire the proof-server URL into the HTTP proof provider', () => { + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + const firstArg = vi.mocked(httpClientProofProvider).mock.calls[0]?.[0]; + expect(firstArg).toBe(env.proofServer); + }); + + it('should expose wallet as both walletProvider and midnightProvider', () => { + const providers = buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + expect(providers.walletProvider).toBe(wallet); + expect(providers.midnightProvider).toBe(wallet); + }); + + it('should pass through an injected privateStateProvider and skip the LevelDB construction', () => { + const injected = { + __injected: true, + } as unknown as Parameters< + typeof buildProviders + >[0]['privateStateProvider']; + + const providers = buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + privateStateProvider: injected, + }); + + expect(providers.privateStateProvider).toBe(injected); + expect(levelPrivateStateProvider).not.toHaveBeenCalled(); + expect(wallet.getEncryptionPublicKey).not.toHaveBeenCalled(); + }); + + it('should return all six provider slots', () => { + const providers = buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }); + + expect(Object.keys(providers).sort()).toEqual( + [ + 'privateStateProvider', + 'publicDataProvider', + 'zkConfigProvider', + 'proofProvider', + 'walletProvider', + 'midnightProvider', + ].sort(), + ); + }); +}); diff --git a/packages/deployer/src/providers/build.ts b/packages/deployer/src/providers/build.ts new file mode 100644 index 0000000..372f0f7 --- /dev/null +++ b/packages/deployer/src/providers/build.ts @@ -0,0 +1,62 @@ +import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider'; +import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; +import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider'; +import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider'; +import type { + MidnightProviders, + PrivateStateProvider, +} from '@midnight-ntwrk/midnight-js-types'; +import type { + EnvironmentConfiguration, + MidnightWalletProvider, +} from '@midnight-ntwrk/testkit-js'; +import type { ContractConfig } from '../config/schema.ts'; +import { derivePrivateStatePassword } from './private-state-password.ts'; + +export interface BuildProvidersOptions { + env: EnvironmentConfiguration; + wallet: MidnightWalletProvider; + contractName: string; + contract: ContractConfig; + zkConfigPath: string; + /** Inject `inMemoryPrivateStateProvider` in tests to avoid LevelDB file-lock contention. */ + privateStateProvider?: PrivateStateProvider; +} + +export function buildProviders({ + env, + wallet, + contractName, + contract, + zkConfigPath, + privateStateProvider, +}: BuildProvidersOptions): MidnightProviders { + const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath); + + const resolvedPrivateStateProvider: PrivateStateProvider = + privateStateProvider ?? + defaultLevelPrivateStateProvider(wallet, contract, contractName); + + return { + privateStateProvider: resolvedPrivateStateProvider, + publicDataProvider: indexerPublicDataProvider(env.indexer, env.indexerWS), + zkConfigProvider, + proofProvider: httpClientProofProvider(env.proofServer, zkConfigProvider), + walletProvider: wallet, + midnightProvider: wallet, + }; +} + +function defaultLevelPrivateStateProvider( + wallet: MidnightWalletProvider, + contract: ContractConfig, + contractName: string, +): PrivateStateProvider { + const password = derivePrivateStatePassword(wallet.getEncryptionPublicKey()); + return levelPrivateStateProvider({ + privateStateStoreName: + contract.private_state_store_name ?? `${contractName}-private-state`, + accountId: wallet.getCoinPublicKey(), + privateStoragePasswordProvider: () => password, + }); +} diff --git a/packages/deployer/src/providers/network.test.ts b/packages/deployer/src/providers/network.test.ts new file mode 100644 index 0000000..5c55d22 --- /dev/null +++ b/packages/deployer/src/providers/network.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NetworkConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import { applyNetwork } from './network.ts'; + +vi.mock('@midnight-ntwrk/midnight-js-network-id', () => ({ + setNetworkId: vi.fn(), +})); + +const { setNetworkId } = await import('@midnight-ntwrk/midnight-js-network-id'); + +const baseNetwork: NetworkConfig = { + network_id: 'testnet', + indexer: 'https://indexer.example/api', + indexer_ws: 'wss://indexer.example/ws', + node: 'https://node.example', + node_ws: 'wss://node.example/ws', +}; + +describe('applyNetwork', () => { + beforeEach(() => { + vi.mocked(setNetworkId).mockClear(); + }); + + it('should set the network id and assemble the environment for a known id', () => { + const { env } = applyNetwork(baseNetwork, 'http://proof-server:6300'); + + expect(setNetworkId).toHaveBeenCalledWith('testnet'); + expect(env.networkId).toBe('testnet'); + expect(env.indexer).toBe('https://indexer.example/api'); + expect(env.indexerWS).toBe('wss://indexer.example/ws'); + expect(env.node).toBe('https://node.example'); + expect(env.nodeWS).toBe('wss://node.example/ws'); + expect(env.proofServer).toBe('http://proof-server:6300'); + }); + + it.each([ + 'undeployed', + 'devnet', + 'qanet', + 'testnet', + 'preview', + 'preprod', + 'mainnet', + ])('should accept known network id %s', (id) => { + expect(() => + applyNetwork({ ...baseNetwork, network_id: id }, 'http://ps'), + ).not.toThrow(); + expect(setNetworkId).toHaveBeenLastCalledWith(id); + }); + + it('should reject an unknown network id with ConfigError', () => { + expect(() => + applyNetwork({ ...baseNetwork, network_id: 'bogus-net' }, 'http://ps'), + ).toThrow(ConfigError); + }); + + it('should not call setNetworkId when the id is unknown', () => { + try { + applyNetwork({ ...baseNetwork, network_id: 'bogus-net' }, 'http://ps'); + } catch { + /* expected */ + } + expect(setNetworkId).not.toHaveBeenCalled(); + }); + + it('should include the allowed-id list in the error message', () => { + expect(() => + applyNetwork({ ...baseNetwork, network_id: 'bogus' }, 'http://ps'), + ).toThrow(/expected one of:.*testnet/); + }); +}); diff --git a/packages/deployer/src/providers/network.ts b/packages/deployer/src/providers/network.ts new file mode 100644 index 0000000..95c3d8a --- /dev/null +++ b/packages/deployer/src/providers/network.ts @@ -0,0 +1,53 @@ +import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import type { EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js'; +import type { NetworkConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; + +/** + * Set the midnight-js network-id singleton + build an + * `EnvironmentConfiguration`. `KNOWN_NETWORK_IDS` is closed so a typo + * fails fast here instead of as a generic midnight-js error later. + */ + +const KNOWN_NETWORK_IDS: ReadonlySet = new Set([ + 'undeployed', + 'devnet', + 'qanet', + 'testnet', + 'preview', + 'preprod', + 'mainnet', +]); + +export interface ResolvedEnvironment { + env: EnvironmentConfiguration; +} + +export function applyNetwork( + network: NetworkConfig, + proofServerUrl: string, +): ResolvedEnvironment { + if (!KNOWN_NETWORK_IDS.has(network.network_id)) { + throw new ConfigError( + `Unknown network_id "${network.network_id}" (expected one of: ${[...KNOWN_NETWORK_IDS].join(', ')})`, + ); + } + setNetworkId(network.network_id); + + const env: EnvironmentConfiguration = { + walletNetworkId: + network.network_id as EnvironmentConfiguration['walletNetworkId'], + networkId: network.network_id, + indexer: network.indexer, + indexerWS: network.indexer_ws, + node: network.node, + nodeWS: network.node_ws, + proofServer: proofServerUrl, + // testkit-js requires this field even though our deploys never + // hit the faucet themselves. Set to undefined so dependent code + // paths (e.g. wait-for-funds hints) treat it as absent. + faucet: undefined, + }; + + return { env }; +} diff --git a/packages/deployer/src/providers/private-state-password.test.ts b/packages/deployer/src/providers/private-state-password.test.ts new file mode 100644 index 0000000..2582dda --- /dev/null +++ b/packages/deployer/src/providers/private-state-password.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { derivePrivateStatePassword } from './private-state-password.ts'; + +describe('derivePrivateStatePassword', () => { + it('should be deterministic for the same input', () => { + const a = derivePrivateStatePassword('abcdef1234567890'); + const b = derivePrivateStatePassword('abcdef1234567890'); + expect(a).toBe(b); + }); + + it('should differ for different inputs', () => { + const a = derivePrivateStatePassword('abcdef1234567890'); + const b = derivePrivateStatePassword('abcdef1234567891'); + expect(a).not.toBe(b); + }); + + it('should not contain 4 identical chars in a row', () => { + for (let i = 0; i < 200; i++) { + const pw = derivePrivateStatePassword(`pubkey-${i}`); + expect(pw).not.toMatch(/(.)\1{3,}/); + } + }); + + it('should produce a password with mixed character classes (uppercase + digit + symbol)', () => { + const pw = derivePrivateStatePassword('any input'); + expect(pw).toMatch(/[A-Z]/); + expect(pw).toMatch(/[0-9]/); + expect(pw).toMatch(/[^A-Za-z0-9]/); + }); + + it('should handle inputs that would have produced naïve-bad passwords', () => { + // A 64-zero hex (the kind of structured pubkey that breaks + // `${encKey}A!`-style derivations) must still produce a valid password. + const pw = derivePrivateStatePassword('0'.repeat(64)); + expect(pw).not.toMatch(/(.)\1{3,}/); + }); + + it('should throw after 1024 rounds when every hash has 4+ identical chars', async () => { + // Force every round to produce a string that hits the `(.)\1{3,}` guard so + // the loop exhausts its retry budget and reaches the explicit throw. + vi.resetModules(); + vi.doMock('node:crypto', async () => { + const actual = + await vi.importActual('node:crypto'); + return { + ...actual, + createHash: () => ({ + update: () => ({ + digest: () => 'aaaaBBBBccccDDDD', + }), + }), + }; + }); + const { derivePrivateStatePassword: derive } = await import( + './private-state-password.ts' + ); + expect(() => derive('anything')).toThrow(/unable to find a hash/); + vi.doUnmock('node:crypto'); + vi.resetModules(); + }); +}); diff --git a/packages/deployer/src/providers/private-state-password.ts b/packages/deployer/src/providers/private-state-password.ts new file mode 100644 index 0000000..98a890e --- /dev/null +++ b/packages/deployer/src/providers/private-state-password.ts @@ -0,0 +1,27 @@ +import { createHash } from 'node:crypto'; + +/** + * Derive a leveldb-compatible password from the wallet's encryption key. + * level-private-state-provider rejects passwords with 4+ identical chars + * in a row, which structured seeds (TEST_MNEMONIC, `0x…0001`) routinely + * produce. We SHA-256 + base64url + strip + rehash-on-collision until clean, + * then append `A1!` for guaranteed character-class diversity. + */ +export function derivePrivateStatePassword( + encryptionPublicKey: string, +): string { + for (let counter = 0; counter < 1024; counter++) { + const body = createHash('sha256') + .update(`${encryptionPublicKey}:${counter}`) + .digest('base64url') + .replace(/[^A-Za-z0-9]/g, ''); + if (!/(.)\1{3,}/.test(body)) { + return `${body}A1!`; + } + } + // Pathologically improbable. Surface explicitly so the deploy fails loud + // rather than silently retrying forever. + throw new Error( + 'derivePrivateStatePassword: unable to find a hash without 4+ repeated chars after 1024 rounds', + ); +} diff --git a/packages/deployer/src/providers/proof-server.test.ts b/packages/deployer/src/providers/proof-server.test.ts new file mode 100644 index 0000000..df28f0f --- /dev/null +++ b/packages/deployer/src/providers/proof-server.test.ts @@ -0,0 +1,191 @@ +import type { Logger } from 'pino'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NetworkConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; + +vi.mock('@midnight-ntwrk/testkit-js', () => ({ + DynamicProofServerContainer: { + start: vi.fn(async () => ({ + getUrl: () => 'http://dynamic-container:6300', + stop: vi.fn(async () => undefined), + })), + }, + StaticProofServerContainer: vi.fn(function StaticProofServerContainer( + this: { getUrl: () => string; stop: () => Promise }, + port: number, + ) { + this.getUrl = () => `http://127.0.0.1:${port}`; + this.stop = vi.fn(async () => undefined); + }), +})); + +const { DynamicProofServerContainer, StaticProofServerContainer } = + await import('@midnight-ntwrk/testkit-js'); +const { ProofServer } = await import('./proof-server.ts'); + +const makeLogger = (): Logger => { + const noop = vi.fn(); + return { + debug: noop, + info: noop, + warn: noop, + error: noop, + fatal: noop, + trace: noop, + } as unknown as Logger; +}; + +const baseNetwork: NetworkConfig = { + network_id: 'testnet', + indexer: 'https://indexer.example/api', + indexer_ws: 'wss://indexer.example/ws', + node: 'https://node.example', + node_ws: 'wss://node.example/ws', +}; + +describe('ProofServer.start — precedence chain', () => { + const originalPort = process.env.PROOF_SERVER_PORT; + + beforeEach(() => { + vi.mocked(DynamicProofServerContainer.start).mockClear(); + vi.mocked(StaticProofServerContainer).mockClear(); + delete process.env.PROOF_SERVER_PORT; + }); + + afterEach(() => { + if (originalPort === undefined) { + delete process.env.PROOF_SERVER_PORT; + } else { + process.env.PROOF_SERVER_PORT = originalPort; + } + }); + + it('(1) should use cliOverride above everything else', async () => { + process.env.PROOF_SERVER_PORT = '9999'; + const ps = await ProofServer.start({ + cliOverride: 'http://cli:6300', + network: { ...baseNetwork, proof_server: 'http://toml:6300' }, + logger: makeLogger(), + }); + + expect(ps.url).toBe('http://cli:6300'); + expect(DynamicProofServerContainer.start).not.toHaveBeenCalled(); + expect(StaticProofServerContainer).not.toHaveBeenCalled(); + }); + + it('(2) should use the TOML proof_server URL when no CLI override', async () => { + const ps = await ProofServer.start({ + network: { ...baseNetwork, proof_server: 'http://toml:6300' }, + logger: makeLogger(), + }); + expect(ps.url).toBe('http://toml:6300'); + expect(DynamicProofServerContainer.start).not.toHaveBeenCalled(); + }); + + it('(3) should boot a dynamic container when TOML proof_server = "auto"', async () => { + const ps = await ProofServer.start({ + network: { ...baseNetwork, proof_server: 'auto' }, + logger: makeLogger(), + }); + + expect(ps.url).toBe('http://dynamic-container:6300'); + expect(DynamicProofServerContainer.start).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(DynamicProofServerContainer.start).mock.calls[0]; + expect(callArgs?.[2]).toBe('testnet'); + }); + + it('(4) should use PROOF_SERVER_PORT when no explicit config', async () => { + process.env.PROOF_SERVER_PORT = '7777'; + const ps = await ProofServer.start({ + network: baseNetwork, + logger: makeLogger(), + }); + + expect(ps.url).toBe('http://127.0.0.1:7777'); + expect(StaticProofServerContainer).toHaveBeenCalledWith(7777); + }); + + it('(4) should throw ConfigError for a non-numeric PROOF_SERVER_PORT', async () => { + process.env.PROOF_SERVER_PORT = 'not-a-number'; + await expect( + ProofServer.start({ network: baseNetwork, logger: makeLogger() }), + ).rejects.toThrow(ConfigError); + }); + + it('(5) should fall back to http://127.0.0.1:6300 when nothing is configured', async () => { + const ps = await ProofServer.start({ + network: baseNetwork, + logger: makeLogger(), + }); + + expect(ps.url).toBe('http://127.0.0.1:6300'); + expect(DynamicProofServerContainer.start).not.toHaveBeenCalled(); + expect(StaticProofServerContainer).not.toHaveBeenCalled(); + }); + + it('should prefer cliOverride = "auto" over TOML URL (CLI wins)', async () => { + const ps = await ProofServer.start({ + cliOverride: 'http://cli-static', + network: { ...baseNetwork, proof_server: 'auto' }, + logger: makeLogger(), + }); + + expect(ps.url).toBe('http://cli-static'); + expect(DynamicProofServerContainer.start).not.toHaveBeenCalled(); + }); +}); + +describe('ProofServer — disposal', () => { + it('should be a no-op for static-URL instances', async () => { + const ps = await ProofServer.start({ + network: { ...baseNetwork, proof_server: 'http://static' }, + logger: makeLogger(), + }); + await expect(ps.dispose()).resolves.toBeUndefined(); + }); + + it('should stop the static container for the PROOF_SERVER_PORT path', async () => { + process.env.PROOF_SERVER_PORT = '7777'; + const ps = await ProofServer.start({ + network: baseNetwork, + logger: makeLogger(), + }); + const instance = vi.mocked(StaticProofServerContainer).mock.instances[0]; + await ps.dispose(); + expect(instance?.stop).toHaveBeenCalledOnce(); + }); + + it('should stop the underlying container for the "auto" path', async () => { + const stop = vi.fn(async () => undefined); + vi.mocked(DynamicProofServerContainer.start).mockResolvedValueOnce({ + getUrl: () => 'http://dyn', + stop, + } as never); + + const ps = await ProofServer.start({ + network: { ...baseNetwork, proof_server: 'auto' }, + logger: makeLogger(), + }); + await ps.dispose(); + expect(stop).toHaveBeenCalledOnce(); + }); + + it('Symbol.asyncDispose should swallow teardown errors via the warn log', async () => { + const stop = vi.fn(async () => { + throw new Error('boom'); + }); + vi.mocked(DynamicProofServerContainer.start).mockResolvedValueOnce({ + getUrl: () => 'http://dyn', + stop, + } as never); + + const logger = makeLogger(); + const ps = await ProofServer.start({ + network: { ...baseNetwork, proof_server: 'auto' }, + logger, + }); + + await expect(ps[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/deployer/src/providers/proof-server.ts b/packages/deployer/src/providers/proof-server.ts new file mode 100644 index 0000000..8c6a7d0 --- /dev/null +++ b/packages/deployer/src/providers/proof-server.ts @@ -0,0 +1,111 @@ +import { + DynamicProofServerContainer, + StaticProofServerContainer, +} from '@midnight-ntwrk/testkit-js'; +import type { Logger } from 'pino'; +import type { NetworkConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; + +export interface ProofServerOptions { + cliOverride?: string; + network: NetworkConfig; + logger: Logger; +} + +/** + * Proof-server handle with a resolved URL + lifecycle. Always acquired via + * {@link ProofServer.start}; {@link dispose} is a no-op for static URLs + * and a container-stop for the `auto` / `PROOF_SERVER_PORT` paths. + */ +export class ProofServer { + /** Resolved URL the proof provider POSTs to. */ + readonly url: string; + readonly #dispose: () => Promise; + readonly #logger: Logger; + + private constructor( + url: string, + dispose: () => Promise, + logger: Logger, + ) { + this.url = url; + this.#dispose = dispose; + this.#logger = logger; + } + + /** + * Resolve URL by precedence: `cliOverride` > TOML `proof_server` URL > + * `proof_server = "auto"` (boots container) > `PROOF_SERVER_PORT` env > + * `http://127.0.0.1:6300`. + */ + static async start(opts: ProofServerOptions): Promise { + const { cliOverride, network, logger } = opts; + const explicit = cliOverride ?? network.proof_server; + + if (explicit && explicit !== 'auto') { + logger.debug(`Using configured proof server: ${explicit}`); + return ProofServer.fromStaticUrl(explicit, logger); + } + + if (explicit === 'auto') { + logger.info('Starting proof-server container (auto)…'); + const container = await DynamicProofServerContainer.start( + logger, + undefined, + network.network_id, + ); + return new ProofServer( + container.getUrl(), + () => container.stop(), + logger, + ); + } + + const port = process.env.PROOF_SERVER_PORT; + if (port !== undefined) { + const parsed = Number.parseInt(port, 10); + if (Number.isNaN(parsed)) { + throw new ConfigError(`Invalid PROOF_SERVER_PORT: ${port}`); + } + logger.debug(`Using PROOF_SERVER_PORT=${parsed}`); + const container = new StaticProofServerContainer(parsed); + return new ProofServer( + container.getUrl(), + () => container.stop(), + logger, + ); + } + + logger.debug( + 'Falling back to default proof server at http://127.0.0.1:6300', + ); + return ProofServer.fromStaticUrl('http://127.0.0.1:6300', logger); + } + + private static fromStaticUrl(url: string, logger: Logger): ProofServer { + return new ProofServer( + url, + async () => { + /* no container to stop */ + }, + logger, + ); + } + + /** Release any underlying container. Idempotent for static-URL instances. */ + async dispose(): Promise { + return this.#dispose(); + } + + /** `await using` hook: swallows teardown errors so they don't mask the deploy's real error. */ + async [Symbol.asyncDispose](): Promise { + try { + await this.#dispose(); + } catch (e) { + this.#logger.warn( + { err: (e as Error).message }, + 'Proof server dispose failed', + ); + } + } +} diff --git a/packages/deployer/src/runDeploy.test.ts b/packages/deployer/src/runDeploy.test.ts new file mode 100644 index 0000000..8fa37ba --- /dev/null +++ b/packages/deployer/src/runDeploy.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as compactConfigModule from './config/compact-config.ts'; +import * as deployerModule from './deployer.ts'; +import { DeployError } from './errors.ts'; +import * as contractResolveModule from './loaders/contract-resolve.ts'; +import { constructorArgs, runDeploy } from './runDeploy.ts'; + +function fakeDeployResult(overrides: Record = {}) { + return { + contractName: 'X', + network: 'local', + address: '0xaddr', + txHash: '0xtx', + txId: 'tx-id', + blockHeight: 42, + signingKey: '0xsk', + deployer: '0xdep', + artifact: 'X', + deploymentsFile: '/tmp/local.json', + dryRun: false, + explorerUrl: '', + ...overrides, + }; +} + +function fakeDeployer( + opts: { dryRun?: () => unknown; deploy?: () => unknown } = {}, +) { + const deploy = opts.deploy ?? vi.fn(async () => fakeDeployResult()); + const dryRun = + opts.dryRun ?? vi.fn(async () => fakeDeployResult({ dryRun: true })); + return { + deploy, + dryRun, + [Symbol.asyncDispose]: vi.fn(async () => undefined), + }; +} + +let originalArgv: string[]; +let exitSpy: ReturnType; +let writeSpy: ReturnType; + +beforeEach(() => { + originalArgv = process.argv; + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as never); + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + process.argv = originalArgv; + vi.restoreAllMocks(); +}); + +describe('runDeploy', () => { + it('should call Deployer.prepare with merged opts (explicit > argv)', async () => { + process.argv = ['node', 'script.ts', '--network', 'preview', '--dry-run']; + const fakeDep = fakeDeployer(); + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDep as any); + + await runDeploy({ contract: 'X', network: 'local', args: [1, 2] }); + + const callArgs = prepare.mock.calls[0]?.[0]; + expect(callArgs?.contract).toBe('X'); + expect(callArgs?.network).toBe('local'); // explicit beats argv + expect(callArgs?.args).toEqual([1, 2]); + }); + + it('should pull --network and --dry-run from argv when opts omit them', async () => { + process.argv = ['node', 'script.ts', '--network', 'preview', '--dry-run']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X' }); + + expect((fakeDep.dryRun as any).mock.calls.length).toBe(1); + expect( + (deployerModule.Deployer.prepare as any).mock.calls[0][0].network, + ).toBe('preview'); + }); + + it('should convert --sync-timeout seconds to milliseconds', async () => { + process.argv = ['node', 'script.ts', '--sync-timeout', '120']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X' }); + + expect( + (deployerModule.Deployer.prepare as any).mock.calls[0][0].syncTimeoutMs, + ).toBe(120_000); + }); + + it('should reject a non-positive --sync-timeout', async () => { + process.argv = ['node', 'script.ts', '--sync-timeout', 'nope']; + await expect(runDeploy({ contract: 'X' })).rejects.toThrow( + /--sync-timeout requires a positive integer/, + ); + }); + + it('should thread --seed-cache-from-dust and --seed-cache-from-shielded to Deployer.prepare', async () => { + process.argv = [ + 'node', + 'script.ts', + '--seed-cache-from-dust', + '/path/to/dust.json', + '--seed-cache-from-shielded', + '/path/to/shielded.gz', + ]; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X' }); + + const callArgs = (deployerModule.Deployer.prepare as any).mock.calls[0][0]; + expect(callArgs.seedCacheDust).toBe('/path/to/dust.json'); + expect(callArgs.seedCacheShielded).toBe('/path/to/shielded.gz'); + }); + + it('should let explicit seedCacheFromDust opt beat the argv value', async () => { + process.argv = [ + 'node', + 'script.ts', + '--seed-cache-from-dust', + '/argv.json', + ]; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X', seedCacheFromDust: '/explicit.json' }); + + const callArgs = (deployerModule.Deployer.prepare as any).mock.calls[0][0]; + expect(callArgs.seedCacheDust).toBe('/explicit.json'); + }); + + it('should reject --seed-cache-from-dust with no value', async () => { + process.argv = ['node', 'script.ts', '--seed-cache-from-dust']; + await expect(runDeploy({ contract: 'X' })).rejects.toThrow( + /--seed-cache-from-dust requires a value/, + ); + }); + + it('should emit JSON on stdout in --json mode on success', async () => { + process.argv = ['node', 'script.ts', '--json']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X' }); + + expect(writeSpy).toHaveBeenCalled(); + const written = writeSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(written); + expect(parsed.contractName).toBe('X'); + expect(parsed.address).toBe('0xaddr'); + }); + + it('should emit JSON error + exit with DeployError.exitCode in --json mode', async () => { + process.argv = ['node', 'script.ts', '--json']; + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( + new DeployError('boom', 3), + ); + + await expect(runDeploy({ contract: 'X' })).rejects.toThrow( + 'process.exit(3)', + ); + + const written = writeSpy.mock.calls[0]?.[0] as string; + const parsed = JSON.parse(written); + expect(parsed.error).toBe('DeployError'); + expect(parsed.message).toBe('boom'); + expect(parsed.exitCode).toBe(3); + }); + + it('should exit with code 1 on non-DeployError', async () => { + process.argv = ['node', 'script.ts']; + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( + new Error('generic'), + ); + + await expect(runDeploy({ contract: 'X' })).rejects.toThrow( + 'process.exit(1)', + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should pass constructorArgs(Contract, ...) tuple through to Deployer.prepare', async () => { + process.argv = ['node', 'script.ts']; + const fakeDep = fakeDeployer(); + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDep as never); + + class FakeContract { + initialState(_ctx: never, _a: string, _b: bigint) { + return undefined as never; + } + } + + await runDeploy({ + contract: 'X', + args: constructorArgs(FakeContract as never, 'hello', 42n), + }); + + expect(prepare.mock.calls[0]?.[0]?.args).toEqual(['hello', 42n]); + }); + + it('should forward a named-object args record to Deployer.prepare untouched', async () => { + process.argv = ['node', 'script.ts']; + const fakeDep = fakeDeployer(); + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDep as never); + + interface MyArgs { + foo: string; + bar: bigint; + } + + await runDeploy({ + contract: 'X', + args: { foo: 'hello', bar: 42n }, + }); + + // The reorder happens inside ConstructorArgs.load against the + // artifact's index.d.ts; runDeploy just forwards the object as-is. + expect(prepare.mock.calls[0]?.[0]?.args).toEqual({ + foo: 'hello', + bar: 42n, + }); + }); + + it('curried form: should resolve Contract → name and forward args positionally', async () => { + process.argv = ['node', 'script.ts']; + const fakeDep = fakeDeployer(); + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDep as never); + vi.spyOn(compactConfigModule.CompactConfig, 'load').mockResolvedValue({ + rootDir: '/tmp', + } as never); + vi.spyOn(contractResolveModule, 'resolveContractName').mockResolvedValue( + 'TokenExample', + ); + + class FakeContract { + initialState(_ctx: never, _a: string, _b: bigint) { + return undefined as never; + } + } + + await runDeploy(FakeContract as never)('hello', 42n); + + expect(prepare.mock.calls[0]?.[0]?.contract).toBe('TokenExample'); + expect(prepare.mock.calls[0]?.[0]?.args).toEqual(['hello', 42n]); + }); + + it('curried form: should merge extra opts (2nd arg) into Deployer.prepare', async () => { + process.argv = ['node', 'script.ts']; + const fakeDep = fakeDeployer(); + const prepare = vi + .spyOn(deployerModule.Deployer, 'prepare') + .mockResolvedValue(fakeDep as never); + vi.spyOn(compactConfigModule.CompactConfig, 'load').mockResolvedValue({ + rootDir: '/tmp', + } as never); + vi.spyOn(contractResolveModule, 'resolveContractName').mockResolvedValue( + 'TokenExample', + ); + + class FakeContract { + initialState(_ctx: never, _a: string) { + return undefined as never; + } + } + + await runDeploy(FakeContract as never, { network: 'preview' })('hello'); + + expect(prepare.mock.calls[0]?.[0]?.network).toBe('preview'); + }); + + it('should print dryRun success line in non-JSON mode', async () => { + process.argv = ['node', 'script.ts', '--dry-run']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + await runDeploy({ + contract: 'X', + logger: logger as never, + }); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/^Dry-run for /), + ); + }); + + it('should print the explorer URL line when the result carries one', async () => { + process.argv = ['node', 'script.ts']; + const deploy = vi.fn(async () => + fakeDeployResult({ explorerUrl: 'https://explorer/contracts/0xaddr' }), + ); + const fakeDep = fakeDeployer({ deploy }); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + await runDeploy({ contract: 'X', logger: logger as never }); + + const explorerCall = logger.info.mock.calls.find((c) => + String(c[0]).includes('explorer:'), + ); + expect(explorerCall).toBeDefined(); + }); + + it('should log the stack trace in verbose mode when an Error throws', async () => { + process.argv = ['node', 'script.ts', '--verbose']; + const err = new Error('boom'); + err.stack = 'Error: boom\n at trace'; + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue(err); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + await expect( + runDeploy({ contract: 'X', logger: logger as never }), + ).rejects.toThrow('process.exit(1)'); + + expect(logger.debug).toHaveBeenCalledWith('Error: boom\n at trace'); + }); +}); diff --git a/packages/deployer/src/runDeploy.ts b/packages/deployer/src/runDeploy.ts new file mode 100644 index 0000000..da4210c --- /dev/null +++ b/packages/deployer/src/runDeploy.ts @@ -0,0 +1,405 @@ +import { type Logger, pino } from 'pino'; +import { CompactConfig } from './config/compact-config.ts'; +import { Deployer, type DeployResult } from './deployer.ts'; +import { DeployError } from './errors.ts'; +import { resolveContractName } from './loaders/contract-resolve.ts'; + +/** + * Shape every Compact-compiled `Contract` class satisfies. The + * compiler emits an `initialState(context, ...args)` method whose tail + * args mirror the constructor signature; {@link ConstructorArgsOf} + * lifts those args out of the artifact's `.d.ts` so deploy scripts + * don't have to redeclare them. + */ +export type CompactContractClass = { + prototype: { initialState(context: never, ...args: never[]): unknown }; +}; + +/** + * Extracts the constructor args tuple from a Compact-compiled + * `Contract` class (strips the leading `context` parameter). The + * result is a labeled tuple — hover on a value shows the param name + * and type. No codegen needed; the artifact's `.d.ts` is the source + * of truth. + * + * @example + * ```ts + * import type { Contract } from '../artifacts/Token/contract/index.js'; + * import { runDeploy, type ConstructorArgsOf } from '@openzeppelin/compact-deployer'; + * + * await runDeploy>({ + * contract: 'Token', + * args: ['OZE', 'OZE', 18n, ... ], + * }); + * ``` + */ +export type ConstructorArgsOf = + C['prototype']['initialState'] extends ( + context: never, + ...args: infer A + ) => unknown + ? A + : never; + +/** + * Typed constructor-args helper. Returns the args tuple unchanged at + * runtime; the work happens in the type system: `Contract` anchors + * the generic so each subsequent arg is checked against the matching + * position in `initialState`, and the editor shows the param name + + * type as you type each comma (TypeScript signature help works on + * function calls but not on tuple literals — that's why this exists + * instead of just `args: [...]`). + * + * @example + * ```ts + * import { runDeploy, constructorArgs } from '@openzeppelin/compact-deployer'; + * import { Contract } from '../artifacts/Token/contract/index.js'; + * + * await runDeploy({ + * contract: 'Token', + * args: constructorArgs(Contract, + * 'OpenZeppelin Token', // _name: string + * 'OZE', // _symbol: string + * 18n, // _decimals: bigint + * ), + * }); + * ``` + */ +export function constructorArgs( + _Contract: C, + ...args: ConstructorArgsOf +): ConstructorArgsOf { + return args; +} + +/** + * Inputs to {@link runDeploy}. Every field has an argv default so a + * hand-written deploy script can be as short as + * `await runDeploy>({ contract: 'X', args: [ … ] });` + * (typed tuple form) or + * `await runDeploy({ contract: 'X', args: { … } });` + * (named-object form — `MyNamedArgs` is hand-written or generated + * separately), or `await runDeploy({ contract: 'X' });` when args + * live in `compact.toml`. + * + * The `Args` generic is either: + * - a `readonly unknown[]` tuple (use `ConstructorArgsOf` + * to get the labeled tuple from the compiled artifact), or + * - a `Record` object describing the constructor's + * named parameters; keys can appear in any order and the deployer + * reorders them via the artifact's `index.d.ts`. + */ +export interface RunDeployOptions< + Args extends readonly unknown[] | Record = + | readonly unknown[] + | Record, +> { + /** Contract name in `compact.toml` (required). */ + contract: string; + /** + * Constructor args, either as a positional tuple or a named object. + * Highest precedence: overrides `compact.toml`'s `args` field. + */ + args?: Args; + /** Path to `compact.toml`. Argv: `--config`. Default: walk up from cwd. */ + configPath?: string; + /** Network name. Argv: `--network`. Default: `[profile].default_network` from `compact.toml`. */ + network?: string; + /** Seed file override. Argv: `--seed-file`. */ + seedFile?: string; + /** Proof-server URL override. Argv: `--proof-server`. */ + proofServer?: string; + /** Wallet-sync timeout in seconds. Argv: `--sync-timeout`. */ + syncTimeoutSec?: number; + /** Skip the on-disk wallet-state cache. Argv: `--no-cache`. */ + skipWalletCache?: boolean; + /** + * Import a pre-warmed dust wallet state file (raw JSON or gzipped) + * into `.states/` before deploy. Use this when first-run preprod + * cold sync OOMs and you already have a `serializeState()` output. + * Argv: `--seed-cache-from-dust`. + */ + seedCacheFromDust?: string; + /** Like {@link seedCacheFromDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ + seedCacheFromShielded?: string; + /** Dry-run mode (skip on-chain submission). Argv: `--dry-run`. */ + dryRun?: boolean; + /** Emit a machine-readable JSON object on stdout instead of pretty lines. Argv: `--json`. */ + json?: boolean; + /** Pino debug logs to stderr. Argv: `-v` / `--verbose`. */ + verbose?: boolean; + /** Override the auto-built logger (for testing or for embedding in larger apps). */ + logger?: Logger; + /** Keystore passphrase prompt. Only needed when `[wallet].keystore` is set. */ + promptPassphrase?: (path: string) => Promise; +} + +/** + * High-level deploy entrypoint for hand-written deploy scripts. + * + * Two call forms: + * + * 1. **Curried with Contract** — single source of truth for the + * contract identity, and the constructor args are typed function + * parameters (per-comma signature help in the editor): + * ```ts + * await runDeploy(Contract)('OZE', 'OZE', 18n, …); + * ``` + * The deployer matches `Contract` against `[contracts.X]` entries + * in `compact.toml` by class identity to pick the config. Extra + * deploy opts can be passed as a second arg: + * ```ts + * await runDeploy(Contract, { network: 'preview' })('OZE', …); + * ``` + * + * 2. **Options object** (legacy / multi-contract / TOML-driven args): + * ```ts + * await runDeploy({ contract: 'TokenExample', args: [...] }); + * ``` + * + * Parses `--network`, `--dry-run`, `--sync-timeout`, `--no-cache`, + * `--seed-cache-from-dust`, `--seed-cache-from-shielded`, + * `--seed-file`, `--proof-server`, `--config`, `--json`, `-v` / + * `--verbose` from `process.argv` as defaults. Explicit options win + * over argv. Wires a pino logger, runs Deployer.prepare + deploy or + * dryRun, prints the result, and exits with the typed exit code on + * error. + * + * Returns the result on success so callers can chain post-deploy work + * (e.g. seeding state via callTx). On error: logs and calls + * `process.exit` — never returns to the caller. + */ +export function runDeploy( + Contract: C, + opts?: Omit, +): (...args: ConstructorArgsOf) => Promise; +export function runDeploy< + Args extends readonly unknown[] | Record = + | readonly unknown[] + | Record, +>(opts: RunDeployOptions): Promise; +export function runDeploy( + arg: CompactContractClass | RunDeployOptions, + opts2?: Omit, +): Promise | ((...args: unknown[]) => Promise) { + if (isContractClass(arg)) { + const Contract = arg; + return (...args: unknown[]) => + runDeployImpl( + { + ...(opts2 ?? {}), + contract: '', + args, + }, + Contract, + ); + } + return runDeployImpl(arg); +} + +function isContractClass(x: unknown): x is CompactContractClass { + return ( + typeof x === 'function' && + typeof (x as { prototype?: { initialState?: unknown } }).prototype + ?.initialState === 'function' + ); +} + +async function runDeployImpl( + opts: RunDeployOptions, + Contract?: CompactContractClass, +): Promise { + const argv = parseArgv(process.argv.slice(2)); + const json = opts.json ?? argv.json; + const verbose = opts.verbose ?? argv.verbose; + const dryRun = opts.dryRun ?? argv.dryRun; + const syncTimeoutSec = opts.syncTimeoutSec ?? argv.syncTimeoutSec; + const logger = opts.logger ?? buildLogger({ json, verbose }); + const configPath = opts.configPath ?? argv.configPath; + + try { + // Curried form: resolve the Contract class to the matching + // [contracts.X] entry in compact.toml by identity. + let contractName = opts.contract; + if (Contract) { + const config = await CompactConfig.load(configPath); + contractName = await resolveContractName( + Contract, + config, + config.rootDir, + ); + } + await using deployer = await Deployer.prepare({ + contract: contractName, + network: opts.network ?? argv.network, + configPath, + seedFile: opts.seedFile ?? argv.seedFile, + proofServer: opts.proofServer ?? argv.proofServer, + args: opts.args, + syncTimeoutMs: + syncTimeoutSec !== undefined ? syncTimeoutSec * 1000 : undefined, + skipWalletCache: opts.skipWalletCache ?? argv.noCache, + seedCacheDust: opts.seedCacheFromDust ?? argv.seedCacheFromDust, + seedCacheShielded: + opts.seedCacheFromShielded ?? argv.seedCacheFromShielded, + promptPassphrase: opts.promptPassphrase, + logger, + }); + + const result = dryRun ? await deployer.dryRun() : await deployer.deploy(); + printResult(result, { json, logger }); + return result; + } catch (e) { + handleError(e, { json, verbose, logger }); + throw e; // unreachable — handleError calls process.exit + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface ParsedArgv { + network?: string; + configPath?: string; + seedFile?: string; + proofServer?: string; + syncTimeoutSec?: number; + seedCacheFromDust?: string; + seedCacheFromShielded?: string; + noCache: boolean; + dryRun: boolean; + json: boolean; + verbose: boolean; +} + +function parseArgv(argv: string[]): ParsedArgv { + const out: ParsedArgv = { + noCache: false, + dryRun: false, + json: false, + verbose: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case '-v': + case '--verbose': + out.verbose = true; + break; + case '--json': + out.json = true; + break; + case '--dry-run': + out.dryRun = true; + break; + case '--no-cache': + out.noCache = true; + break; + case '--seed-cache-from-dust': + out.seedCacheFromDust = expectValue( + argv, + ++i, + '--seed-cache-from-dust', + ); + break; + case '--seed-cache-from-shielded': + out.seedCacheFromShielded = expectValue( + argv, + ++i, + '--seed-cache-from-shielded', + ); + break; + case '--network': + out.network = expectValue(argv, ++i, '--network'); + break; + case '--config': + out.configPath = expectValue(argv, ++i, '--config'); + break; + case '--seed-file': + out.seedFile = expectValue(argv, ++i, '--seed-file'); + break; + case '--proof-server': + out.proofServer = expectValue(argv, ++i, '--proof-server'); + break; + case '--sync-timeout': { + const raw = expectValue(argv, ++i, '--sync-timeout'); + const seconds = Number.parseInt(raw, 10); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error( + `--sync-timeout requires a positive integer (seconds); got "${raw}"`, + ); + } + out.syncTimeoutSec = seconds; + break; + } + default: + // Unknown flags are ignored so the helper coexists with extra + // argv the caller's wrapper script may inject. + break; + } + } + return out; +} + +function expectValue(argv: string[], i: number, flag: string): string { + const v = argv[i]; + if (v === undefined || v.startsWith('-')) { + throw new Error(`${flag} requires a value`); + } + return v; +} + +function buildLogger(opts: { json: boolean; verbose: boolean }): Logger { + // JSON mode keeps stdout machine-parseable, so logger writes to stderr. + // Default mode goes to stdout with the standard pino-pretty fallback. + return pino({ + level: opts.verbose ? 'debug' : 'info', + }); +} + +function printResult( + result: DeployResult, + opts: { json: boolean; logger: Logger }, +): void { + if (opts.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (result.dryRun) { + opts.logger.info( + `Dry-run for ${result.contractName} on ${result.network} OK`, + ); + return; + } + opts.logger.info( + `${result.contractName} deployed on ${result.network}: ${result.address}`, + ); + opts.logger.info(` txId: ${result.txId}`); + opts.logger.info(` txHash: ${result.txHash}`); + opts.logger.info(` blockHeight: ${result.blockHeight}`); + opts.logger.info(` saved to: ${result.deploymentsFile}`); + if (result.explorerUrl) { + opts.logger.info(` explorer: ${result.explorerUrl}`); + } +} + +function handleError( + e: unknown, + opts: { json: boolean; verbose: boolean; logger: Logger }, +): never { + const code = e instanceof DeployError ? e.exitCode : 1; + const name = e instanceof Error ? e.name : 'Error'; + const message = e instanceof Error ? e.message : String(e); + if (opts.json) { + process.stdout.write( + `${JSON.stringify({ error: name, message, exitCode: code })}\n`, + ); + } else { + opts.logger.error({ err: message }, `Deploy failed: ${name}`); + if (opts.verbose && e instanceof Error && e.stack) { + opts.logger.debug(e.stack); + } + } + process.exit(code); +} diff --git a/packages/deployer/src/wallet/handler.test.ts b/packages/deployer/src/wallet/handler.test.ts new file mode 100644 index 0000000..d11fe1e --- /dev/null +++ b/packages/deployer/src/wallet/handler.test.ts @@ -0,0 +1,536 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import type { + EnvironmentConfiguration, + MidnightWalletProvider, +} from '@midnight-ntwrk/testkit-js'; +import { + DEFAULT_DUST_OPTIONS, + FluentWalletBuilder, + MidnightWalletProvider as MidnightWalletProviderClass, + WalletFactory, + WalletSaveStateProvider, + WalletSeeds, +} from '@midnight-ntwrk/testkit-js'; +import { createKeystore } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; +import type { Logger } from 'pino'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type Mock, + vi, +} from 'vitest'; +import { WalletHandler } from './handler.ts'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.alloc(0)), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + copyFileSync: vi.fn(), + mkdirSync: vi.fn(), + }; +}); + +vi.mock('@midnight-ntwrk/testkit-js', () => ({ + DEFAULT_DUST_OPTIONS: { additionalFeeOverhead: 1000n }, + DEFAULT_WALLET_STATE_DIRECTORY: './.states', + FluentWalletBuilder: { forEnvironment: vi.fn() }, + MidnightWalletProvider: { withWallet: vi.fn() }, + WalletFactory: { + createShieldedWallet: vi.fn(() => ({ tag: 'shielded-fresh' })), + createUnshieldedWallet: vi.fn(() => ({ tag: 'unshielded' })), + createDustWallet: vi.fn(() => ({ tag: 'dust' })), + createWalletFacade: vi.fn(async () => ({ tag: 'wallet-facade' })), + restoreShieldedWallet: vi.fn(async () => ({ tag: 'shielded-restored' })), + }, + WalletSaveStateProvider: vi.fn(), + WalletSeeds: { + fromMnemonic: vi.fn(() => ({ + shielded: new Uint8Array(32).fill(0x11), + unshielded: new Uint8Array(32).fill(0x22), + dust: new Uint8Array(32).fill(0x33), + })), + fromMasterSeed: vi.fn(() => ({ + shielded: new Uint8Array(32).fill(0x44), + unshielded: new Uint8Array(32).fill(0x55), + dust: new Uint8Array(32).fill(0x66), + })), + }, +})); + +vi.mock('@midnight-ntwrk/wallet-sdk-unshielded-wallet', () => ({ + createKeystore: vi.fn(() => ({ tag: 'keystore' })), +})); + +vi.mock('@midnight-ntwrk/ledger-v8', () => ({ + ZswapSecretKeys: { fromSeed: vi.fn(() => ({ tag: 'zswap-keys' })) }, + DustSecretKey: { fromSeed: vi.fn(() => ({ tag: 'dust-key' })) }, +})); + +interface FakeProvider { + stop: Mock; + wallet: { shielded: { tag: string } }; +} + +function fakeProvider(opts: { failsOnStop?: boolean } = {}): FakeProvider { + return { + wallet: { shielded: { tag: 'shielded-on-provider' } }, + stop: vi.fn( + opts.failsOnStop + ? async () => { + throw new Error('boom'); + } + : async () => undefined, + ), + }; +} + +interface BuilderChain { + envBuilder: { withDustOptions: Mock; config: unknown }; +} + +function wireTestkitChain(provider: FakeProvider): BuilderChain { + const envBuilder = { + withDustOptions: vi.fn(() => envBuilder), + config: { tag: 'config' }, + }; + vi.mocked(FluentWalletBuilder.forEnvironment).mockReturnValue( + envBuilder as unknown as ReturnType< + typeof FluentWalletBuilder.forEnvironment + >, + ); + vi.mocked(MidnightWalletProviderClass.withWallet).mockResolvedValue( + provider as unknown as MidnightWalletProvider, + ); + return { envBuilder }; +} + +/** Pino-shaped logger whose methods are spies, freshly built per test. */ +function spyLogger(): Logger { + const logger: Record = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + level: 'silent', + }; + logger.child = (): Logger => spyLogger(); + return logger as unknown as Logger; +} + +function fakeEnv( + walletNetworkId: EnvironmentConfiguration['walletNetworkId'] = 'testnet', +): EnvironmentConfiguration { + return { walletNetworkId } as unknown as EnvironmentConfiguration; +} + +describe('WalletHandler', () => { + let logger: Logger; + + beforeEach(() => { + logger = spyLogger(); + vi.mocked(existsSync).mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('seed routing', () => { + it('should route a mnemonic seed through WalletSeeds.fromMnemonic', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'mnemonic', + value: 'abandon abandon abandon', + }); + expect(WalletSeeds.fromMnemonic).toHaveBeenCalledWith( + 'abandon abandon abandon', + ); + expect(WalletSeeds.fromMasterSeed).not.toHaveBeenCalled(); + }); + + it('should route a hex seed through WalletSeeds.fromMasterSeed', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: 'aa'.repeat(32), + }); + expect(WalletSeeds.fromMasterSeed).toHaveBeenCalledWith('aa'.repeat(32)); + expect(WalletSeeds.fromMnemonic).not.toHaveBeenCalled(); + }); + }); + + describe('dust overhead', () => { + it('should override additionalFeeOverhead to a smaller value on non-mainnet networks', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv('preview'), { + kind: 'hex', + value: '00', + }); + // testkit's 5e20 default exceeds a typical preview/preprod wallet's + // dust balance, breaking fee balance. We tune down to 5e14. + expect(WalletFactory.createDustWallet).toHaveBeenCalledWith( + expect.anything(), + expect.any(Uint8Array), + expect.objectContaining({ + additionalFeeOverhead: 500_000_000_000_000n, + }), + ); + }); + + it('should keep the testkit default additionalFeeOverhead on mainnet', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv('mainnet'), { + kind: 'hex', + value: '00', + }); + expect(WalletFactory.createDustWallet).toHaveBeenCalledWith( + expect.anything(), + expect.any(Uint8Array), + expect.objectContaining({ + additionalFeeOverhead: DEFAULT_DUST_OPTIONS.additionalFeeOverhead, + }), + ); + }); + }); + + describe('provider wiring', () => { + it('should expose the wallet built by MidnightWalletProvider.withWallet via .provider', async () => { + const provider = fakeProvider(); + wireTestkitChain(provider); + const handler = await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + expect(handler.provider).toBe(provider); + }); + + it('should pass the createWalletFacade output to MidnightWalletProvider.withWallet', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + // The 3rd positional arg to withWallet is the WalletFacade. + const args = vi.mocked(MidnightWalletProviderClass.withWallet).mock + .calls[0]; + expect(args?.[2]).toEqual({ tag: 'wallet-facade' }); + }); + + it('should derive the unshielded keystore from the seed bytes and network id', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv('testnet'), { + kind: 'hex', + value: '00', + }); + expect(createKeystore).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'testnet', + ); + }); + }); + + describe('wallet-state cache', () => { + it('should build the shielded sub-wallet fresh when no cache file exists', async () => { + vi.mocked(existsSync).mockReturnValue(false); + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); + expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); + }); + + it('should restore the shielded sub-wallet from cache when the file exists', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(WalletSaveStateProvider).mockImplementation(function ( + this: object, + ) { + Object.assign(this, { + load: vi.fn(async () => 'serialized-state'), + save: vi.fn(async () => undefined), + }); + } as unknown as new ( + ...args: unknown[] + ) => InstanceType); + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + expect(WalletFactory.restoreShieldedWallet).toHaveBeenCalledWith( + expect.anything(), + 'serialized-state', + ); + expect(WalletFactory.createShieldedWallet).not.toHaveBeenCalled(); + }); + + it('should skip the cache entirely when skipWalletCache is true', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: '00' }, + { skipWalletCache: true }, + ); + expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); + }); + + it('should fall back to a fresh build when restore throws', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(WalletSaveStateProvider).mockImplementation(function ( + this: object, + ) { + Object.assign(this, { + load: vi.fn(async () => { + throw new Error('corrupt'); + }), + save: vi.fn(async () => undefined), + }); + } as unknown as new ( + ...args: unknown[] + ) => InstanceType); + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'corrupt' }), + expect.stringContaining('Wallet cache restore failed'), + ); + }); + + it('should swallow save() failures with a warn log on saveCache()', async () => { + vi.mocked(WalletSaveStateProvider).mockImplementation(function ( + this: object, + ) { + Object.assign(this, { + load: vi.fn(), + save: vi.fn(async () => { + throw new Error('disk-full'); + }), + }); + } as unknown as new ( + ...args: unknown[] + ) => InstanceType); + wireTestkitChain(fakeProvider()); + const handler = await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + await expect(handler.saveCache()).resolves.toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'disk-full' }), + 'Wallet sub-wallet cache save failed; continuing', + ); + }); + + it('should save the shielded sub-wallet through WalletSaveStateProvider on saveCache()', async () => { + const save = vi.fn(async () => undefined); + vi.mocked(WalletSaveStateProvider).mockImplementation(function ( + this: object, + ) { + Object.assign(this, { load: vi.fn(), save }); + } as unknown as new ( + ...args: unknown[] + ) => InstanceType); + const provider = fakeProvider(); + wireTestkitChain(provider); + const handler = await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + await handler.saveCache(); + expect(save).toHaveBeenCalledWith(provider.wallet.shielded); + }); + }); + + describe('seed cache import', () => { + it('should import a raw-JSON dust source by gzipping into the seed-derived path', async () => { + const raw = Buffer.from('{"state":"raw-json"}', 'utf8'); + vi.mocked(readFileSync).mockReturnValue(raw); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv('preview'), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/path/to/state.json' }, + ); + // Atomic write: payload lands in `.tmp`, then rename to + // ``. Asserts both halves so a future regression that + // skips the rename (or writes directly to target) fails loudly. + expect(writeFileSync).toHaveBeenCalled(); + const [tempPath, payload] = vi.mocked(writeFileSync).mock.calls[0] ?? []; + expect(String(tempPath)).toMatch(/preview-[0-9a-f]{16}-dust\.gz\.tmp$/); + // Payload was raw → must be gzipped on the way in (magic bytes). + const payloadBuf = payload as Buffer; + expect(payloadBuf[0]).toBe(0x1f); + expect(payloadBuf[1]).toBe(0x8b); + // rename(2) is atomic on POSIX within the same filesystem. + const [renameFrom, renameTo] = vi.mocked(renameSync).mock.calls[0] ?? []; + expect(String(renameFrom)).toBe(String(tempPath)); + expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-dust\.gz$/); + }); + + it('should pass a gzipped dust source through unchanged (no double-gzip)', async () => { + const gzipped = gzipSync(Buffer.from('{"state":"raw-json"}', 'utf8')); + vi.mocked(readFileSync).mockReturnValue(gzipped); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv('preview'), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/path/to/state.gz' }, + ); + const payload = vi.mocked(writeFileSync).mock.calls[0]?.[1]; + expect(payload).toEqual(gzipped); + }); + + it('should ensure the .states/ directory exists before writing', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/state.json' }, + ); + expect(mkdirSync).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ recursive: true }), + ); + }); + + it('should throw WalletError when the source file is missing', async () => { + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw new Error('ENOENT: no such file'); + }); + wireTestkitChain(fakeProvider()); + await expect( + WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/missing.json' }, + ), + ).rejects.toThrow(/--seed-cache-from-dust:.*missing\.json/); + }); + + it('should back up an existing target cache to .bak before overwriting', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + vi.mocked(existsSync).mockReturnValue(true); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/state.json' }, + ); + // copyFileSync MUST be called with (target, target.bak) so the + // previous cache bytes are preserved forever. If this assertion + // breaks, the safety net we promised the user is gone. + expect(copyFileSync).toHaveBeenCalledTimes(1); + const [src, dest] = vi.mocked(copyFileSync).mock.calls[0] ?? []; + expect(String(src)).toMatch(/-dust\.gz$/); + expect(String(dest)).toMatch(/-dust\.gz\.bak$/); + expect(String(dest)).toBe(`${String(src)}.bak`); + const sawBackupLog = vi + .mocked(logger.info) + .mock.calls.some((c) => + String(c[0]).includes('previous cache backed up to'), + ); + expect(sawBackupLog).toBe(true); + }); + + it('should NOT create a .bak when the target cache does not already exist', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + vi.mocked(existsSync).mockReturnValue(false); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/state.json' }, + ); + expect(copyFileSync).not.toHaveBeenCalled(); + }); + + it('should warn and skip the import when --no-cache is also set', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { skipWalletCache: true, seedCacheDust: '/state.json' }, + ); + expect(writeFileSync).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--seed-cache-from-*'), + ); + }); + + it('should import a shielded source into the matching -shielded.gz path', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv('preview'), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheShielded: '/state.json' }, + ); + // Final atomic rename lands on `-shielded.gz`; the intermediate + // tmp write goes to `-shielded.gz.tmp`. + const renameTo = vi.mocked(renameSync).mock.calls[0]?.[1]; + expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-shielded\.gz$/); + }); + }); + + describe('dispose', () => { + it('should stop the underlying wallet on Symbol.asyncDispose', async () => { + const provider = fakeProvider(); + wireTestkitChain(provider); + const handler = await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + await handler[Symbol.asyncDispose](); + expect(provider.stop).toHaveBeenCalledTimes(1); + }); + + it('should swallow stop() failures with a warn log on Symbol.asyncDispose', async () => { + const provider = fakeProvider({ failsOnStop: true }); + wireTestkitChain(provider); + const handler = await WalletHandler.build(logger, fakeEnv(), { + kind: 'hex', + value: '00', + }); + await expect(handler[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(provider.stop).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'boom' }), + 'Wallet stop failed', + ); + }); + }); +}); diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts new file mode 100644 index 0000000..7e00f0f --- /dev/null +++ b/packages/deployer/src/wallet/handler.ts @@ -0,0 +1,456 @@ +import { createHash } from 'node:crypto'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { DustSecretKey, ZswapSecretKeys } from '@midnight-ntwrk/ledger-v8'; +import { + DEFAULT_DUST_OPTIONS, + DEFAULT_WALLET_STATE_DIRECTORY, + type DustWalletOptions, + type EnvironmentConfiguration, + FluentWalletBuilder, + MidnightWalletProvider, + WalletFactory, + WalletSaveStateProvider, + WalletSeeds, +} from '@midnight-ntwrk/testkit-js'; +import { + DustWallet, + type DustWalletAPI, +} from '@midnight-ntwrk/wallet-sdk-dust-wallet'; +import type { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade'; +import type { ShieldedWalletAPI } from '@midnight-ntwrk/wallet-sdk-shielded'; +import { + createKeystore, + type UnshieldedKeystore, +} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; +import type { Logger } from 'pino'; +import { WalletError } from '../errors.ts'; +import type { WalletSeed } from './seeds.ts'; + +export interface WalletHandlerBuildOptions { + /** Force a fresh sync from genesis (skip the on-disk cache). Default `false`. */ + skipWalletCache?: boolean; + /** + * Import a pre-warmed dust wallet state file into `.states/` before + * the restore path runs. Accepts raw JSON (output of + * `DustWallet.serializeState()`) or a gzipped copy; gzip is detected + * by magic bytes. Overwrites any existing cache for the seed. + * Ignored under {@link skipWalletCache}. + */ + seedCacheDust?: string; + /** Like {@link seedCacheDust} but for the shielded sub-wallet. */ + seedCacheShielded?: string; +} + +/** + * Owned wallet handle: a built `MidnightWalletProvider` plus its + * shielded + dust on-disk caches. Acquire via {@link build} and an + * `AsyncDisposableStack.use()`; call {@link saveCache} after sync. + */ +export class WalletHandler implements AsyncDisposable { + /** The underlying testkit-js wallet provider. */ + readonly provider: MidnightWalletProvider; + /** The unshielded keystore the wallet was built with. */ + readonly unshieldedKeystore: UnshieldedKeystore; + readonly #logger: Logger; + readonly #shieldedCacheFilePath: string; + readonly #dustCacheFilePath: string; + + private constructor( + provider: MidnightWalletProvider, + keystore: UnshieldedKeystore, + logger: Logger, + shieldedCacheFilePath: string, + dustCacheFilePath: string, + ) { + this.provider = provider; + this.unshieldedKeystore = keystore; + this.#logger = logger; + this.#shieldedCacheFilePath = shieldedCacheFilePath; + this.#dustCacheFilePath = dustCacheFilePath; + } + + /** + * Build a `MidnightWalletProvider` with three fixes over the bare + * testkit-js builder: + * 1. Tunes `additionalFeeOverhead` for non-mainnet wallet sizes. + * 2. Routes mnemonic vs hex seed through the right derivation path + * (they derive *different* wallets from the same input). + * 3. Restores the shielded + dust sub-wallets from on-disk cache + * when present (saves the 30–60 min first-preprod sync). + * Caller drives `provider.start()`; call {@link saveCache} post-sync. + */ + static async build( + logger: Logger, + env: EnvironmentConfiguration, + seed: WalletSeed, + opts: WalletHandlerBuildOptions = {}, + ): Promise { + const dustOptions: DustWalletOptions = { + ...DEFAULT_DUST_OPTIONS, + // testkit's 5e20 default exceeds a typical preview/preprod + // wallet's ~3e15 dust, breaking fee balance. 5e14 keeps margin + // without exceeding the balance on non-mainnet networks. + additionalFeeOverhead: + env.walletNetworkId === 'mainnet' + ? DEFAULT_DUST_OPTIONS.additionalFeeOverhead + : 500_000_000_000_000n, + }; + + const walletSeeds: WalletSeeds = + seed.kind === 'mnemonic' + ? WalletSeeds.fromMnemonic(seed.value) + : WalletSeeds.fromMasterSeed(seed.value); + + // testkit-js doesn't export `mapEnvironmentToConfiguration` and + // the `config` field isn't on the .d.ts. Cast through unknown. + const builderForConfig = FluentWalletBuilder.forEnvironment(env); + const config = (builderForConfig as unknown as { config: ConfigShape }) + .config; + + const unshieldedKeystore: UnshieldedKeystore = createKeystore( + walletSeeds.unshielded, + env.walletNetworkId as Parameters[1], + ); + + const shieldedCacheFilePath = computeCacheFilePath( + env, + walletSeeds.shielded, + 'shielded', + ); + const dustCacheFilePath = computeCacheFilePath( + env, + walletSeeds.dust, + 'dust', + ); + + // Pre-warmed cache import: place the user-supplied state file at + // the seed-derived `.states/` path so the existing restore path + // picks it up. Mutual exclusion with `--no-cache` is a warn, not a + // hard error — keeps the flag combinations cheap. + if (opts.skipWalletCache === true) { + if (opts.seedCacheShielded || opts.seedCacheDust) { + logger.warn( + '--seed-cache-from-* is ignored under --no-cache (cache load is disabled)', + ); + } + } else { + if (opts.seedCacheShielded) { + importSeedCache( + logger, + opts.seedCacheShielded, + shieldedCacheFilePath, + 'shielded', + ); + } + if (opts.seedCacheDust) { + importSeedCache(logger, opts.seedCacheDust, dustCacheFilePath, 'dust'); + } + } + + const shieldedWallet = await loadOrCreateShieldedWallet({ + logger, + config, + seed: walletSeeds.shielded, + cacheFilePath: shieldedCacheFilePath, + skipCache: opts.skipWalletCache === true, + }); + + const unshieldedWallet = WalletFactory.createUnshieldedWallet( + config as Parameters[0], + unshieldedKeystore as Parameters< + typeof WalletFactory.createUnshieldedWallet + >[1], + ); + + const dustWallet = await loadOrCreateDustWallet({ + logger, + config, + seed: walletSeeds.dust, + dustOptions, + cacheFilePath: dustCacheFilePath, + skipCache: opts.skipWalletCache === true, + }); + + type CreateFacadeArgs = Parameters; + const walletFacade: WalletFacade = await WalletFactory.createWalletFacade( + config as CreateFacadeArgs[0], + shieldedWallet as CreateFacadeArgs[1], + unshieldedWallet, + dustWallet, + ); + + const provider = await MidnightWalletProvider.withWallet( + logger, + env, + walletFacade, + ZswapSecretKeys.fromSeed(walletSeeds.shielded), + DustSecretKey.fromSeed(walletSeeds.dust), + unshieldedKeystore as Parameters< + typeof MidnightWalletProvider.withWallet + >[5], + ); + + return new WalletHandler( + provider, + unshieldedKeystore, + logger, + shieldedCacheFilePath, + dustCacheFilePath, + ); + } + + /** + * Snapshot the shielded + dust sub-wallets to disk. Best-effort and + * independent per sub-wallet. Both are cached because on real + * networks both are slow on first sync (shielded trial-decrypts every + * note; dust streams the global unfiltered ledger event log). + */ + async saveCache(): Promise { + await Promise.allSettled([ + this.#saveSubWalletCache( + this.#shieldedCacheFilePath, + this.provider.wallet.shielded, + 'shielded', + ), + this.#saveSubWalletCache( + this.#dustCacheFilePath, + this.provider.wallet.dust, + 'dust', + ), + ]); + } + + async #saveSubWalletCache( + filePath: string, + subWallet: unknown, + label: string, + ): Promise { + try { + const dir = pathDir(filePath); + const filename = pathBase(filePath); + // `seed` param only feeds the default filename; we pass an + // explicit one, so the empty string is fine. + const saver = new WalletSaveStateProvider( + this.#logger, + '', + dir, + filename, + ); + await saver.save(subWallet as Parameters[0]); + } catch (e) { + this.#logger.warn( + { err: (e as Error).message, label, filePath }, + 'Wallet sub-wallet cache save failed; continuing', + ); + } + } + + async [Symbol.asyncDispose](): Promise { + try { + await this.provider.stop(); + } catch (e) { + this.#logger.warn({ err: (e as Error).message }, 'Wallet stop failed'); + } + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +/** Opaque testkit-js `FluentWalletBuilder.config` (not exported by testkit). */ +type ConfigShape = unknown; + +/** + * Drop a user-supplied wallet-state file into `.states/` under the + * seed-derived filename so the existing restore path picks it up. + * Detects gzip via magic bytes (`0x1f 0x8b`); raw JSON is gzipped on + * the way in. Throws {@link WalletError} on read failure so a bad path + * fails fast instead of silently doing a cold sync from genesis. + * + * Safety guarantees: + * 1. **Source is read-only.** Only `readFileSync` touches `srcPath`. + * 2. **Backup is preserved forever.** If the target `.gz` already + * exists, it is `copyFileSync`'d to `.bak` *before* any + * write — never deleted, never overwritten by this helper. A user + * who hits a bad-format import can roll back with + * `mv .bak ` and re-run. + * 3. **Write is atomic.** New bytes land in `.tmp` first, + * then `rename(2)` over the final path. POSIX rename is atomic + * within the same filesystem, so a mid-write crash can never leave + * the existing cache half-overwritten. A stale `.tmp` left by a + * failed rename is harmless (cache load only scans `.gz`) and gets + * overwritten by the next import attempt. + */ +function importSeedCache( + logger: Logger, + srcPath: string, + targetPath: string, + kind: 'shielded' | 'dust', +): void { + const absoluteSrc = resolve(process.cwd(), srcPath); + let bytes: Buffer; + try { + bytes = readFileSync(absoluteSrc); + } catch (e) { + throw new WalletError( + `--seed-cache-from-${kind}: cannot read ${absoluteSrc}: ${(e as Error).message}`, + ); + } + const isGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; + const payload = isGzipped ? bytes : gzipSync(bytes); + const backupPath = `${targetPath}.bak`; + const tempPath = `${targetPath}.tmp`; + mkdirSync(pathDir(targetPath), { recursive: true }); + // Preserve the previous cache forever as `.bak` so a + // bad-format import is always recoverable by hand. We use copy (not + // rename) so the live `` keeps its bytes throughout the + // window before the atomic rename below. + const backedUp = existsSync(targetPath); + if (backedUp) { + copyFileSync(targetPath, backupPath); + } + writeFileSync(tempPath, payload); + renameSync(tempPath, targetPath); + logger.info( + `Imported ${kind} cache: ${absoluteSrc} → ${targetPath}${ + backedUp ? ` (previous cache backed up to ${backupPath})` : '' + }`, + ); +} + +/** + * `--.gz`. Per-kind suffix prevents + * shielded/dust cross-load (different state schemas). Don't reuse + * testkit's helper: it embeds the seed verbatim and gates the + * network on env vars instead of runtime ID. + */ +function computeCacheFilePath( + env: EnvironmentConfiguration, + seed: Uint8Array, + kind: 'shielded' | 'dust', +): string { + const seedHash = createHash('sha256').update(seed).digest('hex').slice(0, 16); + const filename = `${env.walletNetworkId}-${seedHash}-${kind}.gz`; + return resolve(process.cwd(), DEFAULT_WALLET_STATE_DIRECTORY, filename); +} + +/** + * Restore dust wallet from cache, else build fresh. Routes through + * `DustWallet(config).restore(...)` because testkit doesn't expose a + * `WalletFactory.restoreDustWallet`. Caching turns preprod's 1 h+ + * first-run dust sync into seconds on subsequent boots. + */ +async function loadOrCreateDustWallet(args: { + logger: Logger; + config: ConfigShape; + seed: Uint8Array; + dustOptions: DustWalletOptions; + cacheFilePath: string; + skipCache: boolean; +}): Promise { + const { logger, config, seed, dustOptions, cacheFilePath, skipCache } = args; + + if (!skipCache && existsSync(cacheFilePath)) { + try { + const dir = pathDir(cacheFilePath); + const filename = pathBase(cacheFilePath); + const loader = new WalletSaveStateProvider(logger, '', dir, filename); + const serializedState = await loader.load(); + // `costParameters` is runtime state on the builder, not baked + // into the snapshot. Re-apply `dustOptions` so the restored + // wallet honours our `additionalFeeOverhead` override. + const dustConfig = buildDustConfig(config, dustOptions); + const dustClass = DustWallet( + dustConfig as Parameters[0], + ); + const restored = dustClass.restore(serializedState); + logger.info(`Restored dust wallet state from ${cacheFilePath}`); + return restored as unknown as DustWalletAPI; + } catch (e) { + logger.warn( + { err: (e as Error).message, cacheFilePath }, + 'Dust wallet cache restore failed; falling back to fresh sync', + ); + } + } else if (skipCache) { + logger.info('Dust wallet cache disabled (--no-cache); doing fresh sync'); + } + + return WalletFactory.createDustWallet( + config as Parameters[0], + seed, + dustOptions, + ); +} + +async function loadOrCreateShieldedWallet(args: { + logger: Logger; + config: ConfigShape; + seed: Uint8Array; + cacheFilePath: string; + skipCache: boolean; +}): Promise { + const { logger, config, seed, cacheFilePath, skipCache } = args; + + if (!skipCache && existsSync(cacheFilePath)) { + try { + const dir = pathDir(cacheFilePath); + const filename = pathBase(cacheFilePath); + const loader = new WalletSaveStateProvider(logger, '', dir, filename); + const serializedState = await loader.load(); + const restored = await WalletFactory.restoreShieldedWallet( + config as Parameters[0], + serializedState, + ); + logger.info(`Restored wallet state from ${cacheFilePath}`); + return restored as ShieldedWalletAPI; + } catch (e) { + logger.warn( + { err: (e as Error).message, cacheFilePath }, + 'Wallet cache restore failed; falling back to fresh sync', + ); + } + } else if (skipCache) { + logger.info('Wallet cache disabled (--no-cache); doing fresh sync'); + } + + return WalletFactory.createShieldedWallet( + config as Parameters[0], + seed, + ) as ShieldedWalletAPI; +} + +/** Layer `dustOptions` onto the base config so cache-restored wallets honour `additionalFeeOverhead`. */ +function buildDustConfig( + config: ConfigShape, + dustOptions: DustWalletOptions, +): ConfigShape { + return { + ...(config as Record), + costParameters: { + ledgerParams: dustOptions.ledgerParams, + additionalFeeOverhead: dustOptions.additionalFeeOverhead, + feeBlocksMargin: dustOptions.feeBlocksMargin, + }, + } as ConfigShape; +} + +function pathDir(p: string): string { + const i = p.lastIndexOf('/'); + return i === -1 ? '.' : p.slice(0, i); +} + +function pathBase(p: string): string { + const i = p.lastIndexOf('/'); + return i === -1 ? p : p.slice(i + 1); +} diff --git a/packages/deployer/src/wallet/keystore.test.ts b/packages/deployer/src/wallet/keystore.test.ts new file mode 100644 index 0000000..393a28f --- /dev/null +++ b/packages/deployer/src/wallet/keystore.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { WalletError } from '../errors.ts'; +import { Keystore, type MidnightKeystore } from './keystore.ts'; + +const FAST_OPTS = { scryptN: 1024, scryptR: 8, scryptP: 1, dklen: 32 }; +const SEED = 'deadbeef'.repeat(8); + +describe('Keystore', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'keystore-test-')); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + describe('encrypt / decrypt', () => { + it('should round-trip a seed through encrypt → decrypt', () => { + const ks = Keystore.encrypt(SEED, 'hunter2', FAST_OPTS); + const json = ks.toJSON(); + expect(json.version).toBe('midnight-1'); + expect(json.crypto.cipher).toBe('aes-128-ctr'); + expect(json.crypto.kdf).toBe('scrypt'); + expect(ks.decrypt('hunter2')).toBe(SEED); + }); + + it('should accept a 0x-prefixed hex seed and round-trip back to unprefixed hex', () => { + const ks = Keystore.encrypt(`0x${SEED}`, 'pw', FAST_OPTS); + expect(ks.decrypt('pw')).toBe(SEED); + }); + + it('should reject a non-hex seed', () => { + expect(() => Keystore.encrypt('not hex!', 'pw', FAST_OPTS)).toThrow( + WalletError, + ); + }); + + it('should reject an odd-length hex seed', () => { + expect(() => Keystore.encrypt('abc', 'pw', FAST_OPTS)).toThrow( + WalletError, + ); + }); + + it('should reject a wrong passphrase with MAC mismatch', () => { + const ks = Keystore.encrypt(SEED, 'hunter2', FAST_OPTS); + expect(() => ks.decrypt('wrong')).toThrow(/MAC mismatch/); + }); + + it('should produce a different ciphertext on each encryption (random salt/iv)', () => { + const a = Keystore.encrypt(SEED, 'pp', FAST_OPTS).toJSON(); + const b = Keystore.encrypt(SEED, 'pp', FAST_OPTS).toJSON(); + expect(a.crypto.ciphertext).not.toBe(b.crypto.ciphertext); + expect(a.crypto.kdfparams.salt).not.toBe(b.crypto.kdfparams.salt); + }); + }); + + describe('toJSON', () => { + it('should expose the full on-disk shape with all crypto fields', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS); + const json = ks.toJSON(); + expect(json.version).toBe('midnight-1'); + expect(typeof json.id).toBe('string'); + expect(json.crypto.cipher).toBe('aes-128-ctr'); + expect(json.crypto.kdf).toBe('scrypt'); + expect(typeof json.crypto.ciphertext).toBe('string'); + expect(typeof json.crypto.mac).toBe('string'); + expect(typeof json.crypto.cipherparams.iv).toBe('string'); + expect(json.crypto.kdfparams).toMatchObject({ + dklen: 32, + n: 1024, + p: 1, + r: 8, + }); + expect(typeof json.crypto.kdfparams.salt).toBe('string'); + }); + }); + + describe('writeToFile', () => { + it('should write JSON to disk with mode 0o600', async () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS); + const path = join(tmp, 'wallet.json'); + await ks.writeToFile(path); + const st = statSync(path); + // mask out file-type bits, only check perm bits + expect(st.mode & 0o777).toBe(0o600); + const parsed = JSON.parse(await readFile(path, 'utf8')); + expect(parsed.version).toBe('midnight-1'); + }); + }); + + describe('readFromFile', () => { + it('should round-trip through writeToFile + readFromFile + decrypt', async () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS); + const path = join(tmp, 'wallet.json'); + await ks.writeToFile(path); + const loaded = await Keystore.readFromFile(path); + expect(loaded.decrypt('pw')).toBe(SEED); + }); + + it('should wrap fs errors as WalletError', async () => { + await expect( + Keystore.readFromFile(join(tmp, 'does-not-exist.json')), + ).rejects.toThrow(/Failed to read keystore at/); + }); + + it('should reject invalid JSON with WalletError', async () => { + const path = join(tmp, 'bad.json'); + await writeFile(path, '{ not valid json'); + await expect(Keystore.readFromFile(path)).rejects.toThrow( + /Invalid JSON in keystore/, + ); + }); + }); + + describe('fromJSON validation', () => { + it('should reject an unsupported version', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS); + const tampered = { + ...ks.toJSON(), + version: 'eth-3', + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow( + /Unsupported keystore version/, + ); + }); + + it('should reject an unsupported KDF', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { ...ks.crypto, kdf: 'pbkdf2' }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(/Unsupported KDF/); + }); + + it('should reject an unsupported cipher', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { ...ks.crypto, cipher: 'aes-256-gcm' }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(/Unsupported cipher/); + }); + }); +}); diff --git a/packages/deployer/src/wallet/keystore.ts b/packages/deployer/src/wallet/keystore.ts new file mode 100644 index 0000000..9a2a2de --- /dev/null +++ b/packages/deployer/src/wallet/keystore.ts @@ -0,0 +1,202 @@ +/** + * Web3 Secret Storage v3-shaped JSON keystore (scrypt + AES-128-CTR + + * SHA-256 MAC) with a `version: "midnight-1"` marker so future schema + * bumps don't collide with Ethereum tooling that reads v3. + */ + +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + randomUUID, + scryptSync, +} from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { WalletError } from '../errors.ts'; + +const VERSION = 'midnight-1'; + +/** On-disk JSON shape. Exported so consumers can transport keystores verbatim. */ +export interface MidnightKeystore { + version: typeof VERSION; + id: string; + crypto: { + cipher: 'aes-128-ctr'; + ciphertext: string; + cipherparams: { iv: string }; + kdf: 'scrypt'; + kdfparams: { dklen: number; n: number; p: number; r: number; salt: string }; + mac: string; + }; +} + +export interface KeystoreCreateOptions { + scryptN?: number; + scryptP?: number; + scryptR?: number; + dklen?: number; +} + +const DEFAULTS: Required = { + scryptN: 1 << 17, + scryptP: 1, + scryptR: 8, + dklen: 32, +}; + +/** Encrypted wallet-seed wrapper; invariants enforced at construction. */ +export class Keystore { + readonly #data: MidnightKeystore; + + private constructor(data: MidnightKeystore) { + this.#data = data; + } + + /** Encrypt a 32-byte hex seed (with or without `0x`) under `passphrase`. Override {@link DEFAULTS} only for tests that need fast scrypt. */ + static encrypt( + seedHex: string, + passphrase: string, + opts: KeystoreCreateOptions = {}, + ): Keystore { + const seed = seedFromHex(seedHex); + const { scryptN, scryptP, scryptR, dklen } = { ...DEFAULTS, ...opts }; + + const salt = randomBytes(32); + const iv = randomBytes(16); + const derived = scryptSync(Buffer.from(passphrase, 'utf8'), salt, dklen, { + N: scryptN, + p: scryptP, + r: scryptR, + maxmem: 512 * 1024 * 1024, + }); + + const encKey = derived.subarray(0, 16); + const macKey = derived.subarray(16, 32); + + const cipher = createCipheriv('aes-128-ctr', encKey, iv); + const ciphertext = Buffer.concat([cipher.update(seed), cipher.final()]); + const mac = createHash('sha256') + .update(Buffer.concat([macKey, ciphertext])) + .digest(); + + return new Keystore({ + version: VERSION, + id: randomUUID(), + crypto: { + cipher: 'aes-128-ctr', + ciphertext: ciphertext.toString('hex'), + cipherparams: { iv: iv.toString('hex') }, + kdf: 'scrypt', + kdfparams: { + dklen, + n: scryptN, + p: scryptP, + r: scryptR, + salt: salt.toString('hex'), + }, + mac: mac.toString('hex'), + }, + }); + } + + /** Read + parse a JSON keystore file. Validates via {@link Keystore.fromJSON}. */ + static async readFromFile(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch (e) { + throw new WalletError( + `Failed to read keystore at ${path}: ${(e as Error).message}`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + throw new WalletError( + `Invalid JSON in keystore ${path}: ${(e as Error).message}`, + ); + } + return Keystore.fromJSON(parsed as MidnightKeystore); + } + + /** Wrap parsed keystore JSON; validates version/cipher/KDF eagerly. */ + static fromJSON(data: MidnightKeystore): Keystore { + if (data.version !== VERSION) { + throw new WalletError( + `Unsupported keystore version: ${data.version} (expected ${VERSION})`, + ); + } + if (data.crypto.kdf !== 'scrypt') { + throw new WalletError( + `Unsupported KDF: ${data.crypto.kdf} (expected scrypt)`, + ); + } + if (data.crypto.cipher !== 'aes-128-ctr') { + throw new WalletError( + `Unsupported cipher: ${data.crypto.cipher} (expected aes-128-ctr)`, + ); + } + return new Keystore(data); + } + + /** Recover the hex-encoded seed. Throws {@link WalletError} on MAC mismatch. */ + decrypt(passphrase: string): string { + const { kdfparams, ciphertext, cipherparams, mac } = this.#data.crypto; + const derived = scryptSync( + Buffer.from(passphrase, 'utf8'), + Buffer.from(kdfparams.salt, 'hex'), + kdfparams.dklen, + { + N: kdfparams.n, + p: kdfparams.p, + r: kdfparams.r, + maxmem: 512 * 1024 * 1024, + }, + ); + const encKey = derived.subarray(0, 16); + const macKey = derived.subarray(16, 32); + + const cipherBytes = Buffer.from(ciphertext, 'hex'); + const expectedMac = createHash('sha256') + .update(Buffer.concat([macKey, cipherBytes])) + .digest('hex'); + if (expectedMac !== mac) { + throw new WalletError( + 'Keystore MAC mismatch (wrong passphrase or corrupted file)', + ); + } + + const decipher = createDecipheriv( + 'aes-128-ctr', + encKey, + Buffer.from(cipherparams.iv, 'hex'), + ); + const plain = Buffer.concat([ + decipher.update(cipherBytes), + decipher.final(), + ]); + return plain.toString('hex'); + } + + /** Write to disk as pretty JSON with mode `0o600`. */ + async writeToFile(path: string): Promise { + await writeFile(path, `${JSON.stringify(this.#data, null, 2)}\n`, { + mode: 0o600, + }); + } + + /** Return the on-disk JSON shape. */ + toJSON(): MidnightKeystore { + return this.#data; + } +} + +function seedFromHex(hex: string): Buffer { + const stripped = hex.startsWith('0x') ? hex.slice(2) : hex; + if (!/^[0-9a-fA-F]+$/.test(stripped) || stripped.length % 2 !== 0) { + throw new WalletError('Seed must be hex-encoded'); + } + return Buffer.from(stripped, 'hex'); +} diff --git a/packages/deployer/src/wallet/seeds.test.ts b/packages/deployer/src/wallet/seeds.test.ts new file mode 100644 index 0000000..3da18d2 --- /dev/null +++ b/packages/deployer/src/wallet/seeds.test.ts @@ -0,0 +1,264 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CompactConfig } from '../config/compact-config.ts'; +import type { NetworkConfig } from '../config/schema.ts'; +import { WalletError } from '../errors.ts'; +import { Keystore } from './keystore.ts'; +import { + classifySeed, + LOCAL_PREFUNDED_SEEDS, + localPrefundedSeed, + resolveSeed, +} from './seeds.ts'; + +vi.mock('./keystore.ts', () => ({ + Keystore: { readFromFile: vi.fn() }, +})); + +const HEX_SEED = 'aa'.repeat(32); +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +function fakeConfig(rootDir: string, keystore?: string): CompactConfig { + return { + rootDir, + wallet: keystore ? { keystore } : undefined, + } as unknown as CompactConfig; +} + +function fakeNetwork(opts: { local?: { index?: number } } = {}): NetworkConfig { + return { + wallet: opts.local + ? { source: 'local', index: opts.local.index ?? 0 } + : undefined, + } as unknown as NetworkConfig; +} + +describe('classifySeed', () => { + it('should classify a 64-char hex string as hex (lowercased)', () => { + const hex = 'A'.repeat(64); + expect(classifySeed(hex)).toEqual({ kind: 'hex', value: 'a'.repeat(64) }); + }); + + it('should classify a 128-char hex string as hex', () => { + const hex = `${'0'.repeat(127)}1`; + expect(classifySeed(hex)).toEqual({ kind: 'hex', value: hex }); + }); + + it('should classify a valid BIP39 mnemonic as mnemonic (no conversion)', () => { + expect(classifySeed(MNEMONIC)).toEqual({ + kind: 'mnemonic', + value: MNEMONIC, + }); + }); + + it('should reject empty input', () => { + expect(() => classifySeed(' ')).toThrow(WalletError); + }); + + it('should reject an invalid hex length', () => { + expect(() => classifySeed('abc123')).toThrow(WalletError); + }); + + it('should reject gibberish that is neither hex nor BIP39', () => { + expect(() => classifySeed('this is definitely not valid')).toThrow( + WalletError, + ); + }); +}); + +describe('localPrefundedSeed', () => { + it('should return the prefunded seed at the given index', () => { + for (let i = 0; i < LOCAL_PREFUNDED_SEEDS.length; i++) { + expect(localPrefundedSeed(i)).toBe(LOCAL_PREFUNDED_SEEDS[i]); + } + }); + + it('should throw RangeError when the index is out of range', () => { + expect(() => localPrefundedSeed(99)).toThrow(RangeError); + }); +}); + +describe('resolveSeed', () => { + let rootDir: string; + const originalEnvSeed = process.env.MN_DEPLOYER_SEED; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'seeds-resolve-')); + delete process.env.MN_DEPLOYER_SEED; + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + vi.clearAllMocks(); + if (originalEnvSeed === undefined) { + delete process.env.MN_DEPLOYER_SEED; + } else { + process.env.MN_DEPLOYER_SEED = originalEnvSeed; + } + }); + + describe('--seed-file branch', () => { + it('should read seed from a relative seedFile path under rootDir', async () => { + writeFileSync(join(rootDir, 'seed.hex'), `${HEX_SEED}\n`); + const result = await resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + seedFile: 'seed.hex', + }); + expect(result).toEqual({ + seed: { kind: 'hex', value: HEX_SEED }, + origin: 'cli', + }); + }); + + it('should read seed from an absolute seedFile path unchanged', async () => { + const abs = join(rootDir, 'abs-seed.hex'); + writeFileSync(abs, HEX_SEED); + const result = await resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + seedFile: abs, + }); + expect(result.origin).toBe('cli'); + expect(result.seed).toEqual({ kind: 'hex', value: HEX_SEED }); + }); + + it('should wrap fs errors as WalletError with the --seed-file label', async () => { + await expect( + resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + seedFile: 'does-not-exist.hex', + }), + ).rejects.toThrow(/Failed to read --seed-file/); + }); + }); + + describe('MN_DEPLOYER_SEED branch', () => { + it('should return env seed with origin=env when set', async () => { + process.env.MN_DEPLOYER_SEED = HEX_SEED; + const result = await resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + }); + expect(result).toEqual({ + seed: { kind: 'hex', value: HEX_SEED }, + origin: 'env', + }); + }); + + it('should ignore a whitespace-only env value', async () => { + process.env.MN_DEPLOYER_SEED = ' '; + await expect( + resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + }), + ).rejects.toThrow(WalletError); + }); + }); + + describe('keystore branch', () => { + it('should throw WalletError when the keystore file does not exist', async () => { + await expect( + resolveSeed({ + config: fakeConfig(rootDir, 'missing-keystore.json'), + networkName: 'testnet', + network: fakeNetwork(), + promptPassphrase: async () => 'pw', + }), + ).rejects.toThrow(/Keystore file not found:/); + }); + + it('should throw WalletError when keystore is configured but no passphrase prompt provided', async () => { + const ksPath = join(rootDir, 'keystore.json'); + writeFileSync(ksPath, '{}'); + await expect( + resolveSeed({ + config: fakeConfig(rootDir, 'keystore.json'), + networkName: 'testnet', + network: fakeNetwork(), + }), + ).rejects.toThrow(/no passphrase prompt provided/); + }); + + it('should decrypt the keystore and return origin=keystore on the happy path', async () => { + const ksPath = join(rootDir, 'keystore.json'); + writeFileSync(ksPath, '{}'); + const decrypt = vi.fn(() => HEX_SEED); + vi.mocked(Keystore.readFromFile).mockResolvedValue({ + decrypt, + } as unknown as Keystore); + const prompt = vi.fn(async () => 'hunter2'); + const result = await resolveSeed({ + config: fakeConfig(rootDir, 'keystore.json'), + networkName: 'testnet', + network: fakeNetwork(), + promptPassphrase: prompt, + }); + expect(Keystore.readFromFile).toHaveBeenCalledWith(ksPath); + expect(prompt).toHaveBeenCalledWith(ksPath); + expect(decrypt).toHaveBeenCalledWith('hunter2'); + expect(result).toEqual({ + seed: { kind: 'hex', value: HEX_SEED }, + origin: 'keystore', + }); + }); + }); + + describe('local prefunded branch', () => { + it('should return the indexed local prefunded seed when networkName=local and source=local', async () => { + const result = await resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'local', + network: fakeNetwork({ local: { index: 2 } }), + }); + expect(result.origin).toBe('local'); + // index 2 is a 64-char hex seed in LOCAL_PREFUNDED_SEEDS + expect(result.seed.kind).toBe('hex'); + expect(result.seed.value).toBe(LOCAL_PREFUNDED_SEEDS[2]); + }); + + it('should default to index 0 (the mnemonic) when no index is configured', async () => { + const result = await resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'local', + network: fakeNetwork({ local: {} }), + }); + expect(result.origin).toBe('local'); + expect(result.seed.kind).toBe('mnemonic'); + }); + }); + + describe('no source available', () => { + it('should throw WalletError with the help message when no seed source is configured', async () => { + await expect( + resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'testnet', + network: fakeNetwork(), + }), + ).rejects.toThrow( + /Provide --seed-file, set MN_DEPLOYER_SEED, or configure \[wallet\].keystore/, + ); + }); + + it('should NOT fall into the local branch when networkName is local but no wallet source is set', async () => { + await expect( + resolveSeed({ + config: fakeConfig(rootDir), + networkName: 'local', + network: fakeNetwork(), + }), + ).rejects.toThrow(WalletError); + }); + }); +}); diff --git a/packages/deployer/src/wallet/seeds.ts b/packages/deployer/src/wallet/seeds.ts new file mode 100644 index 0000000..535f767 --- /dev/null +++ b/packages/deployer/src/wallet/seeds.ts @@ -0,0 +1,141 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { isAbsolute, resolve } from 'node:path'; +import { TEST_MNEMONIC } from '@midnight-ntwrk/testkit-js'; +import { validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import type { CompactConfig } from '../config/compact-config.ts'; +import type { NetworkConfig } from '../config/schema.ts'; +import { WalletError } from '../errors.ts'; +import { Keystore } from './keystore.ts'; + +// --- Local prefunded seeds (dev-preset midnight-node) --- + +/** + * Prefunded wallets on `midnight-node --preset=dev`. Slot 0 is the testkit-js + * `TEST_MNEMONIC`; slots 1..4 are the hex seeds from `LocalTestEnvironment`. + */ +export const LOCAL_PREFUNDED_SEEDS: readonly string[] = [ + TEST_MNEMONIC, + '0000000000000000000000000000000000000000000000000000000000000001', + '0000000000000000000000000000000000000000000000000000000000000002', + '0000000000000000000000000000000000000000000000000000000000000003', + '0000000000000000000000000000000000000000000000000000000000000004', +] as const; + +export function localPrefundedSeed(index: number): string { + const seed = LOCAL_PREFUNDED_SEEDS[index]; + if (!seed) { + throw new RangeError( + `local wallet index ${index} out of range (0..${LOCAL_PREFUNDED_SEEDS.length - 1})`, + ); + } + return seed; +} + +// --- Classify: raw string → discriminated WalletSeed --- + +/** + * The wallet builder derives *different* wallets from `.withSeed(hex)` vs + * `.withMnemonic(phrase)` for the same entropy, so we keep the kind + * explicit through the resolve chain. + */ +export type WalletSeed = + | { kind: 'hex'; value: string } + | { kind: 'mnemonic'; value: string }; + +export function classifySeed(input: string): WalletSeed { + const trimmed = input.trim(); + if (!trimmed) { + throw new WalletError('Seed cannot be empty'); + } + if ( + /^[0-9a-fA-F]+$/.test(trimmed) && + (trimmed.length === 64 || trimmed.length === 128) + ) { + return { kind: 'hex', value: trimmed.toLowerCase() }; + } + if (validateMnemonic(trimmed, wordlist)) { + return { kind: 'mnemonic', value: trimmed }; + } + throw new WalletError( + 'Invalid seed: expected a 64/128-char hex string or a valid BIP39 mnemonic (12 or 24 words).', + ); +} + +// --- Resolve: pick a seed from the precedence chain --- + +/** + * Precedence: `--seed-file` > `MN_DEPLOYER_SEED` > `[wallet].keystore` + * (passphrase-prompted) > `[networks.local].wallet.source = "local"`. + * Throws {@link WalletError} when none match. + */ +export interface SeedResolution { + seed: WalletSeed; + origin: 'cli' | 'env' | 'keystore' | 'local'; +} + +export interface ResolveOptions { + config: CompactConfig; + networkName: string; + network: NetworkConfig; + seedFile?: string; + promptPassphrase?: (path: string) => Promise; +} + +export async function resolveSeed( + opts: ResolveOptions, +): Promise { + const { rootDir } = opts.config; + if (opts.seedFile) { + const path = absoluteUnder(rootDir, opts.seedFile); + const raw = await safeRead(path, '--seed-file'); + return { seed: classifySeed(raw), origin: 'cli' }; + } + + const envSeed = process.env.MN_DEPLOYER_SEED; + if (envSeed?.trim()) { + return { seed: classifySeed(envSeed), origin: 'env' }; + } + + const keystorePath = opts.config.wallet?.keystore; + if (keystorePath) { + const path = absoluteUnder(rootDir, keystorePath); + if (!existsSync(path)) { + throw new WalletError(`Keystore file not found: ${path}`); + } + if (!opts.promptPassphrase) { + throw new WalletError( + 'Keystore configured but no passphrase prompt provided', + ); + } + const ks = await Keystore.readFromFile(path); + const passphrase = await opts.promptPassphrase(path); + return { seed: classifySeed(ks.decrypt(passphrase)), origin: 'keystore' }; + } + + if (opts.networkName === 'local' && opts.network.wallet?.source === 'local') { + return { + seed: classifySeed(localPrefundedSeed(opts.network.wallet.index ?? 0)), + origin: 'local', + }; + } + + throw new WalletError( + `No deployer seed for network "${opts.networkName}". Provide --seed-file, set MN_DEPLOYER_SEED, or configure [wallet].keystore in compact.toml.`, + ); +} + +function absoluteUnder(root: string, p: string): string { + return isAbsolute(p) ? p : resolve(root, p); +} + +async function safeRead(path: string, label: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (e) { + throw new WalletError( + `Failed to read ${label} (${path}): ${(e as Error).message}`, + ); + } +} diff --git a/packages/deployer/tsconfig.json b/packages/deployer/tsconfig.json new file mode 100644 index 0000000..d46d4e6 --- /dev/null +++ b/packages/deployer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "declaration": true, + "skipLibCheck": true, + "sourceMap": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/deployer/vitest.config.ts b/packages/deployer/vitest.config.ts new file mode 100644 index 0000000..8b2eb8b --- /dev/null +++ b/packages/deployer/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + reporters: 'verbose', + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + thresholds: { + statements: 95, + branches: 90, + functions: 100, + lines: 95, + }, + }, + }, +}); diff --git a/turbo.json b/turbo.json index 08dd369..4baf411 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,19 @@ "outputs": [], "cache": false }, + "coverage": { + "dependsOn": ["^build"], + "env": ["COMPACT_HOME"], + "inputs": [ + "src/**/*.ts", + "src/**/*.compact", + "test/**/*.ts", + "vitest.config.ts", + "package.json" + ], + "outputs": ["coverage/**"], + "cache": false + }, "build": { "dependsOn": ["^build"], "env": ["COMPACT_HOME"], diff --git a/yarn.lock b/yarn.lock index 1777295..dc2eab8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,86 @@ __metadata: version: 8 cacheKey: 10 +"@apollo/client@npm:^4.1.6": + version: 4.2.0 + resolution: "@apollo/client@npm:4.2.0" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.1.1" + "@wry/caches": "npm:^1.0.0" + "@wry/equality": "npm:^0.5.6" + "@wry/trie": "npm:^0.5.0" + graphql-tag: "npm:^2.12.6" + optimism: "npm:^0.18.0" + tslib: "npm:^2.3.0" + peerDependencies: + graphql: ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + checksum: 10/fceef4fdeb0780fe91dd95ccd9fea9b56710698b154a1bc6b843ade8df39475d5133eb918705152459ec54f618bd420008e1a0c46012ae8ae7d62bc1e619359e + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10/0ae29cc2005084abdae2966afdb86ed14d41c9c37db02c3693d5022fba9f5d59b011d039380b8e537c34daf117c549f52b452398f576e908fb9db3c7abbb3a00 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 + languageName: node + linkType: hard + +"@babel/parser@npm:^7.29.3": + version: 7.29.3 + resolution: "@babel/parser@npm:7.29.3" + dependencies: + "@babel/types": "npm:^7.29.0" + bin: + parser: ./bin/babel-parser.js + checksum: 10/10e8f34e0fdaa495b9db8be71f4eb29b16d8a57e0818c1bb1c4084015b0383803fd77812ed41597760cbf3d9ab3ae9f4af54f39ff5e5d8e081ba43593232f0ca + languageName: node + linkType: hard + +"@babel/types@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10/bfc2b211210f3894dcd7e6a33b2d1c32c93495dc1e36b547376aa33441abe551ab4bc1640d4154ee2acd8e46d3bbc925c7224caae02fcaf0e6a771e97fccc661 + languageName: node + linkType: hard + +"@balena/dockerignore@npm:^1.0.2": + version: 1.0.2 + resolution: "@balena/dockerignore@npm:1.0.2" + checksum: 10/13d654fdd725008577d32e721c720275bdc48f72bce612326363d5bed449febbed856c517a0b23c7c40d87cb531e63432804550b4ecc13e365d26fee38fb6c8a + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10/46600b2dde460269b07a8e4f12b72e418eae1337b85c979f43af3336c9a1c65b04e42508ab6b245f1e0e3c64328e1c38d8cd733e4a7cebc4fbf9cf65c6e59937 + languageName: node + linkType: hard + "@biomejs/biome@npm:2.4.16": version: 2.4.16 resolution: "@biomejs/biome@npm:2.4.16" @@ -105,6 +185,32 @@ __metadata: languageName: node linkType: hard +"@effect/platform@npm:^0.95.0": + version: 0.95.0 + resolution: "@effect/platform@npm:0.95.0" + dependencies: + find-my-way-ts: "npm:^0.1.6" + msgpackr: "npm:^1.11.4" + multipasta: "npm:^0.2.7" + peerDependencies: + effect: ^3.20.0 + checksum: 10/ae3f3bd441f77bb0f3bb71f954d3a06be2565e4d924eba8c7d5c898da32d893f42c4af0e5c6fee5a1ba087ab7d2d1dae8734a4b1e830baeb654fcccd63c996bb + languageName: node + linkType: hard + +"@effect/platform@npm:^0.96.0": + version: 0.96.1 + resolution: "@effect/platform@npm:0.96.1" + dependencies: + find-my-way-ts: "npm:^0.1.6" + msgpackr: "npm:^1.11.10" + multipasta: "npm:^0.2.7" + peerDependencies: + effect: ^3.21.2 + checksum: 10/36d8b1d43d636be02f9119e0e6d981565a88801ec097bda1cae0ed65bea9fb1963226140b3f6b714f4ea9091a248bc20bf2cc0d775b238a9ef4010ddea48fa65 + languageName: node + linkType: hard + "@emnapi/core@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -133,6 +239,60 @@ __metadata: languageName: node linkType: hard +"@graphql-typed-document-node/core@npm:^3.1.1, @graphql-typed-document-node/core@npm:^3.2.0": + version: 3.2.0 + resolution: "@graphql-typed-document-node/core@npm:3.2.0" + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10/fa44443accd28c8cf4cb96aaaf39d144a22e8b091b13366843f4e97d19c7bfeaf609ce3c7603a4aeffe385081eaf8ea245d078633a7324c11c5ec4b2011bb76d + languageName: node + linkType: hard + +"@grpc/grpc-js@npm:^1.11.1": + version: 1.14.3 + resolution: "@grpc/grpc-js@npm:1.14.3" + dependencies: + "@grpc/proto-loader": "npm:^0.8.0" + "@js-sdsl/ordered-map": "npm:^4.4.2" + checksum: 10/bb9bfe2f749179ae5ac7774d30486dfa2e0b004518c28de158b248e0f6f65f40138f01635c48266fa540670220f850216726e3724e1eb29d078817581c96e4db + languageName: node + linkType: hard + +"@grpc/proto-loader@npm:^0.7.13": + version: 0.7.15 + resolution: "@grpc/proto-loader@npm:0.7.15" + dependencies: + lodash.camelcase: "npm:^4.3.0" + long: "npm:^5.0.0" + protobufjs: "npm:^7.2.5" + yargs: "npm:^17.7.2" + bin: + proto-loader-gen-types: build/bin/proto-loader-gen-types.js + checksum: 10/2e2b33ace8bc34211522751a9e654faf9ac997577a9e9291b1619b4c05d7878a74d2101c3bc43b2b2b92bca7509001678fb191d4eb100684cc2910d66f36c373 + languageName: node + linkType: hard + +"@grpc/proto-loader@npm:^0.8.0": + version: 0.8.1 + resolution: "@grpc/proto-loader@npm:0.8.1" + dependencies: + lodash.camelcase: "npm:^4.3.0" + long: "npm:^5.0.0" + protobufjs: "npm:^7.5.5" + yargs: "npm:^17.7.2" + bin: + proto-loader-gen-types: build/bin/proto-loader-gen-types.js + checksum: 10/d9ef734a43fa3003b9fea4ad9392137f353b79d62b6452b68f8f6b1d8f97947139141d111108ba3e858642989e966e4aa1211012a657d1e41f80a9c7540070ec + languageName: node + linkType: hard + +"@isaacs/cliui@npm:^9.0.0": + version: 9.0.0 + resolution: "@isaacs/cliui@npm:9.0.0" + checksum: 10/8ea3d1009fd29071419209bb91ede20cf27e6e2a1630c5e0702d8b3f47f9e1a3f1c5a587fa2cb96d22d18219790327df49db1bcced573346bbaf4577cf46b643 + languageName: node + linkType: hard + "@isaacs/fs-minipass@npm:^4.0.0": version: 4.0.1 resolution: "@isaacs/fs-minipass@npm:4.0.1" @@ -142,14 +302,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/resolve-uri@npm:^3.0.3": +"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" checksum: 10/97106439d750a409c22c8bff822d648f6a71f3aa9bc8e5129efdc36343cd3096ddc4eeb1c62d2fe48e9bdd4db37b05d4646a17114ecebd3bbcacfa2de51c3c1d languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5 @@ -166,6 +326,37 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.31": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 + languageName: node + linkType: hard + +"@js-sdsl/ordered-map@npm:^4.4.2": + version: 4.4.2 + resolution: "@js-sdsl/ordered-map@npm:4.4.2" + checksum: 10/ac64e3f0615ecc015461c9f527f124d2edaa9e68de153c1e270c627e01e83d046522d7e872692fd57a8c514578b539afceff75831c0d8b2a9a7a347fbed35af4 + languageName: node + linkType: hard + +"@midnight-ntwrk/compact-js@npm:2.5.1": + version: 2.5.1 + resolution: "@midnight-ntwrk/compact-js@npm:2.5.1" + dependencies: + "@effect/platform": "npm:^0.95.0" + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/platform-js": "npm:^2.2.4" + effect: "npm:^3.20.0" + tslib: "npm:^2.8.1" + checksum: 10/ee041b88d8fd43dc63f8cbb6b02f2eb0d6445921633b032e1dd3e909c75be8ca8f311cee70d16a9d06795bbb94172d2a6797799ee3870f29a8aa42e7e3e153b6 + languageName: node + linkType: hard + "@midnight-ntwrk/compact-runtime@npm:0.16.0": version: 0.16.0 resolution: "@midnight-ntwrk/compact-runtime@npm:0.16.0" @@ -177,657 +368,3261 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/ledger-v8@npm:8.1.0": - version: 8.1.0 - resolution: "@midnight-ntwrk/ledger-v8@npm:8.1.0" - checksum: 10/10d56076b0333a502f157c816f8cfebefc8d50221cb20c6db15abcbf2d0092bdaf7e9bc1bd19a6d9f51455547c713c916cb16d4a7d18e83cba0e172ad6e2a507 +"@midnight-ntwrk/dapp-connector-api@npm:4.0.1": + version: 4.0.1 + resolution: "@midnight-ntwrk/dapp-connector-api@npm:4.0.1" + checksum: 10/b5a2fe117390ea40d5d1030a600351400624532169f2beeaa2fa130935c27110e3743fb8f9028d2541ae466e6e168a79efcfd45aaf1bf87a8ca8340bbcf53814 languageName: node linkType: hard -"@midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": - version: 3.0.0 - resolution: "@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0" - checksum: 10/873aeb9e631c3678373c62b5aef847de454de94427028fb3d3f28bfdc8b2c02a3c770bd79d9bfef183eb9db6fb8c23e6826636f2e512ffd6eacbcf7cc0651c5d +"@midnight-ntwrk/ledger-v8@npm:8.0.3": + version: 8.0.3 + resolution: "@midnight-ntwrk/ledger-v8@npm:8.0.3" + checksum: 10/93d24ddeff967a5f5d566a7e8fc0c5586f309e954adf56761fff4ab67874b846c2a4f3f2aede4f51a9e1445d01f52a7446da121473f0120793bc622feeeed207 languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.4 - resolution: "@napi-rs/wasm-runtime@npm:1.1.4" +"@midnight-ntwrk/midnight-js-compact@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-compact@npm:4.1.0" dependencies: - "@tybys/wasm-util": "npm:^0.10.1" - peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10/1db3dc7eeb981306b09360487bd8ce4dfa5588d273bd8ea9f07dccca1b4ade57b675414180fc9bb66966c6c50b17208b0263194993e2f7f92cc7af28bda4d1af + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + bin: + fetch-compactc: dist/fetch-compact.mjs + run-compactc: dist/run-compactc.cjs + checksum: 10/230da503784e600151c6749d54bc719f32f5e24a85911087f871385b2996ad646b094cfe00bb3ee1c285cda177743efc7c27502da5d7c0dffd23b4bfc6dbd13d languageName: node linkType: hard -"@openzeppelin/compact-builder@workspace:^, @openzeppelin/compact-builder@workspace:packages/builder": - version: 0.0.0-use.local - resolution: "@openzeppelin/compact-builder@workspace:packages/builder" - dependencies: - "@tsconfig/node24": "npm:^24.0.3" - "@types/node": "npm:25.9.1" - "@types/shell-quote": "npm:^1.7.5" - chalk: "npm:^5.6.2" - log-symbols: "npm:^7.0.0" - ora: "npm:^9.0.0" - shell-quote: "npm:^1.8.4" - typescript: "npm:^6.0.3" - vitest: "npm:^4.0.15" - languageName: unknown - linkType: soft - -"@openzeppelin/compact-cli@workspace:packages/cli": - version: 0.0.0-use.local - resolution: "@openzeppelin/compact-cli@workspace:packages/cli" +"@midnight-ntwrk/midnight-js-contracts@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-contracts@npm:4.1.0" dependencies: - "@openzeppelin/compact-builder": "workspace:^" - "@tsconfig/node24": "npm:^24.0.3" - "@types/node": "npm:25.9.1" - chalk: "npm:^5.6.2" - ora: "npm:^9.0.0" - typescript: "npm:^6.0.3" - vitest: "npm:^4.1.6" - bin: - compact-builder: dist/runBuilder.js - compact-compiler: dist/runCompiler.js - languageName: unknown - linkType: soft + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + checksum: 10/29c807ed2a62f6186bb3337848740ba090f2b0c50353e30e808d7912b6b630353a3b63f83e7103e3d191b1a99fc90d75680f72fab1fd526d0abeaa6eb845db0b + languageName: node + linkType: hard -"@openzeppelin/compact-simulator@workspace:packages/simulator": - version: 0.0.0-use.local - resolution: "@openzeppelin/compact-simulator@workspace:packages/simulator" +"@midnight-ntwrk/midnight-js-http-client-proof-provider@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-http-client-proof-provider@npm:4.1.0" dependencies: - "@midnight-ntwrk/compact-runtime": "npm:0.16.0" - "@midnight-ntwrk/ledger-v8": "npm:8.1.0" - "@tsconfig/node24": "npm:^24.0.3" - "@types/node": "npm:25.9.1" - fast-check: "npm:^4.5.2" - typescript: "npm:^6.0.3" - vitest: "npm:^4.1.6" - languageName: unknown - linkType: soft - -"@oxc-project/types@npm:=0.132.0": - version: 0.132.0 - resolution: "@oxc-project/types@npm:0.132.0" - checksum: 10/e0694a3c24746006ad774a1cab34efac3ccad5b519234063bcde17e9afe3475680749357e9f90164a222326414cb9510da1b8da350edc0cd35612fd05147c218 + "@midnight-ntwrk/midnight-js-contracts": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + cross-fetch: "npm:^4.1.0" + fetch-retry: "npm:^6.0.0" + checksum: 10/0a4c90e0a7988c5e08b670573b49f2b083c4260858f02bf80908cf8fd3bc67c78bee029764a4a64f8b1363d29c1f4857dbe438352c2b4c1e2f8f183e0a5be066 languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-android-arm64@npm:1.0.2" - conditions: os=android & cpu=arm64 +"@midnight-ntwrk/midnight-js-indexer-public-data-provider@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-indexer-public-data-provider@npm:4.1.0" + dependencies: + "@apollo/client": "npm:^4.1.6" + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + buffer: "npm:^6.0.3" + cross-fetch: "npm:^4.1.0" + graphql: "npm:^16.13.2" + graphql-ws: "npm:^6.0.8" + isomorphic-ws: "npm:^5.0.0" + rxjs: "npm:^7.5.0" + ws: "npm:^8.20.0" + checksum: 10/529ce6f5eb910f1db6b8405f5b5c216afefd8bf4fe8c672019a540c6b8ffd0d59d45c78107def6fe50f74e566af622294f099d36424658d4b951cc59aea96a29 + languageName: node + linkType: hard + +"@midnight-ntwrk/midnight-js-level-private-state-provider@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-level-private-state-provider@npm:4.1.0" + dependencies: + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@noble/ciphers": "npm:^2.0.0" + "@noble/hashes": "npm:^2.0.0" + abstract-level: "npm:^3.0.0" + buffer: "npm:^6.0.3" + level: "npm:^10.0.0" + superjson: "npm:^2.0.0" + checksum: 10/52f90ea4695fd630c5b52d918c7b52a4636928ce8cf172503b34debc50f1a21b24c3932b2604a83f34bf537c19c9695a09df6a84a3f0ebe053dacf5a784a0815 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.2" - conditions: os=darwin & cpu=arm64 +"@midnight-ntwrk/midnight-js-network-id@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-network-id@npm:4.1.0" + checksum: 10/34c3ec96126db9e44380eb47b7bff2755b6809e4da491c63743f95b644df77bf49b6d3788a37248608ab657fd56ce22088189254ffebb2ca188d36a7ae85b376 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.2" - conditions: os=darwin & cpu=x64 +"@midnight-ntwrk/midnight-js-node-zk-config-provider@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-node-zk-config-provider@npm:4.1.0" + dependencies: + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + checksum: 10/e437458920b867a415e9717743391f1d376bbe9842965dabbb03377364108b72a27c6c0651ebe85349f0d1f507b58c3011be6df5be2f861e9a12ace82910cba6 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.2" - conditions: os=freebsd & cpu=x64 +"@midnight-ntwrk/midnight-js-protocol@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-protocol@npm:4.1.0" + dependencies: + "@midnight-ntwrk/compact-js": "npm:2.5.1" + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:8.0.3" + "@midnight-ntwrk/onchain-runtime-v3": "npm:3.0.0" + "@midnight-ntwrk/platform-js": "npm:2.2.4" + checksum: 10/ffe843c7c234b18a098e6197704c16b3509171e958970deff05c39614d85b8f0cb4c010b248330c3e4174df89df22920251cab7b676c7fa1e219d63d278f3726 languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.2" - conditions: os=linux & cpu=arm +"@midnight-ntwrk/midnight-js-types@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-types@npm:4.1.0" + dependencies: + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + rxjs: "npm:^7.5.0" + checksum: 10/1dce63152741e9c47703bb0bebe716e724731826c35c9c88c8b54f3eb63b9561a7e371d76451bc71a5bc1c2b44d2a4a1d440e08d744706dbfc64775b5666bff5 languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.2" - conditions: os=linux & cpu=arm64 & libc=glibc +"@midnight-ntwrk/midnight-js-utils@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/midnight-js-utils@npm:4.1.0" + dependencies: + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + checksum: 10/032560a0b2e34b60d5eda39be19041a5817997cd9318ce91fab0fe431b7f3885285eb5eb7f06bf26fca7c2a4018774863b78b3afd3b46300d515b230ebfffc36 languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.2" - conditions: os=linux & cpu=arm64 & libc=musl +"@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0, @midnight-ntwrk/onchain-runtime-v3@npm:^3.0.0": + version: 3.0.0 + resolution: "@midnight-ntwrk/onchain-runtime-v3@npm:3.0.0" + checksum: 10/873aeb9e631c3678373c62b5aef847de454de94427028fb3d3f28bfdc8b2c02a3c770bd79d9bfef183eb9db6fb8c23e6826636f2e512ffd6eacbcf7cc0651c5d languageName: node linkType: hard -"@rolldown/binding-linux-ppc64-gnu@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.2" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@midnight-ntwrk/platform-js@npm:2.2.4, @midnight-ntwrk/platform-js@npm:^2.2.4": + version: 2.2.4 + resolution: "@midnight-ntwrk/platform-js@npm:2.2.4" + dependencies: + "@effect/platform": "npm:^0.95.0" + effect: "npm:^3.20.0" + tslib: "npm:^2.8.1" + checksum: 10/1650bb7e54a64740aaaf27f7e84b7bffdb08611c994bbf54208db43a0a11d10ea8994f05d82e848d60d6fcee8a9b3a5db770d306262b99547e71185d52614825 languageName: node linkType: hard -"@rolldown/binding-linux-s390x-gnu@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.2" - conditions: os=linux & cpu=s390x & libc=glibc +"@midnight-ntwrk/testkit-js@npm:4.1.0": + version: 4.1.0 + resolution: "@midnight-ntwrk/testkit-js@npm:4.1.0" + dependencies: + "@midnight-ntwrk/dapp-connector-api": "npm:4.0.1" + "@midnight-ntwrk/midnight-js-compact": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-contracts": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-http-client-proof-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-level-private-state-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-node-zk-config-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-protocol": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + "@midnight-ntwrk/wallet-sdk": "npm:1.0.0" + "@midnight-ntwrk/zkir-v2": "npm:2.1.0" + buffer: "npm:^6.0.3" + cross-fetch: "npm:^4.0.0" + rxjs: "npm:^7.8.1" + ws: "npm:^8.20.0" + checksum: 10/f1a7f66d7c17f07cd21b69c300cf9317c3742a046b2d8596b3062643c55648e8c8a171577ce284c0166f5f575f3ae9e677d50b7d42024d3b8d89aa2d055fe345 + languageName: node + linkType: hard + +"@midnight-ntwrk/wallet-sdk-abstractions@npm:2.1.0, @midnight-ntwrk/wallet-sdk-abstractions@npm:^2.1.0": + version: 2.1.0 + resolution: "@midnight-ntwrk/wallet-sdk-abstractions@npm:2.1.0" + dependencies: + effect: "npm:^3.19.19" + checksum: 10/acd476877ab4d32a2580d0b8c4a22a4458a9f5f3bd61b3220fc8a9da63a5cc61ccb5fd95d47506fe47999e708ade7a37d4eca74707cffe9a6b9b648c9ed28596 languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.2" - conditions: os=linux & cpu=x64 & libc=glibc +"@midnight-ntwrk/wallet-sdk-address-format@npm:3.1.1, @midnight-ntwrk/wallet-sdk-address-format@npm:^3.1.1": + version: 3.1.1 + resolution: "@midnight-ntwrk/wallet-sdk-address-format@npm:3.1.1" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@scure/base": "npm:^2.0.0" + "@subsquid/scale-codec": "npm:^4.0.1" + checksum: 10/d92eb47928ae9dfc93bd8b549ba9c32b54b43eaae34ed7031c46b6654a55c92173eed47732f170307a4b372ed692bf3637d0b78fc58fdc3f5635d97bb782be4a languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.2" - conditions: os=linux & cpu=x64 & libc=musl +"@midnight-ntwrk/wallet-sdk-capabilities@npm:3.3.0, @midnight-ntwrk/wallet-sdk-capabilities@npm:^3.3.0": + version: 3.3.0 + resolution: "@midnight-ntwrk/wallet-sdk-capabilities@npm:3.3.0" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:^2.1.0" + "@midnight-ntwrk/wallet-sdk-indexer-client": "npm:^1.2.1" + "@midnight-ntwrk/wallet-sdk-node-client": "npm:^1.1.1" + "@midnight-ntwrk/wallet-sdk-prover-client": "npm:^1.2.1" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:^1.1.1" + "@midnight-ntwrk/zkir-v2": "npm:^2.1.0" + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/dab6a7c2862a0181e16b1e94f882e9de655de16644a5476cb784d503847febe682cb8a565defdd90eef50247f5363a0eb1c8cd4a0702c279831c4a9e62b7e5a7 languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.2" - conditions: os=openharmony & cpu=arm64 +"@midnight-ntwrk/wallet-sdk-dust-wallet@npm:4.0.0, @midnight-ntwrk/wallet-sdk-dust-wallet@npm:^4.0.0": + version: 4.0.0 + resolution: "@midnight-ntwrk/wallet-sdk-dust-wallet@npm:4.0.0" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:2.1.0" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:3.1.1" + "@midnight-ntwrk/wallet-sdk-capabilities": "npm:3.3.0" + "@midnight-ntwrk/wallet-sdk-indexer-client": "npm:1.2.1" + "@midnight-ntwrk/wallet-sdk-runtime": "npm:1.0.3" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/f8da07b8e4b1be2603747f6c17afe1362265d292d21bdb1b4984c9049aa5c99ac289ba6eabe212625be200b3bf502b504508df6febf3d3bc78b5cc44128ebb94 languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.2" +"@midnight-ntwrk/wallet-sdk-facade@npm:4.0.0, @midnight-ntwrk/wallet-sdk-facade@npm:^4.0.0": + version: 4.0.0 + resolution: "@midnight-ntwrk/wallet-sdk-facade@npm:4.0.0" dependencies: - "@emnapi/core": "npm:1.10.0" - "@emnapi/runtime": "npm:1.10.0" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - conditions: cpu=wasm32 + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:^3.1.1" + "@midnight-ntwrk/wallet-sdk-capabilities": "npm:^3.3.0" + "@midnight-ntwrk/wallet-sdk-dust-wallet": "npm:^4.0.0" + "@midnight-ntwrk/wallet-sdk-indexer-client": "npm:^1.2.1" + "@midnight-ntwrk/wallet-sdk-shielded": "npm:^3.0.0" + "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "npm:^3.0.0" + rxjs: "npm:^7.8.2" + checksum: 10/4884866470ce22b190d9f8f0aa79f423f7818670743103ddedf316830367a8b7dafa5bde3229570a6c49276c34421e4e57e468d7a8c097e0920098f67be4eb6c languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.2" - conditions: os=win32 & cpu=arm64 +"@midnight-ntwrk/wallet-sdk-hd@npm:3.0.2, @midnight-ntwrk/wallet-sdk-hd@npm:^3.0.2": + version: 3.0.2 + resolution: "@midnight-ntwrk/wallet-sdk-hd@npm:3.0.2" + dependencies: + "@scure/bip32": "npm:^2.0.1" + "@scure/bip39": "npm:^2.0.1" + checksum: 10/697361dfa33bbb32f9eef6bed7aa13591af60405fa0f7caaf90b772148dd543e75b2500f8b2208105ed71b53e9d4c650b25b0ef5e5460628d0ff6f1235f8fd22 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.2": - version: 1.0.2 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.2" - conditions: os=win32 & cpu=x64 +"@midnight-ntwrk/wallet-sdk-indexer-client@npm:1.2.1, @midnight-ntwrk/wallet-sdk-indexer-client@npm:^1.2.1": + version: 1.2.1 + resolution: "@midnight-ntwrk/wallet-sdk-indexer-client@npm:1.2.1" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.2.0" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + effect: "npm:^3.19.19" + graphql: "npm:^16.13.0" + graphql-http: "npm:^1.22.4" + graphql-ws: "npm:^6.0.7" + checksum: 10/419c9fe66e100659a4ae958ea7b55d885f2e201d8ef67ce49ad3802be7e606419f4909b3c7c0b1892cbf065a21263bff05b216f99b007af17a132c11757dfdbf languageName: node linkType: hard -"@rolldown/pluginutils@npm:^1.0.0": - version: 1.0.1 - resolution: "@rolldown/pluginutils@npm:1.0.1" - checksum: 10/4e95cf9ce23d75e5aa03ea0249cd86f7d1e21f83fbf6f8520e4edd8a251ba1b82c4ba9bc13cd24b6c4661daec6225b06e6d35c64c604e731b230b2a49af47d05 +"@midnight-ntwrk/wallet-sdk-node-client@npm:^1.1.1": + version: 1.1.1 + resolution: "@midnight-ntwrk/wallet-sdk-node-client@npm:1.1.1" + dependencies: + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:2.1.0" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + "@polkadot/api": "npm:^16.5.4" + "@polkadot/types": "npm:^16.5.4" + "@polkadot/util": "npm:^14.0.1" + "@types/bn.js": "npm:^5.2.0" + bn.js: "npm:^5.2.3" + effect: "npm:^3.19.19" + checksum: 10/e2c32fbfc4a475891f31ff786887a20b33a315c005b231aa66da8eb54d923728c113fa7bf629c5f328a92aadb821feabefcf51fb18c21b161440991caa15cf9d languageName: node linkType: hard -"@standard-schema/spec@npm:^1.1.0": - version: 1.1.0 - resolution: "@standard-schema/spec@npm:1.1.0" - checksum: 10/a209615c9e8b2ea535d7db0a5f6aa0f962fd4ab73ee86a46c100fb78116964af1f55a27c1794d4801e534a196794223daa25ff5135021e03c7828aa3d95e1763 +"@midnight-ntwrk/wallet-sdk-prover-client@npm:^1.2.1": + version: 1.2.1 + resolution: "@midnight-ntwrk/wallet-sdk-prover-client@npm:1.2.1" + dependencies: + "@effect/platform": "npm:^0.96.0" + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + "@midnight-ntwrk/zkir-v2": "npm:2.1.0" + effect: "npm:^3.19.19" + web-worker: "npm:^1.5.0" + checksum: 10/ec5c0cf6d5ab382d342655d4cd2dc08fa0d74969d63bcfc781cc13c187340db3744b9fcb0dbd95cf92966752c20334e668aba9ef1ef6a7059d97f374defda0a8 languageName: node linkType: hard -"@tsconfig/node10@npm:^1.0.7": - version: 1.0.12 - resolution: "@tsconfig/node10@npm:1.0.12" - checksum: 10/27e2f989dbb20f773aa121b609a5361a473b7047ff286fce7c851e61f5eec0c74f0bdb38d5bd69c8a06f17e60e9530188f2219b1cbeabeac91f0a5fd348eac2a +"@midnight-ntwrk/wallet-sdk-runtime@npm:1.0.3": + version: 1.0.3 + resolution: "@midnight-ntwrk/wallet-sdk-runtime@npm:1.0.3" + dependencies: + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:2.1.0" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/b1b2cff5fd3814e5b8a8400e2b0f347a58fc4f1ed3405a628e690d06095dcbb4b8fead017c8cc199e319e77c165090106967b266a953815bd33c2d5cad819425 languageName: node linkType: hard -"@tsconfig/node12@npm:^1.0.7": - version: 1.0.11 - resolution: "@tsconfig/node12@npm:1.0.11" - checksum: 10/5ce29a41b13e7897a58b8e2df11269c5395999e588b9a467386f99d1d26f6c77d1af2719e407621412520ea30517d718d5192a32403b8dfcc163bf33e40a338a +"@midnight-ntwrk/wallet-sdk-shielded@npm:3.0.0, @midnight-ntwrk/wallet-sdk-shielded@npm:^3.0.0": + version: 3.0.0 + resolution: "@midnight-ntwrk/wallet-sdk-shielded@npm:3.0.0" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:2.1.0" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:3.1.1" + "@midnight-ntwrk/wallet-sdk-capabilities": "npm:3.3.0" + "@midnight-ntwrk/wallet-sdk-indexer-client": "npm:1.2.1" + "@midnight-ntwrk/wallet-sdk-runtime": "npm:1.0.3" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/e52e4f3d2c1722e401454686312aca9189d6cd37d5f14f61f14937e8109b4af4c695c4a6710a651031bcfdd1d7ce2a3018a25a14b8762783ab8c03364a1dd936 languageName: node linkType: hard -"@tsconfig/node14@npm:^1.0.0": - version: 1.0.3 - resolution: "@tsconfig/node14@npm:1.0.3" - checksum: 10/19275fe80c4c8d0ad0abed6a96dbf00642e88b220b090418609c4376e1cef81bf16237bf170ad1b341452feddb8115d8dd2e5acdfdea1b27422071163dc9ba9d +"@midnight-ntwrk/wallet-sdk-unshielded-wallet@npm:3.0.0, @midnight-ntwrk/wallet-sdk-unshielded-wallet@npm:^3.0.0": + version: 3.0.0 + resolution: "@midnight-ntwrk/wallet-sdk-unshielded-wallet@npm:3.0.0" + dependencies: + "@midnight-ntwrk/ledger-v8": "npm:^8.0.3" + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:2.1.0" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:3.1.1" + "@midnight-ntwrk/wallet-sdk-capabilities": "npm:3.3.0" + "@midnight-ntwrk/wallet-sdk-indexer-client": "npm:1.2.1" + "@midnight-ntwrk/wallet-sdk-runtime": "npm:1.0.3" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:1.1.1" + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/cab6e5d9071544b20946ba1da9014a4b7da824291fe493d634063d346b046288094b7e33c37ff3373ed28fa2660689a8c2836027895614bd44752f9daeb5081d languageName: node linkType: hard -"@tsconfig/node16@npm:^1.0.2": - version: 1.0.4 - resolution: "@tsconfig/node16@npm:1.0.4" - checksum: 10/202319785901f942a6e1e476b872d421baec20cf09f4b266a1854060efbf78cde16a4d256e8bc949d31e6cd9a90f1e8ef8fb06af96a65e98338a2b6b0de0a0ff +"@midnight-ntwrk/wallet-sdk-utilities@npm:1.1.1, @midnight-ntwrk/wallet-sdk-utilities@npm:^1.1.1": + version: 1.1.1 + resolution: "@midnight-ntwrk/wallet-sdk-utilities@npm:1.1.1" + dependencies: + effect: "npm:^3.19.19" + rxjs: "npm:^7.8.2" + checksum: 10/1775ac559ba003274fde80b839f296d5e1bba8c580cd6aae31db9df97f8ab5682ead4b76adbd3db01ad3af051fb81d0e24be2567cffdce51d1d55e864c6104a8 languageName: node linkType: hard -"@tsconfig/node24@npm:^24.0.3": - version: 24.0.4 - resolution: "@tsconfig/node24@npm:24.0.4" - checksum: 10/e3c011e4589d1d3f8774e6587434bf6d5655ca31c848ac1d173de7949e2f2e1c3dd0be5f97869201e46c4a6dda03003659e1c5078efc580ecefce1d706b8b6a1 +"@midnight-ntwrk/wallet-sdk@npm:1.0.0": + version: 1.0.0 + resolution: "@midnight-ntwrk/wallet-sdk@npm:1.0.0" + dependencies: + "@midnight-ntwrk/wallet-sdk-abstractions": "npm:^2.1.0" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:^3.1.1" + "@midnight-ntwrk/wallet-sdk-capabilities": "npm:^3.3.0" + "@midnight-ntwrk/wallet-sdk-dust-wallet": "npm:^4.0.0" + "@midnight-ntwrk/wallet-sdk-facade": "npm:^4.0.0" + "@midnight-ntwrk/wallet-sdk-hd": "npm:^3.0.2" + "@midnight-ntwrk/wallet-sdk-shielded": "npm:^3.0.0" + "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "npm:^3.0.0" + "@midnight-ntwrk/wallet-sdk-utilities": "npm:^1.1.1" + checksum: 10/2c70429c4b1cd54d60b29807412dacf2ce326ae63cc0a03f092731b0e76225cd496fc3bb68eae4f51c799a691a2f6843664cb1beca9e13619daae054918cbb66 languageName: node linkType: hard -"@turbo/darwin-64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/darwin-64@npm:2.9.14" - conditions: os=darwin & cpu=x64 +"@midnight-ntwrk/zkir-v2@npm:2.1.0, @midnight-ntwrk/zkir-v2@npm:^2.1.0": + version: 2.1.0 + resolution: "@midnight-ntwrk/zkir-v2@npm:2.1.0" + checksum: 10/c16761489c3abbf858a4b7c2c4dd99d498f40554b5f1a57a93534b21c66390d4c6b0035dee8923fb5972418c75ac1f80e2e0675d8f3eb2a96dce7e7555fb2b7d languageName: node linkType: hard -"@turbo/darwin-arm64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/darwin-arm64@npm:2.9.14" +"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@turbo/linux-64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/linux-64@npm:2.9.14" - conditions: os=linux & cpu=x64 +"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.3" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@turbo/linux-arm64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/linux-arm64@npm:2.9.14" +"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.3" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@turbo/windows-64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/windows-64@npm:2.9.14" - conditions: os=win32 & cpu=x64 +"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.3" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@turbo/windows-arm64@npm:2.9.14": - version: 2.9.14 - resolution: "@turbo/windows-arm64@npm:2.9.14" - conditions: os=win32 & cpu=arm64 +"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.3" + conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.1": - version: 0.10.2 - resolution: "@tybys/wasm-util@npm:0.10.2" +"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3": + version: 3.0.3 + resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.1.4 + resolution: "@napi-rs/wasm-runtime@npm:1.1.4" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10/1db3dc7eeb981306b09360487bd8ce4dfa5588d273bd8ea9f07dccca1b4ade57b675414180fc9bb66966c6c50b17208b0263194993e2f7f92cc7af28bda4d1af + languageName: node + linkType: hard + +"@noble/ciphers@npm:^2.0.0": + version: 2.2.0 + resolution: "@noble/ciphers@npm:2.2.0" + checksum: 10/d75348aa682b41ad3e24cdd0a56c6d9ca033fb629ab93f37d6690be41c4882359b27598a11af0f5439ba82df4f9e3875dea1f875064310f68fef63cf24e3481a + languageName: node + linkType: hard + +"@noble/curves@npm:2.2.0": + version: 2.2.0 + resolution: "@noble/curves@npm:2.2.0" + dependencies: + "@noble/hashes": "npm:2.2.0" + checksum: 10/f9545e55bb8b6cdf2618c936870b9229339c90b25f129fc368b4b534e723f274e5c0daf8abca2f891bcf0a59c3b49c5ac5205899aec07f5251f545ec616e3aa9 + languageName: node + linkType: hard + +"@noble/curves@npm:^1.3.0, @noble/curves@npm:~1.9.2": + version: 1.9.7 + resolution: "@noble/curves@npm:1.9.7" + dependencies: + "@noble/hashes": "npm:1.8.0" + checksum: 10/3cfe2735ea94972988ca9e217e0ebb2044372a7160b2079bf885da789492a6291fc8bf76ca3d8bf8dee477847ee2d6fac267d1e6c4f555054059f5e8c4865d44 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.8.0, @noble/hashes@npm:^1.3.1, @noble/hashes@npm:^1.3.3, @noble/hashes@npm:~1.8.0": + version: 1.8.0 + resolution: "@noble/hashes@npm:1.8.0" + checksum: 10/474b7f56bc6fb2d5b3a42132561e221b0ea4f91e590f4655312ca13667840896b34195e2b53b7f097ec080a1fdd3b58d902c2a8d0fbdf51d2e238b53808a177e + languageName: node + linkType: hard + +"@noble/hashes@npm:2.2.0, @noble/hashes@npm:^2.0.0": + version: 2.2.0 + resolution: "@noble/hashes@npm:2.2.0" + checksum: 10/b1b78bedc2a01394be047429f3d888905015fe8a09f1b7e43e0b5736b54133df62f73dcc73ede43af38e96e86156afb45b86973fdeaa95d9f0880333c3fc0907 + languageName: node + linkType: hard + +"@npmcli/agent@npm:^3.0.0": + version: 3.0.0 + resolution: "@npmcli/agent@npm:3.0.0" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^10.0.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10/775c9a7eb1f88c195dfb3bce70c31d0fe2a12b28b754e25c08a3edb4bc4816bfedb7ac64ef1e730579d078ca19dacf11630e99f8f3c3e0fd7b23caa5fd6d30a6 + languageName: node + linkType: hard + +"@npmcli/fs@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/fs@npm:4.0.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10/405c4490e1ff11cf299775449a3c254a366a4b1ffc79d87159b0ee7d5558ac9f6a2f8c0735fd6ff3873cef014cb1a44a5f9127cb6a1b2dbc408718cca9365b5a + languageName: node + linkType: hard + +"@openzeppelin/compact-builder@workspace:^, @openzeppelin/compact-builder@workspace:packages/builder": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-builder@workspace:packages/builder" + dependencies: + "@tsconfig/node24": "npm:^24.0.3" + "@types/node": "npm:25.9.1" + "@types/shell-quote": "npm:^1.7.5" + chalk: "npm:^5.6.2" + log-symbols: "npm:^7.0.0" + ora: "npm:^9.0.0" + shell-quote: "npm:^1.8.4" + typescript: "npm:^6.0.3" + vitest: "npm:^4.0.15" + languageName: unknown + linkType: soft + +"@openzeppelin/compact-cli@workspace:packages/cli": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-cli@workspace:packages/cli" + dependencies: + "@openzeppelin/compact-builder": "workspace:^" + "@openzeppelin/compact-deployer": "workspace:^" + "@tsconfig/node24": "npm:^24.0.3" + "@types/node": "npm:25.9.1" + "@types/ws": "npm:^8.5.10" + chalk: "npm:^5.6.2" + ora: "npm:^9.0.0" + pino: "npm:^9.7.0" + pino-pretty: "npm:^13.0.0" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.6" + ws: "npm:^8.16.0" + bin: + compact-builder: dist/runBuilder.js + compact-compiler: dist/runCompiler.js + compact-deploy: dist/runDeploy.js + languageName: unknown + linkType: soft + +"@openzeppelin/compact-deployer@workspace:^, @openzeppelin/compact-deployer@workspace:packages/deployer": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-deployer@workspace:packages/deployer" + dependencies: + "@midnight-ntwrk/compact-js": "npm:2.5.1" + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:8.0.3" + "@midnight-ntwrk/midnight-js-contracts": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-http-client-proof-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-level-private-state-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-node-zk-config-provider": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" + "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" + "@midnight-ntwrk/testkit-js": "npm:4.1.0" + "@midnight-ntwrk/wallet-sdk-address-format": "npm:3.1.1" + "@midnight-ntwrk/wallet-sdk-dust-wallet": "npm:4.0.0" + "@midnight-ntwrk/wallet-sdk-facade": "npm:4.0.0" + "@midnight-ntwrk/wallet-sdk-hd": "npm:3.0.2" + "@midnight-ntwrk/wallet-sdk-shielded": "npm:3.0.0" + "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "npm:3.0.0" + "@scure/bip39": "npm:^1.2.1" + "@tsconfig/node24": "npm:^24.0.3" + "@types/node": "npm:24.10.1" + axios: "npm:^1.12.0" + pino: "npm:^9.7.0" + rxjs: "npm:^7.8.1" + smol-toml: "npm:^1.3.4" + testcontainers: "npm:^10.28.0" + typescript: "npm:^5.9.3" + vitest: "npm:^4.1.6" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + +"@openzeppelin/compact-simulator@workspace:packages/simulator": + version: 0.0.0-use.local + resolution: "@openzeppelin/compact-simulator@workspace:packages/simulator" + dependencies: + "@midnight-ntwrk/compact-runtime": "npm:0.16.0" + "@midnight-ntwrk/ledger-v8": "npm:8.1.0" + "@tsconfig/node24": "npm:^24.0.3" + "@types/node": "npm:25.9.1" + fast-check: "npm:^4.5.2" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.6" + languageName: unknown + linkType: soft + +"@oxc-project/types@npm:=0.133.0": + version: 0.133.0 + resolution: "@oxc-project/types@npm:0.133.0" + checksum: 10/de44f653a9e0c0267309122f1f184120c6869af4382218a6bf4a320c5150743eb00b5e8641b04917666281995ed0fe6381561922a48a28082a75bb122acf3ac6 + languageName: node + linkType: hard + +"@pinojs/redact@npm:^0.4.0": + version: 0.4.0 + resolution: "@pinojs/redact@npm:0.4.0" + checksum: 10/2210ffb6b38357853d47239fd0532cc9edb406325270a81c440a35cece22090127c30c2ead3eefa3e608f2244087485308e515c431f4f69b6bd2e16cbd32812b + languageName: node + linkType: hard + +"@polkadot-api/json-rpc-provider-proxy@npm:^0.1.0": + version: 0.1.0 + resolution: "@polkadot-api/json-rpc-provider-proxy@npm:0.1.0" + checksum: 10/1a232337a4f6f32f3ec0350d5aaceaab21547ccee3cca63318d4b9238982efa5ff2406b033c320318c72d067b73508c0a1af21eb47acabaff714c1c21477bafa + languageName: node + linkType: hard + +"@polkadot-api/json-rpc-provider@npm:0.0.1, @polkadot-api/json-rpc-provider@npm:^0.0.1": + version: 0.0.1 + resolution: "@polkadot-api/json-rpc-provider@npm:0.0.1" + checksum: 10/1f315bdadcba7def7145011132e6127b983c6f91f976be217ad7d555bb96a67f3a270fe4a46e427531822c5d54d353d84a6439d112a99cdfc07013d3b662ee3c + languageName: node + linkType: hard + +"@polkadot-api/metadata-builders@npm:0.3.2": + version: 0.3.2 + resolution: "@polkadot-api/metadata-builders@npm:0.3.2" + dependencies: + "@polkadot-api/substrate-bindings": "npm:0.6.0" + "@polkadot-api/utils": "npm:0.1.0" + checksum: 10/874b38e1fb92beea99b98b889143f25671f137e54113767aeabb79ff5cdf7d61cadb0121f08c7a9a40718b924d7c9a1dd700f81e7e287bc55923b0129e2a6160 + languageName: node + linkType: hard + +"@polkadot-api/observable-client@npm:^0.3.0": + version: 0.3.2 + resolution: "@polkadot-api/observable-client@npm:0.3.2" + dependencies: + "@polkadot-api/metadata-builders": "npm:0.3.2" + "@polkadot-api/substrate-bindings": "npm:0.6.0" + "@polkadot-api/utils": "npm:0.1.0" + peerDependencies: + "@polkadot-api/substrate-client": 0.1.4 + rxjs: ">=7.8.0" + checksum: 10/91b95a06e3ddd477c2489110d7cffdcfaf87a222054b437013c701dc43eac6a5d30438b1ac8fb130166ba039a67808e6199ccb3b2eaac7dcf8d2ef7a835f047b + languageName: node + linkType: hard + +"@polkadot-api/substrate-bindings@npm:0.6.0": + version: 0.6.0 + resolution: "@polkadot-api/substrate-bindings@npm:0.6.0" + dependencies: + "@noble/hashes": "npm:^1.3.1" + "@polkadot-api/utils": "npm:0.1.0" + "@scure/base": "npm:^1.1.1" + scale-ts: "npm:^1.6.0" + checksum: 10/01926a9083f608514a55c3d23563ebef139e2963d4adbebe7dcd99b65e1a08f1551fc0e147e787a31c749402767333c96eb1399f85a6c71654cfa1cc9d26e445 + languageName: node + linkType: hard + +"@polkadot-api/substrate-client@npm:^0.1.2": + version: 0.1.4 + resolution: "@polkadot-api/substrate-client@npm:0.1.4" + dependencies: + "@polkadot-api/json-rpc-provider": "npm:0.0.1" + "@polkadot-api/utils": "npm:0.1.0" + checksum: 10/e7172696db404676d297cd5661b195de110593769f9ce37f32bdb5576ca00c56d32fcb04172a91102986fdda27a13962d909ad9466869a2991611d658ee6ac92 + languageName: node + linkType: hard + +"@polkadot-api/utils@npm:0.1.0": + version: 0.1.0 + resolution: "@polkadot-api/utils@npm:0.1.0" + checksum: 10/c557daea91ddb03e16b93c7c5a75533495c7b77cbbbdc2b4f5e97af0c1e1132a47e434c9c729a08241bd7b3624b6644ac0950f914aa8b29a0f419bf0fd224c7c + languageName: node + linkType: hard + +"@polkadot/api-augment@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/api-augment@npm:16.5.6" + dependencies: + "@polkadot/api-base": "npm:16.5.6" + "@polkadot/rpc-augment": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-augment": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/155e90fb8b11ae9d6fc1db1108ddb231187764ab5f42f0b2dca0c0d2a5e8ac5f833a7a32cfb9f401dea4395b631af99354e312432b41973281358e7fa05c5a26 + languageName: node + linkType: hard + +"@polkadot/api-base@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/api-base@npm:16.5.6" + dependencies: + "@polkadot/rpc-core": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + rxjs: "npm:^7.8.1" + tslib: "npm:^2.8.1" + checksum: 10/28c238896a3150f3cd405c7d204992b70e9704b04075e7bee440b590701ed025f5baa5a25d81c7396aa0e2d77a63ed7c17a489451d758edd75183198b4552a69 + languageName: node + linkType: hard + +"@polkadot/api-derive@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/api-derive@npm:16.5.6" + dependencies: + "@polkadot/api": "npm:16.5.6" + "@polkadot/api-augment": "npm:16.5.6" + "@polkadot/api-base": "npm:16.5.6" + "@polkadot/rpc-core": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + "@polkadot/util-crypto": "npm:^14.0.3" + rxjs: "npm:^7.8.1" + tslib: "npm:^2.8.1" + checksum: 10/493be1bfa7807d6c39f8bef9569f1d5ae9e87e2330bd561a2dcf59a3bfec71c2cd260e33005c752d17a6e24195184e18db7a1a80309af9738bb0070a7f3b90db + languageName: node + linkType: hard + +"@polkadot/api@npm:16.5.6, @polkadot/api@npm:^16.5.4": + version: 16.5.6 + resolution: "@polkadot/api@npm:16.5.6" + dependencies: + "@polkadot/api-augment": "npm:16.5.6" + "@polkadot/api-base": "npm:16.5.6" + "@polkadot/api-derive": "npm:16.5.6" + "@polkadot/keyring": "npm:^14.0.3" + "@polkadot/rpc-augment": "npm:16.5.6" + "@polkadot/rpc-core": "npm:16.5.6" + "@polkadot/rpc-provider": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-augment": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/types-create": "npm:16.5.6" + "@polkadot/types-known": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + "@polkadot/util-crypto": "npm:^14.0.3" + eventemitter3: "npm:^5.0.1" + rxjs: "npm:^7.8.1" + tslib: "npm:^2.8.1" + checksum: 10/bfd3c7d8f4e69fa405eafcc437abfe7d69754301f280459c4665cc4bb2d55e62741967cd72bfbec15dbbacc343c261f9480e073fd5d534da24aabc013be0b7da + languageName: node + linkType: hard + +"@polkadot/keyring@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/keyring@npm:14.0.3" + dependencies: + "@polkadot/util": "npm:14.0.3" + "@polkadot/util-crypto": "npm:14.0.3" + tslib: "npm:^2.8.0" + peerDependencies: + "@polkadot/util": 14.0.3 + "@polkadot/util-crypto": 14.0.3 + checksum: 10/69f9f776363f8327d72b43794262ae709fc2824182637e499ed6e9ca94315645d78005bf1f25bdfb7305e5d79879cb932c114e6612467ddf21a760117834e8a2 + languageName: node + linkType: hard + +"@polkadot/networks@npm:14.0.3, @polkadot/networks@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/networks@npm:14.0.3" + dependencies: + "@polkadot/util": "npm:14.0.3" + "@substrate/ss58-registry": "npm:^1.51.0" + tslib: "npm:^2.8.0" + checksum: 10/eb006f537f103b0d417e52966d0098b528326d1ebbae84e4c7834627bb3e863b7b849856992aa58c4a0aeb0ed1e1838a9619aeba7610d0e7c75e99ffcc6c9ecd + languageName: node + linkType: hard + +"@polkadot/rpc-augment@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/rpc-augment@npm:16.5.6" + dependencies: + "@polkadot/rpc-core": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/77abf8d1ced793a489a6b0888f190ac0d3b1fe03f310ec34f2f2dc5b646bd23606cf6dd93e660cb7383995931672a36e1e9ab642e9c8010d60fab83ccdd0ac42 + languageName: node + linkType: hard + +"@polkadot/rpc-core@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/rpc-core@npm:16.5.6" + dependencies: + "@polkadot/rpc-augment": "npm:16.5.6" + "@polkadot/rpc-provider": "npm:16.5.6" + "@polkadot/types": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + rxjs: "npm:^7.8.1" + tslib: "npm:^2.8.1" + checksum: 10/795d504e109367d1bf41f27e90b440968e06f5b86c1ef9e5806d98bd38036cc1dd5bbe9aeb539b1e81865d78a0957a22341b9397372c0e6b748cdc51ca79ea30 + languageName: node + linkType: hard + +"@polkadot/rpc-provider@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/rpc-provider@npm:16.5.6" + dependencies: + "@polkadot/keyring": "npm:^14.0.3" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-support": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + "@polkadot/util-crypto": "npm:^14.0.3" + "@polkadot/x-fetch": "npm:^14.0.3" + "@polkadot/x-global": "npm:^14.0.3" + "@polkadot/x-ws": "npm:^14.0.3" + "@substrate/connect": "npm:0.8.11" + eventemitter3: "npm:^5.0.1" + mock-socket: "npm:^9.3.1" + nock: "npm:^13.5.5" + tslib: "npm:^2.8.1" + dependenciesMeta: + "@substrate/connect": + optional: true + checksum: 10/06913cb6887652896a47aef6fef3cb811d9bed577a4d13c570baa0c8df401ecfcaec58f27d338d0d6c6319acbfc3b6a4b4a837679fae089dcec0bd1babd9e418 + languageName: node + linkType: hard + +"@polkadot/types-augment@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/types-augment@npm:16.5.6" + dependencies: + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/b2b300af0cac2394d1b95a907e25b1f78d3af7502186c6bc2f3eef51928c6638d6db8e55de57a6ddbef0b621d5d6a36311aefa1820f23d61bd86f3a6d20108c8 + languageName: node + linkType: hard + +"@polkadot/types-codec@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/types-codec@npm:16.5.6" + dependencies: + "@polkadot/util": "npm:^14.0.3" + "@polkadot/x-bigint": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/80cd00315e19d5521732ee0c676444dbf7081ff056ccd070b665064cda0d364a7b434c39a23a68af89c20e2020b93ce281eef8d4a7db28161ce88ee92ce7dd07 + languageName: node + linkType: hard + +"@polkadot/types-create@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/types-create@npm:16.5.6" + dependencies: + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/553c023d34fefdac5461cdc8c8d451a669dfbc15c2bd1f24b0836a68829ad06b5329487091a21bd7d557f76b2fb364a53f33a32f9da1ae8e3474a32f2da61127 + languageName: node + linkType: hard + +"@polkadot/types-known@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/types-known@npm:16.5.6" + dependencies: + "@polkadot/networks": "npm:^14.0.3" + "@polkadot/types": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/types-create": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/6681e5189e0f16127379981c44d6abb35829e2731961ed6996c06bfc8c5f811fc26010f4213ea2e1f06c36b174576ef2f64f783bebd7e38c735cc06445ee557f + languageName: node + linkType: hard + +"@polkadot/types-support@npm:16.5.6": + version: 16.5.6 + resolution: "@polkadot/types-support@npm:16.5.6" + dependencies: + "@polkadot/util": "npm:^14.0.3" + tslib: "npm:^2.8.1" + checksum: 10/d43b902392af367adde8d9492161ca7a5ae6acc7d3c9b87e9633896b25d3ba783a96e5a00436a137e55c231d1465ae9c5d15472ec674051c917401106655de80 + languageName: node + linkType: hard + +"@polkadot/types@npm:16.5.6, @polkadot/types@npm:^16.5.4": + version: 16.5.6 + resolution: "@polkadot/types@npm:16.5.6" + dependencies: + "@polkadot/keyring": "npm:^14.0.3" + "@polkadot/types-augment": "npm:16.5.6" + "@polkadot/types-codec": "npm:16.5.6" + "@polkadot/types-create": "npm:16.5.6" + "@polkadot/util": "npm:^14.0.3" + "@polkadot/util-crypto": "npm:^14.0.3" + rxjs: "npm:^7.8.1" + tslib: "npm:^2.8.1" + checksum: 10/85c3ad043d16216f9b49fbb613d17c0af70ba817f20c3fa287e0ff628d3a5338ce4e7505e74a59610f1eb0b4f26b2a8701c3f25c1e90f7c95f2e3bde1fc5391b + languageName: node + linkType: hard + +"@polkadot/util-crypto@npm:14.0.3, @polkadot/util-crypto@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/util-crypto@npm:14.0.3" + dependencies: + "@noble/curves": "npm:^1.3.0" + "@noble/hashes": "npm:^1.3.3" + "@polkadot/networks": "npm:14.0.3" + "@polkadot/util": "npm:14.0.3" + "@polkadot/wasm-crypto": "npm:^7.5.3" + "@polkadot/wasm-util": "npm:^7.5.3" + "@polkadot/x-bigint": "npm:14.0.3" + "@polkadot/x-randomvalues": "npm:14.0.3" + "@scure/base": "npm:^1.1.7" + "@scure/sr25519": "npm:^0.2.0" + tslib: "npm:^2.8.0" + peerDependencies: + "@polkadot/util": 14.0.3 + checksum: 10/e8f2da806cb81d3c014415bdd633f0fc5871132ce790ca892f65899010386d64fa25f7c047574cc96402afa03b5ff77e4dff904e69b90e714a7150e18ef0f507 + languageName: node + linkType: hard + +"@polkadot/util@npm:14.0.3, @polkadot/util@npm:^14.0.1, @polkadot/util@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/util@npm:14.0.3" + dependencies: + "@polkadot/x-bigint": "npm:14.0.3" + "@polkadot/x-global": "npm:14.0.3" + "@polkadot/x-textdecoder": "npm:14.0.3" + "@polkadot/x-textencoder": "npm:14.0.3" + "@types/bn.js": "npm:^5.1.6" + bn.js: "npm:^5.2.1" + tslib: "npm:^2.8.0" + checksum: 10/7731f26f363696a2e313fdd44d870d711924e8d24200e1c5e88769e02c220af99382460372caa1715511548753e1e3d5c1466a02308b0d4dec0700ec0ab4e88b + languageName: node + linkType: hard + +"@polkadot/wasm-bridge@npm:7.5.4": + version: 7.5.4 + resolution: "@polkadot/wasm-bridge@npm:7.5.4" + dependencies: + "@polkadot/wasm-util": "npm:7.5.4" + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + "@polkadot/x-randomvalues": "*" + checksum: 10/64db5db90a82396032c31e6745b2e77817b8e9258841b72e506370ecf3ac63497efc654ca113419baf3c9b5fabda86bb21b29e1b508f192ab4e07beab8ef6d04 + languageName: node + linkType: hard + +"@polkadot/wasm-crypto-asmjs@npm:7.5.4": + version: 7.5.4 + resolution: "@polkadot/wasm-crypto-asmjs@npm:7.5.4" + dependencies: + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + checksum: 10/9e03f052b871bc9e33268b01025fe43789f2af40e4aabbe3b7d8348a0752001cd137c20ba66c58ee7d692e798d957024c7cbd0cbf1a8cf3e6baebbe67696e781 + languageName: node + linkType: hard + +"@polkadot/wasm-crypto-init@npm:7.5.4": + version: 7.5.4 + resolution: "@polkadot/wasm-crypto-init@npm:7.5.4" + dependencies: + "@polkadot/wasm-bridge": "npm:7.5.4" + "@polkadot/wasm-crypto-asmjs": "npm:7.5.4" + "@polkadot/wasm-crypto-wasm": "npm:7.5.4" + "@polkadot/wasm-util": "npm:7.5.4" + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + "@polkadot/x-randomvalues": "*" + checksum: 10/c1077a74156bd6356487043b23a849b214274c74fc44f1e2c203ec58f152c47c577f9da920ebf79ef746cfdfd2f246b1dd6a97c5796556f1c00e63d795eb896f + languageName: node + linkType: hard + +"@polkadot/wasm-crypto-wasm@npm:7.5.4": + version: 7.5.4 + resolution: "@polkadot/wasm-crypto-wasm@npm:7.5.4" + dependencies: + "@polkadot/wasm-util": "npm:7.5.4" + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + checksum: 10/338b5d4b347116efa09aba7f27f1d13e84a4ef62680ab02e2c47bbd43180844434cf49f8c954528cbb8bebef69bdf101be33e3a6fe093efd3f5ab2245f5e7faf + languageName: node + linkType: hard + +"@polkadot/wasm-crypto@npm:^7.5.3": + version: 7.5.4 + resolution: "@polkadot/wasm-crypto@npm:7.5.4" + dependencies: + "@polkadot/wasm-bridge": "npm:7.5.4" + "@polkadot/wasm-crypto-asmjs": "npm:7.5.4" + "@polkadot/wasm-crypto-init": "npm:7.5.4" + "@polkadot/wasm-crypto-wasm": "npm:7.5.4" + "@polkadot/wasm-util": "npm:7.5.4" + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + "@polkadot/x-randomvalues": "*" + checksum: 10/d4edce7bc9e8fa8387abe1d3fa4433937ab40faf4889a949a5a64c42f852837e3da96c00a73fb383fc8ef3fe177ac40dc85a13bcd43b059f2d04bab52f537801 + languageName: node + linkType: hard + +"@polkadot/wasm-util@npm:7.5.4, @polkadot/wasm-util@npm:^7.5.3": + version: 7.5.4 + resolution: "@polkadot/wasm-util@npm:7.5.4" + dependencies: + tslib: "npm:^2.7.0" + peerDependencies: + "@polkadot/util": "*" + checksum: 10/4dda837f3ac84705d709a2e62fc0f9ec54518dbae88d3bf9dc68b65f17f50eadf7fff4289f3deaf51f93d79d5ac0631ecf57ad572d55f98a11149beaa3b2bcc4 + languageName: node + linkType: hard + +"@polkadot/x-bigint@npm:14.0.3, @polkadot/x-bigint@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-bigint@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + tslib: "npm:^2.8.0" + checksum: 10/82017c7046c9d65af15cead3ebbaea08e07992e7fb081f7cc9175dae61988a0a352d923da57da5ee86fb8d671ab5449f6e630798b889002ea8b899d7e3d1b5d3 + languageName: node + linkType: hard + +"@polkadot/x-fetch@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-fetch@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + node-fetch: "npm:^3.3.2" + tslib: "npm:^2.8.0" + checksum: 10/cf9add8a351d8021ea9728ea648ad34d3244de2848cf90cb08037d73b16b63251577beb4590669dcff1bd1f64c99b62cb059831b333ea07a047bc0b33f79a0e7 + languageName: node + linkType: hard + +"@polkadot/x-global@npm:14.0.3, @polkadot/x-global@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-global@npm:14.0.3" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10/5d75b2097ae7f279efdc49c02e7f4deb5ffa131250f25439bcf7f1a334e3ae525467520521424cca62a198f396ee9f5c321f591cb9b55f1b2aeaf69cd129c829 + languageName: node + linkType: hard + +"@polkadot/x-randomvalues@npm:14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-randomvalues@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + tslib: "npm:^2.8.0" + peerDependencies: + "@polkadot/util": 14.0.3 + "@polkadot/wasm-util": "*" + checksum: 10/03aa905b34f2eefc038d1a8edaf41a631aef36e229235d40d965a460ca127c027753bad0954ca889967877ba7d13d1fc5b49dc86d6637c1f98596c9ad600cb04 + languageName: node + linkType: hard + +"@polkadot/x-textdecoder@npm:14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-textdecoder@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + tslib: "npm:^2.8.0" + checksum: 10/3ec2210f9d3b0f5cab0a2b39575dd3d0393aed141e8cb9cc743573b17ea201d08c6f28aebc6acafd9eae9362ad6b223091486131a53409b684a3ddecbce19250 + languageName: node + linkType: hard + +"@polkadot/x-textencoder@npm:14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-textencoder@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + tslib: "npm:^2.8.0" + checksum: 10/541fd458433e153683ac41e8d6c060a2e46dd29ff5638abf992dd5ea7838a3514b4ee1d9ca11d50b384d3d001fb1347f01e176531cca10bfc4840b4736cdd474 + languageName: node + linkType: hard + +"@polkadot/x-ws@npm:^14.0.3": + version: 14.0.3 + resolution: "@polkadot/x-ws@npm:14.0.3" + dependencies: + "@polkadot/x-global": "npm:14.0.3" + tslib: "npm:^2.8.0" + ws: "npm:^8.18.0" + checksum: 10/c66b7f9c5857884ec94abe5796372816d1029e2f81078f026eef12456ef0971f59e2d678fec347f3bdf6f755834a41074b4b6177f10ec2a7b56a19d35825ac8b + languageName: node + linkType: hard + +"@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/aspromise@npm:1.1.2" + checksum: 10/8a938d84fe4889411296db66b29287bd61ea3c14c2d23e7a8325f46a2b8ce899857c5f038d65d7641805e6c1d06b495525c7faf00c44f85a7ee6476649034969 + languageName: node + linkType: hard + +"@protobufjs/base64@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/base64@npm:1.1.2" + checksum: 10/c71b100daeb3c9bdccab5cbc29495b906ba0ae22ceedc200e1ba49717d9c4ab15a6256839cebb6f9c6acae4ed7c25c67e0a95e734f612b258261d1a3098fe342 + languageName: node + linkType: hard + +"@protobufjs/codegen@npm:^2.0.5": + version: 2.0.5 + resolution: "@protobufjs/codegen@npm:2.0.5" + checksum: 10/290335fa114f26202abc0695f279d53e2fd516b01cfd8298923591e0bda011295ff40e3582a1cda0a0f27cbc5039a0292082d5ad08872bb5d6243a614ac15c88 + languageName: node + linkType: hard + +"@protobufjs/eventemitter@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/eventemitter@npm:1.1.0" + checksum: 10/03af3e99f17ad421283d054c88a06a30a615922a817741b43ca1b13e7c6b37820a37f6eba9980fb5150c54dba6e26cb6f7b64a6f7d8afa83596fafb3afa218c3 + languageName: node + linkType: hard + +"@protobufjs/fetch@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/fetch@npm:1.1.1" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.1" + checksum: 10/427cf2da8c69b494b0df3b2fb1f43c97f0f71ca2c8ef8232dac7e44f2527ad0cc9cecb243eda14a918e86018bfa6d54d92252240d2b37ed205b13adb5506fa1d + languageName: node + linkType: hard + +"@protobufjs/float@npm:^1.0.2": + version: 1.0.2 + resolution: "@protobufjs/float@npm:1.0.2" + checksum: 10/634c2c989da0ef2f4f19373d64187e2a79f598c5fb7991afb689d29a2ea17c14b796b29725945fa34b9493c17fb799e08ac0a7ccaae460ee1757d3083ed35187 + languageName: node + linkType: hard + +"@protobufjs/inquire@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/inquire@npm:1.1.2" + checksum: 10/259756489c75a751552df60d18f82503d2534855646397b96b91cf15807fa852e99bd9eb73dabb64da37aec7913844032ecb031a4326d82aae622f5e4c2f8a17 + languageName: node + linkType: hard + +"@protobufjs/path@npm:^1.1.2": + version: 1.1.2 + resolution: "@protobufjs/path@npm:1.1.2" + checksum: 10/bb709567935fd385a86ad1f575aea98131bbd719c743fb9b6edd6b47ede429ff71a801cecbd64fc72deebf4e08b8f1bd8062793178cdaed3713b8d15771f9b83 + languageName: node + linkType: hard + +"@protobufjs/pool@npm:^1.1.0": + version: 1.1.0 + resolution: "@protobufjs/pool@npm:1.1.0" + checksum: 10/b9c7047647f6af28e92aac54f6f7c1f7ff31b201b4bfcc7a415b2861528854fce3ec666d7e7e10fd744da905f7d4aef2205bbcc8944ca0ca7a82e18134d00c46 + languageName: node + linkType: hard + +"@protobufjs/utf8@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/utf8@npm:1.1.1" + checksum: 10/ed0c3f9ff1afd602a0aed54c4c03a0b8f641686a5587d8949e088dcac653fb2019d15691ed92eef23dfdf9f4293249532d0508ecd15cef810acf026917719a19 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-android-arm64@npm:1.0.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.3" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10/4e95cf9ce23d75e5aa03ea0249cd86f7d1e21f83fbf6f8520e4edd8a251ba1b82c4ba9bc13cd24b6c4661daec6225b06e6d35c64c604e731b230b2a49af47d05 + languageName: node + linkType: hard + +"@scure/base@npm:2.2.0, @scure/base@npm:^2.0.0": + version: 2.2.0 + resolution: "@scure/base@npm:2.2.0" + checksum: 10/b52ec9cd54bad77e22f881b6924ccab692dc1c6dd10287d1787bf263e9f1e560d6d2bda906538fb9a39615d61a1b5c2f53f57a511667fd10e93b9cdaa6fb5d2a + languageName: node + linkType: hard + +"@scure/base@npm:^1.1.1, @scure/base@npm:^1.1.7, @scure/base@npm:~1.2.5": + version: 1.2.6 + resolution: "@scure/base@npm:1.2.6" + checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 + languageName: node + linkType: hard + +"@scure/bip32@npm:^2.0.1": + version: 2.2.0 + resolution: "@scure/bip32@npm:2.2.0" + dependencies: + "@noble/curves": "npm:2.2.0" + "@noble/hashes": "npm:2.2.0" + "@scure/base": "npm:2.2.0" + checksum: 10/595875bdfdd153621a35d71b73bb77e1406b5d659bbd20fc4db3fed697d72d39a62c8a6b2bb9816ce4e50199200252008ae203cd637f3acf1e0821180755cd3d + languageName: node + linkType: hard + +"@scure/bip39@npm:^1.2.1": + version: 1.6.0 + resolution: "@scure/bip39@npm:1.6.0" + dependencies: + "@noble/hashes": "npm:~1.8.0" + "@scure/base": "npm:~1.2.5" + checksum: 10/63e60c40fa1bda2c1b50351546fee6d7b0947cc814aa7a4209dcedd3693b5053302c8fca28292f5f50735e11c613265359acdc019127393dbab17e53489fc449 + languageName: node + linkType: hard + +"@scure/bip39@npm:^2.0.1": + version: 2.2.0 + resolution: "@scure/bip39@npm:2.2.0" + dependencies: + "@noble/hashes": "npm:2.2.0" + "@scure/base": "npm:2.2.0" + checksum: 10/f8f05c9f1337f694e1b490dcc795ac0da87e3cb4e5377889c19caa910c46567aa6b4071f2fc102fffb76020c221e09ffe9e1dde471728224335713c55cbfb182 + languageName: node + linkType: hard + +"@scure/sr25519@npm:^0.2.0": + version: 0.2.0 + resolution: "@scure/sr25519@npm:0.2.0" + dependencies: + "@noble/curves": "npm:~1.9.2" + "@noble/hashes": "npm:~1.8.0" + checksum: 10/3c47b474811642b43fd8c96f7846c9d88c9a06eefa7d6360b6421ebdfb6cf582e1e8fdce9ae4708b088a0e323cd6519c883c3a33a284c2fad592414b02f19049 + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10/aee780cc1431888ca4b9aba9b24ffc8f3073fc083acc105e3951481478a2f4dc957796931b2da9e2d8329584cf211e4542275f188296c1cdff3ed44fd93a8bc8 + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10/a209615c9e8b2ea535d7db0a5f6aa0f962fd4ab73ee86a46c100fb78116964af1f55a27c1794d4801e534a196794223daa25ff5135021e03c7828aa3d95e1763 + languageName: node + linkType: hard + +"@subsquid/scale-codec@npm:^4.0.1": + version: 4.0.1 + resolution: "@subsquid/scale-codec@npm:4.0.1" + dependencies: + "@subsquid/util-internal-hex": "npm:^1.2.2" + "@subsquid/util-internal-json": "npm:^1.2.2" + checksum: 10/d0c81f43c6c93d6885baa0992dd170c94e8259b2eb500694b62b8ca25624c78bb7e4815b1120bbb7f3ed0e7eda02cd02233e1d8b5bac903322731ff3c9fb42bc + languageName: node + linkType: hard + +"@subsquid/util-internal-hex@npm:^1.2.2": + version: 1.2.3 + resolution: "@subsquid/util-internal-hex@npm:1.2.3" + checksum: 10/d3feeb16e130d7a5281bbd98c0ddc9a44d3c49f2655766d4e97d16407c8466b3b246bbefecfb397580f2402dc62b45065c8e62ce986b14935246b1252e66d347 + languageName: node + linkType: hard + +"@subsquid/util-internal-json@npm:^1.2.2": + version: 1.2.3 + resolution: "@subsquid/util-internal-json@npm:1.2.3" + dependencies: + "@subsquid/util-internal-hex": "npm:^1.2.2" + checksum: 10/9a518c8fc56066778b0535ed243024e17f958d9020d99d5444657fd877d7da3adc1f34b3f0e621cb8365729bc9e10aeb63bb24b91e579eb413ef8cbbab66c81d + languageName: node + linkType: hard + +"@substrate/connect-extension-protocol@npm:^2.0.0": + version: 2.2.2 + resolution: "@substrate/connect-extension-protocol@npm:2.2.2" + checksum: 10/b5427526dafcbd0ec45d3ce7ef7a3d1018496cae7d8ef60f545d4e143420b3e51fe37af966f493e73f4cb9383bc78af756cdc19294e633240c8a86c620b3d8b5 + languageName: node + linkType: hard + +"@substrate/connect-known-chains@npm:^1.1.5": + version: 1.10.3 + resolution: "@substrate/connect-known-chains@npm:1.10.3" + checksum: 10/b0b4e2914a9c8c0576196ff78f7d0a1ccaf3ee2a02f0b710ee5e79153fdcd4be36e5b7a58998ea72d13f9251dc13d448967114da14efc6aa1891eda284d066bb + languageName: node + linkType: hard + +"@substrate/connect@npm:0.8.11": + version: 0.8.11 + resolution: "@substrate/connect@npm:0.8.11" + dependencies: + "@substrate/connect-extension-protocol": "npm:^2.0.0" + "@substrate/connect-known-chains": "npm:^1.1.5" + "@substrate/light-client-extension-helpers": "npm:^1.0.0" + smoldot: "npm:2.0.26" + checksum: 10/380ba85aa3aec4439fae2ee42173376615ca60262d9c37e6e43d1d65d0d0f63f38c009bb476e9a612b0b9985c1b5808c4d9a75aff9e1828c77e75c8b7584d824 + languageName: node + linkType: hard + +"@substrate/light-client-extension-helpers@npm:^1.0.0": + version: 1.0.0 + resolution: "@substrate/light-client-extension-helpers@npm:1.0.0" + dependencies: + "@polkadot-api/json-rpc-provider": "npm:^0.0.1" + "@polkadot-api/json-rpc-provider-proxy": "npm:^0.1.0" + "@polkadot-api/observable-client": "npm:^0.3.0" + "@polkadot-api/substrate-client": "npm:^0.1.2" + "@substrate/connect-extension-protocol": "npm:^2.0.0" + "@substrate/connect-known-chains": "npm:^1.1.5" + rxjs: "npm:^7.8.1" + peerDependencies: + smoldot: 2.x + checksum: 10/ca0726e8271aa9eb4f1edbb13e7f6986d45c9a4ae9a73a1a14aa9a41552821ca291a33459b7e8fc1ec1bde1ead9336a8bca4fb8781c060d5cbdd7e59ca96cb2d + languageName: node + linkType: hard + +"@substrate/ss58-registry@npm:^1.51.0": + version: 1.51.0 + resolution: "@substrate/ss58-registry@npm:1.51.0" + checksum: 10/34eb21292f543a8be7c62ad3bcdae89d61c8a51e35a0be4687b6b4e955b5180a90a7691a9e6779f7509f8dfcfdfa372d8278087a9668521b9c501adb85c915b6 + languageName: node + linkType: hard + +"@tsconfig/node10@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node10@npm:1.0.11" + checksum: 10/51fe47d55fe1b80ec35e6e5ed30a13665fd3a531945350aa74a14a1e82875fb60b350c2f2a5e72a64831b1b6bc02acb6760c30b3738b54954ec2dea82db7a267 + languageName: node + linkType: hard + +"@tsconfig/node12@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node12@npm:1.0.11" + checksum: 10/5ce29a41b13e7897a58b8e2df11269c5395999e588b9a467386f99d1d26f6c77d1af2719e407621412520ea30517d718d5192a32403b8dfcc163bf33e40a338a + languageName: node + linkType: hard + +"@tsconfig/node14@npm:^1.0.0": + version: 1.0.3 + resolution: "@tsconfig/node14@npm:1.0.3" + checksum: 10/19275fe80c4c8d0ad0abed6a96dbf00642e88b220b090418609c4376e1cef81bf16237bf170ad1b341452feddb8115d8dd2e5acdfdea1b27422071163dc9ba9d + languageName: node + linkType: hard + +"@tsconfig/node16@npm:^1.0.2": + version: 1.0.4 + resolution: "@tsconfig/node16@npm:1.0.4" + checksum: 10/202319785901f942a6e1e476b872d421baec20cf09f4b266a1854060efbf78cde16a4d256e8bc949d31e6cd9a90f1e8ef8fb06af96a65e98338a2b6b0de0a0ff + languageName: node + linkType: hard + +"@tsconfig/node24@npm:^24.0.3": + version: 24.0.3 + resolution: "@tsconfig/node24@npm:24.0.3" + checksum: 10/02318441e4fc1a493be0ea4299813f952cc564e8eac7b23508f22a1774692e7b362ba58b9dddd9ffb052d1c9fadc4f1937f5590bc581584fe662ae83512feb8d + languageName: node + linkType: hard + +"@turbo/darwin-64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/darwin-64@npm:2.9.16" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@turbo/darwin-arm64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/darwin-arm64@npm:2.9.16" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@turbo/linux-64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/linux-64@npm:2.9.16" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@turbo/linux-arm64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/linux-arm64@npm:2.9.16" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@turbo/windows-64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/windows-64@npm:2.9.16" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@turbo/windows-arm64@npm:2.9.16": + version: 2.9.16 + resolution: "@turbo/windows-arm64@npm:2.9.16" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.1": + version: 0.10.2 + resolution: "@tybys/wasm-util@npm:0.10.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/d12f1dafe12d7a573c406b35ffef0038042b9cc9fbcc74d657267eb635499b956276afc05eebdbd81bea582e1c4c921421a1dd7243a93daaa8c8216b19395c23 + languageName: node + linkType: hard + +"@types/bn.js@npm:^5.1.6, @types/bn.js@npm:^5.2.0": + version: 5.2.0 + resolution: "@types/bn.js@npm:5.2.0" + dependencies: + "@types/node": "npm:*" + checksum: 10/06c93841f74e4a5e5b81b74427d56303b223c9af36389b4cd3c562bda93f43c425c7e241aee1b0b881dde57238dc2e07f21d30d412b206a7dae4435af4c054e8 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10/e79947307dc235953622e65f83d2683835212357ca261389116ab90bed369ac862ba28b146b4fed08b503ae1e1a12cb93ce783f24bb8d562950469f4320e1c7c + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10/249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c + languageName: node + linkType: hard + +"@types/docker-modem@npm:*": + version: 3.0.6 + resolution: "@types/docker-modem@npm:3.0.6" + dependencies: + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10/cc58e8189f6ec5a2b8ca890207402178a97ddac8c80d125dc65d8ab29034b5db736de15e99b91b2d74e66d14e26e73b6b8b33216613dd15fd3aa6b82c11a83ed + languageName: node + linkType: hard + +"@types/dockerode@npm:^3.3.35": + version: 3.3.47 + resolution: "@types/dockerode@npm:3.3.47" + dependencies: + "@types/docker-modem": "npm:*" + "@types/node": "npm:*" + "@types/ssh2": "npm:*" + checksum: 10/b840ae7872398a3b02e5789006a69d0cf5bb7ec6c0eb714c7ca04ca093add8de4cd06204ecd8f01388e347e62927cf4c599e8b7dba53e81c1350910da766d517 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10/25a4c16a6752538ffde2826c2cc0c6491d90e69cd6187bef4a006dd2c3c45469f049e643d7e516c515f21484dc3d48fd5c870be158a5beb72f5baf3dc43e4099 + languageName: node + linkType: hard + +"@types/node@npm:*, @types/node@npm:>=13.7.0": + version: 25.9.0 + resolution: "@types/node@npm:25.9.0" + dependencies: + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10/8725e4e3191ba81626b322cfb80b62064c687d5da2983d7318068069f940a9c019e6f342a674ccc4ad26ef6f0a5dcbc7451a81610155ca2c6d5202800b144a19 + languageName: node + linkType: hard + +"@types/node@npm:24.10.1": + version: 24.10.1 + resolution: "@types/node@npm:24.10.1" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10/ddac8c97be5f7401e31ea0e9316c6e864993c6cd06689b7f9874ecfb576ef8349f2d14298248a08b94a6dd029570a46a285cddc4d50e524817f1a3730b29a86e + languageName: node + linkType: hard + +"@types/node@npm:25.9.1": + version: 25.9.1 + resolution: "@types/node@npm:25.9.1" + dependencies: + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10/8a1ccf60f0c0ca856d3324a690ee35776f26dfc1d51c3763aecdf246a3246a7971a0156bf6eb3239aa22dfa940eb361d048212f5c3204264d31ef4c41d17416a + languageName: node + linkType: hard + +"@types/node@npm:^18.11.18": + version: 18.19.130 + resolution: "@types/node@npm:18.19.130" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10/ebb85c6edcec78df926de27d828ecbeb1b3d77c165ceef95bfc26e171edbc1924245db4eb2d7d6230206fe6b1a1f7665714fe1c70739e9f5980d8ce31af6ef82 + languageName: node + linkType: hard + +"@types/object-inspect@npm:^1.8.1": + version: 1.13.0 + resolution: "@types/object-inspect@npm:1.13.0" + checksum: 10/8caf52c815947540b5246e0b5b2d455a2183791fe9427537eab8a40b465392400cee6ce50beaeb35465e167e9cb405ccfde90eb5317ee2c9df85af7508f0a320 + languageName: node + linkType: hard + +"@types/shell-quote@npm:^1.7.5": + version: 1.7.5 + resolution: "@types/shell-quote@npm:1.7.5" + checksum: 10/32b4d697c7d23dbadf40713692c47f1595f083a3b3deea76cb18e30a05d197aa9205d2b87f6d92edb60cda120b51e35d32bda96ed9b0a7e32921eed2deb4559e + languageName: node + linkType: hard + +"@types/ssh2-streams@npm:*": + version: 0.1.13 + resolution: "@types/ssh2-streams@npm:0.1.13" + dependencies: + "@types/node": "npm:*" + checksum: 10/182c9de8384e11fcfed04e447c3c1d37f898ed4e7f0be0cc58b3bd5b23e22957c17939b68f709092cece758a4befa92913dd967115f643fa0e2dc629fc2e2383 + languageName: node + linkType: hard + +"@types/ssh2@npm:*": + version: 1.15.5 + resolution: "@types/ssh2@npm:1.15.5" + dependencies: + "@types/node": "npm:^18.11.18" + checksum: 10/dd6f29f4e96ea43aa61d29a4a3ad87ad8d11bf1bef637b2848958abd94b05d28754cc611eac13f52d43bd1f51afe7c660cd1c8533ae06878b5739888f4ea0d99 + languageName: node + linkType: hard + +"@types/ssh2@npm:^0.5.48": + version: 0.5.52 + resolution: "@types/ssh2@npm:0.5.52" + dependencies: + "@types/node": "npm:*" + "@types/ssh2-streams": "npm:*" + checksum: 10/fc2584af091da49da9d6628dd8a5e851b217bb9b1b732b0361903894f2730ab3fdf8634f954be34c5a513f7eb0b2772d059d64062bcf6b4a0eb73bfc83c4b858 + languageName: node + linkType: hard + +"@types/ws@npm:^8.5.10": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/coverage-v8@npm:4.1.8" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.2" + "@vitest/utils": "npm:4.1.8" + ast-v8-to-istanbul: "npm:^1.0.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.2.0" + magicast: "npm:^0.5.2" + obug: "npm:^2.1.1" + std-env: "npm:^4.0.0-rc.1" + tinyrainbow: "npm:^3.1.0" + peerDependencies: + "@vitest/browser": 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10/08d9ea65ca4cc007a1f1cdc85ea36d51bfa91a9b2f0e9ad27436b777629b4138e33dba2f68c8e68b01343310bf9d5624ad1d6d24553a5b289b66da51561259eb + languageName: node + linkType: hard + +"@vitest/expect@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/expect@npm:4.1.8" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10/cb7d78e250ec77b7e180ac3e5f543501488c69b237d7ed97ffe9196c5e946b0e4a37be05a2ec38af7ce7750c1a98286480acdd247286a29c239b08a13b085d4b + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/mocker@npm:4.1.8" + dependencies: + "@vitest/spy": "npm:4.1.8" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10/fc977703b07d950aa170bafdef988bc7ba88f0a80159d1563ce95696763729ec1f6d015012aad36cf4e1b522d327b205292c56d76692d2a9f72285d694ed3cba + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/pretty-format@npm:4.1.8" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10/56a4b685cdf9f2e9708025f17dab8c0fa990ab06e5b38606a1ddde52a09830a099843da6a1b127ee48217ab023bad7bd23c49eb4969d77dff07df363fad0bb0e + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/runner@npm:4.1.8" + dependencies: + "@vitest/utils": "npm:4.1.8" + pathe: "npm:^2.0.3" + checksum: 10/278d1482123877343731b3bb822d0280af928252ee263aab73ca189c39de3bb767ce715581870b2e1eb408f7cba01106a6989406cb2ada1332f181912558a3c1 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/snapshot@npm:4.1.8" + dependencies: + "@vitest/pretty-format": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10/162ca0eccb72db02081b04307d21ac8d14c8fcd4a840872459274f589b1665f108bd4119dff19d5a2150a0e26b90531791ebec7ee74f0c2c5285b491cebbcfcb + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/spy@npm:4.1.8" + checksum: 10/53e948d8f5e229e969e704dc8a54fd42ad715b2b18f401592f4bba97dcf33bd4cf01d11af577d4efe42dc2d90c9e6574ec991531fd8f1bdfee916a1dd0828547 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.8": + version: 4.1.8 + resolution: "@vitest/utils@npm:4.1.8" + dependencies: + "@vitest/pretty-format": "npm:4.1.8" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10/13250b9e7825d425cc9a3d22aeb2e8d117c93e96a192138e93d76bfe7d5a391ab3888c5aa9e0394b0314bdff41e441ad7a32b0c0caa00cd202223b88087dcc78 + languageName: node + linkType: hard + +"@wry/caches@npm:^1.0.0": + version: 1.0.1 + resolution: "@wry/caches@npm:1.0.1" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10/055f592ee52b5fd9aa86e274e54e4a8b2650f619000bf6f61880ce14aaf47eb2ab34f3ada2eab964fe8b2f19bf8097ecacddcea4638fcc64c3d3a0a512aaa07c + languageName: node + linkType: hard + +"@wry/context@npm:^0.7.0": + version: 0.7.4 + resolution: "@wry/context@npm:0.7.4" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10/70d648949a97a035b2be2d6ddb716d4162113e850ab2c4c86331b2da94a7e826204080ce04eee2a95665bd3a0b245bf2ea3aae9adfa57b004ae0d2d49bdb5c8f + languageName: node + linkType: hard + +"@wry/equality@npm:^0.5.6": + version: 0.5.7 + resolution: "@wry/equality@npm:0.5.7" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10/69dccf33c0c41fd7ec5550f5703b857c6484a949412ad747001da941270ea436648c3ab988a2091765304249585ac30c7b417fad8be9a7ce19c1221f71548e35 + languageName: node + linkType: hard + +"@wry/trie@npm:^0.5.0": + version: 0.5.0 + resolution: "@wry/trie@npm:0.5.0" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10/578a08f3a96256c9b163230337183d9511fd775bdfe147a30561ccaacedc9ce33b9731ee6e591bb1f5f53e41b26789e519b47dff5100c7bf4e1cd2df3062f797 + languageName: node + linkType: hard + +"abbrev@npm:^3.0.0": + version: 3.0.1 + resolution: "abbrev@npm:3.0.1" + checksum: 10/ebd2c149dda6f543b66ce3779ea612151bb3aa9d0824f169773ee9876f1ca5a4e0adbcccc7eed048c04da7998e1825e2aa76fcca92d9e67dea50ac2b0a58dc2e + languageName: node + linkType: hard + +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10/ed84af329f1828327798229578b4fe03a4dd2596ba304083ebd2252666bdc1d7647d66d0b18704477e1f8aa315f055944aa6e859afebd341f12d0a53c37b4b40 + languageName: node + linkType: hard + +"abstract-level@npm:^3.0.0, abstract-level@npm:^3.1.0": + version: 3.1.1 + resolution: "abstract-level@npm:3.1.1" + dependencies: + buffer: "npm:^6.0.3" + is-buffer: "npm:^2.0.5" + level-supports: "npm:^6.2.0" + level-transcoder: "npm:^1.0.1" + maybe-combine-errors: "npm:^1.0.0" + module-error: "npm:^1.0.1" + checksum: 10/1a4d19efac7a8781972aa5e8a57dce39b3ada75a15c1ee25c8dce5978d72b5f9e2bc8d7fbfabafdc49b5941c5b1913465331864b3061fd0d0ed351a397624b46 + languageName: node + linkType: hard + +"acorn-walk@npm:^8.1.1": + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10/871386764e1451c637bb8ab9f76f4995d408057e9909be6fb5ad68537ae3375d85e6a6f170b98989f44ab3ff6c74ad120bc2779a3d577606e7a0cd2b4efcaf77 + languageName: node + linkType: hard + +"acorn@npm:^8.11.0, acorn@npm:^8.4.1": + version: 8.15.0 + resolution: "acorn@npm:8.15.0" + bin: + acorn: bin/acorn + checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 + languageName: node + linkType: hard + +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10/21fb903e0917e5cb16591b4d0ef6a028a54b83ac30cd1fca58dece3d4e0990512a8723f9f83130d88a41e2af8b1f7be1386fda3ea2d181bb1a62155e75e95e23 + languageName: node + linkType: hard + +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10/2aa4bb54caf2d622f1afdad09441695af2a83aa3fe8b8afa581d205e57ed4261c183c4d3877cee25794443fde5876417d859c108078ab788d6af7e4fe52eb66b + languageName: node + linkType: hard + +"ansi-regex@npm:^6.0.1": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10/b4494dfbfc7e4591b4711a396bd27e540f8153914123dccb4cdbbcb514015ada63a3809f362b9d8d4f6b17a706f1d7bea3c6f974b15fa5ae76b5b502070889ff + languageName: node + linkType: hard + +"archiver-utils@npm:^5.0.0, archiver-utils@npm:^5.0.2": + version: 5.0.2 + resolution: "archiver-utils@npm:5.0.2" + dependencies: + glob: "npm:^10.0.0" + graceful-fs: "npm:^4.2.0" + is-stream: "npm:^2.0.1" + lazystream: "npm:^1.0.0" + lodash: "npm:^4.17.15" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10/9dde4aa3f0cb1bdfe0b3d4c969f82e6cca9ae76338b7fee6f0071a14a2a38c0cdd1c41ecd3e362466585aa6cc5d07e9e435abea8c94fd9c7ace35f184abef9e4 + languageName: node + linkType: hard + +"archiver@npm:^7.0.1": + version: 7.0.1 + resolution: "archiver@npm:7.0.1" + dependencies: + archiver-utils: "npm:^5.0.2" + async: "npm:^3.2.4" + buffer-crc32: "npm:^1.0.0" + readable-stream: "npm:^4.0.0" + readdir-glob: "npm:^1.1.2" + tar-stream: "npm:^3.0.0" + zip-stream: "npm:^6.0.1" + checksum: 10/81c6102db99d7ffd5cb2aed02a678f551c6603991a059ca66ef59249942b835a651a3d3b5240af4f8bec4e61e13790357c9d1ad4a99982bd2cc4149575c31d67 + languageName: node + linkType: hard + +"arg@npm:^4.1.0": + version: 4.1.3 + resolution: "arg@npm:4.1.3" + checksum: 10/969b491082f20cad166649fa4d2073ea9e974a4e5ac36247ca23d2e5a8b3cb12d60e9ff70a8acfe26d76566c71fd351ee5e6a9a6595157eb36f92b1fd64e1599 + languageName: node + linkType: hard + +"asn1@npm:^0.2.6": + version: 0.2.6 + resolution: "asn1@npm:0.2.6" + dependencies: + safer-buffer: "npm:~2.1.0" + checksum: 10/cf629291fee6c1a6f530549939433ebf32200d7849f38b810ff26ee74235e845c0c12b2ed0f1607ac17383d19b219b69cefa009b920dab57924c5c544e495078 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10/a0789dd882211b87116e81e2648ccb7f60340b34f19877dd020b39ebb4714e475eb943e14ba3e22201c221ef6645b7bfe10297e76b6ac95b48a9898c1211ce66 + languageName: node + linkType: hard + +"ast-v8-to-istanbul@npm:^1.0.0": + version: 1.0.3 + resolution: "ast-v8-to-istanbul@npm:1.0.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + estree-walker: "npm:^3.0.3" + js-tokens: "npm:^10.0.0" + checksum: 10/7b9079a87d311f4a4dd13c6a3bd2cc76c70b094ab7a8359eaf8314bf8a5841b38db0ea26f12ae5ee08abf0654fc46778db6562c1e8ff6145739ba659ee139744 + languageName: node + linkType: hard + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10/1a09379937d846f0ce7614e75071c12826945d4e417db634156bf0e4673c495989302f52186dfa9767a1d9181794554717badd193ca2bbab046ef1da741d8efd + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10/3d49e7acbeee9e84537f4cb0e0f91893df8eba976759875ae8ee9e3d3c82f6ecdebdb347c2fad9926b92596d93cdfc78ecc988bcdf407e40433e8e8e6fe5d78e + languageName: node + linkType: hard + +"async-lock@npm:^1.4.1": + version: 1.4.1 + resolution: "async-lock@npm:1.4.1" + checksum: 10/80d55ac95f920e880a865968b799963014f6d987dd790dd08173fae6e1af509d8cd0ab45a25daaca82e3ef8e7c939f5d128cd1facfcc5c647da8ac2409e20ef9 + languageName: node + linkType: hard + +"async@npm:^3.2.4": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10/3ce727cbc78f69d6a4722517a58ee926c8c21083633b1d3fdf66fd688f6c127a53a592141bd4866f9b63240a86e9d8e974b13919450bd17fa33c2d22c4558ad8 + languageName: node + linkType: hard + +"atomic-sleep@npm:^1.0.0": + version: 1.0.0 + resolution: "atomic-sleep@npm:1.0.0" + checksum: 10/3ab6d2cf46b31394b4607e935ec5c1c3c4f60f3e30f0913d35ea74b51b3585e84f590d09e58067f11762eec71c87d25314ce859030983dc0e4397eed21daa12e + languageName: node + linkType: hard + +"axios@npm:^1.12.0": + version: 1.16.1 + resolution: "axios@npm:1.16.1" + dependencies: + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10/9b6218cf96321cfbbf8f160658d695367114bcf4fb62492bdc1ccd647f184b5c71ae400e5ecaaf41079bc561de2ecbaf1fec63f398b3ec53389beff7694df64c + languageName: node + linkType: hard + +"b4a@npm:^1.6.4": + version: 1.8.1 + resolution: "b4a@npm:1.8.1" + peerDependencies: + react-native-b4a: "*" + peerDependenciesMeta: + react-native-b4a: + optional: true + checksum: 10/8536650b525f9f916e8fff9f5976fbeba2fc3238f047cad52e91073cf9825306ce7a68d0077ba2d06e3d20c95b445dccc2ab97ed45773331244d82251329cf8d + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10/9706c088a283058a8a99e0bf91b0a2f75497f185980d9ffa8b304de1d9e58ebda7c72c07ebf01dadedaac5b2907b2c6f566f660d62bd336c3468e960403b9d65 + languageName: node + linkType: hard + +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + +"bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": + version: 2.8.3 + resolution: "bare-events@npm:2.8.3" + peerDependencies: + bare-abort-controller: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + checksum: 10/704252793362d4a422959f3b5d134a3f893f020b515cccf55965c8076941d6e7fd8c23268560693f2300270378a00384156237e4390edda2d4ca0e641bfe774e + languageName: node + linkType: hard + +"bare-fs@npm:^4.0.1, bare-fs@npm:^4.5.5": + version: 4.7.1 + resolution: "bare-fs@npm:4.7.1" + dependencies: + bare-events: "npm:^2.5.4" + bare-path: "npm:^3.0.0" + bare-stream: "npm:^2.6.4" + bare-url: "npm:^2.2.2" + fast-fifo: "npm:^1.3.2" + peerDependencies: + bare-buffer: "*" + peerDependenciesMeta: + bare-buffer: + optional: true + checksum: 10/bb873bf8d22c45fd14444b0f9731315a77b696c9387b09cc0df9975b998d1b5db9f4c88aa4b264ce59edeade573689ba9e0ba172003cc8900b2c2ad803f9275b + languageName: node + linkType: hard + +"bare-os@npm:^3.0.1": + version: 3.9.1 + resolution: "bare-os@npm:3.9.1" + checksum: 10/2a106aca9eeb1cf41e30403410c9fa81a9e13c25818debc21444f2485158e01e65f10daff37acab0cbf9460c00e64e6bcaedef07b25a9171ec1e45485213ff50 + languageName: node + linkType: hard + +"bare-path@npm:^3.0.0": + version: 3.0.0 + resolution: "bare-path@npm:3.0.0" + dependencies: + bare-os: "npm:^3.0.1" + checksum: 10/712d90e9cd8c3263cc11b0e0d386d1531a452706d7840c081ee586b34b00d72544e65df7a40013d47c1b177277495225deeede65cb2984db88a979cb65aaa2ff + languageName: node + linkType: hard + +"bare-stream@npm:^2.6.4": + version: 2.13.1 + resolution: "bare-stream@npm:2.13.1" + dependencies: + streamx: "npm:^2.25.0" + teex: "npm:^1.0.1" + peerDependencies: + bare-abort-controller: "*" + bare-buffer: "*" + bare-events: "*" + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + checksum: 10/50aa90a7005d71c1af8fafcc84f378bd4d7c2dd293a581ffe3899bee39b0d2eb07c47e1092f581fa5b199a63c0ad2618b150c0ab716658727e3fcc7fd7d1e401 + languageName: node + linkType: hard + +"bare-url@npm:^2.2.2": + version: 2.4.3 + resolution: "bare-url@npm:2.4.3" + dependencies: + bare-path: "npm:^3.0.0" + checksum: 10/e2c16dd57e0c4b974813d9acd626b96e83a8894e19b0bf780de4bef40a7000c697984a47c398c8f612aa7991974bfb97f1c3c3fd410085a55fa5db15d1ba6309 + languageName: node + linkType: hard + +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 + languageName: node + linkType: hard + +"bcrypt-pbkdf@npm:^1.0.2": + version: 1.0.2 + resolution: "bcrypt-pbkdf@npm:1.0.2" + dependencies: + tweetnacl: "npm:^0.14.3" + checksum: 10/13a4cde058250dbf1fa77a4f1b9a07d32ae2e3b9e28e88a0c7a1827835bc3482f3e478c4a0cfd4da6ff0c46dae07da1061123a995372b32cc563d9975f975404 + languageName: node + linkType: hard + +"bl@npm:^4.0.3": + version: 4.1.0 + resolution: "bl@npm:4.1.0" + dependencies: + buffer: "npm:^5.5.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.4.0" + checksum: 10/b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55 + languageName: node + linkType: hard + +"bn.js@npm:^5.2.1, bn.js@npm:^5.2.3": + version: 5.2.3 + resolution: "bn.js@npm:5.2.3" + checksum: 10/dfb3927e0d531e6ec4f191597ce6f7f7665310c356fef5f968ada676b8058027f959af42eaa37b5f5c63617e819d3741813025ab15dd71a90f2e74698df0b58e + languageName: node + linkType: hard + +"brace-expansion@npm:^2.0.1": + version: 2.1.0 + resolution: "brace-expansion@npm:2.1.0" + dependencies: + balanced-match: "npm:^1.0.0" + checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.5": + version: 5.0.6 + resolution: "brace-expansion@npm:5.0.6" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/a7acf120fefa79e9d7c9c92898114f57c07596a3920197f3c5917e6a628b04220a5f7f9618c30bdd973a6576a32113b99f9c3f1c8245ccc399dd2a9a718d81d8 + languageName: node + linkType: hard + +"browser-level@npm:^3.0.0": + version: 3.0.0 + resolution: "browser-level@npm:3.0.0" + dependencies: + abstract-level: "npm:^3.1.0" + checksum: 10/719e9aa36fb85ed7bd9d06267961c7b151866422e4ff4e97cc82966c6fdefcc13a19bbd2cefe151d57af21bf7d2e2419e758f8646af445dca47d8ab191e7236b + languageName: node + linkType: hard + +"buffer-crc32@npm:^1.0.0": + version: 1.0.0 + resolution: "buffer-crc32@npm:1.0.0" + checksum: 10/ef3b7c07622435085c04300c9a51e850ec34a27b2445f758eef69b859c7827848c2282f3840ca6c1eef3829145a1580ce540cab03ccf4433827a2b95d3b09ca7 + languageName: node + linkType: hard + +"buffer@npm:^5.5.0": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6 + languageName: node + linkType: hard + +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10/b6bc68237ebf29bdacae48ce60e5e28fc53ae886301f2ad9496618efac49427ed79096750033e7eab1897a4f26ae374ace49106a5758f38fb70c78c9fda2c3b1 + languageName: node + linkType: hard + +"buildcheck@npm:~0.0.6": + version: 0.0.7 + resolution: "buildcheck@npm:0.0.7" + checksum: 10/cca174bcc917ee9dc00b1be404b4f22656d9c243d439d3456e6bd52263f05ad5f5d3c77e62a1f6ccaf1d36cb65efc5ee3bb30ed10e1675f22a1abdfad99eb9b3 + languageName: node + linkType: hard + +"byline@npm:^5.0.0": + version: 5.0.0 + resolution: "byline@npm:5.0.0" + checksum: 10/737ca83e8eda2976728dae62e68bc733aea095fab08db4c6f12d3cee3cf45b6f97dce45d1f6b6ff9c2c947736d10074985b4425b31ce04afa1985a4ef3d334a7 + languageName: node + linkType: hard + +"cacache@npm:^19.0.1": + version: 19.0.1 + resolution: "cacache@npm:19.0.1" + dependencies: + "@npmcli/fs": "npm:^4.0.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^10.2.2" + lru-cache: "npm:^10.0.1" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^7.0.2" + ssri: "npm:^12.0.0" + tar: "npm:^7.4.3" + unique-filename: "npm:^4.0.0" + checksum: 10/ea026b27b13656330c2bbaa462a88181dcaa0435c1c2e705db89b31d9bdf7126049d6d0445ba746dca21454a0cfdf1d6f47fd39d34c8c8435296b30bc5738a13 + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10/00482c1f6aa7cfb30fb1dbeb13873edf81cfac7c29ed67a5957d60635a56b2a4a480f1016ddbdb3395cc37900d46037fb965043a51c5c789ffeab4fc535d18b5 + languageName: node + linkType: hard + +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10/13cda42cc40aa46da04a41cf7e5c61df6b6ae0b4e8a8c8b40e04d6947e4d7951377ea8c14f9fa7fe5aaa9e8bd9ba414f11288dc958d4cee6f5221b9436f2778f + languageName: node + linkType: hard + +"chalk@npm:^5.6.2": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 + languageName: node + linkType: hard + +"chownr@npm:^1.1.1": + version: 1.1.4 + resolution: "chownr@npm:1.1.4" + checksum: 10/115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10/b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c + languageName: node + linkType: hard + +"classic-level@npm:^3.0.0": + version: 3.0.0 + resolution: "classic-level@npm:3.0.0" + dependencies: + abstract-level: "npm:^3.1.0" + module-error: "npm:^1.0.1" + napi-macros: "npm:^2.2.2" + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.3.0" + checksum: 10/96c07b0ca6f38dc5535c040804fdb845f728dcabd12838dafbcb379ca4b4cce906fb14c4ab8d871b3798f0e27a7815b9f584be535d1e00089f1104da97e44f95 + languageName: node + linkType: hard + +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10/1eb9a3f878b31addfe8d82c6d915ec2330cec8447ab1f117f4aa34f0137fbb3137ec3466e1c9a65bcb7557f6e486d343f2da57f253a2f668d691372dfa15c090 + languageName: node + linkType: hard + +"cli-spinners@npm:^3.2.0": + version: 3.3.0 + resolution: "cli-spinners@npm:3.3.0" + checksum: 10/d95f69f4a6a4efab2104ca5d4723c9f6fae9a4006df7fdcc1f79ea6539324e274b85bf6f5931146d84296b0f71814f4c1ff1acc158f2e1107c0c9797c1291bcc + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10/eaa5561aeb3135c2cddf7a3b3f562fc4238ff3b3fc666869ef2adf264be0f372136702f16add9299087fb1907c2e4ec5dbfe83bd24bce815c70a80c6c1a2e950 + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10/fa00c91b4332b294de06b443923246bccebe9fab1b253f7fe1772d37b06a2269b4039a85e309abe1fe11b267b11c08d1d0473fda3badd6167f57313af2887a64 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10/b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 + languageName: node + linkType: hard + +"colorette@npm:^2.0.7": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10/0b8de48bfa5d10afc160b8eaa2b9938f34a892530b2f7d7897e0458d9535a066e3998b49da9d21161c78225b272df19ae3a64d6df28b4c9734c0e55bbd02406f + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10/2e969e637d05d09fa50b02d74c83a1186f6914aae89e6653b62595cc75a221464f884f55f231b8f4df7a49537fba60bdc0427acd2bf324c09a1dbb84837e36e4 + languageName: node + linkType: hard + +"compact-tools-monorepo@workspace:.": + version: 0.0.0-use.local + resolution: "compact-tools-monorepo@workspace:." + dependencies: + "@biomejs/biome": "npm:2.4.16" + "@openzeppelin/compact-deployer": "workspace:^" + "@types/node": "npm:25.9.1" + "@vitest/coverage-v8": "npm:4.1.8" + pino: "npm:^9.7.0" + ts-node: "npm:^10.9.2" + turbo: "npm:^2.9.14" + typescript: "npm:^6.0.3" + vitest: "npm:^4.1.6" + languageName: unknown + linkType: soft + +"compress-commons@npm:^6.0.2": + version: 6.0.2 + resolution: "compress-commons@npm:6.0.2" + dependencies: + crc-32: "npm:^1.2.0" + crc32-stream: "npm:^6.0.0" + is-stream: "npm:^2.0.1" + normalize-path: "npm:^3.0.0" + readable-stream: "npm:^4.0.0" + checksum: 10/78e3ba10aeef919a1c5bbac21e120f3e1558a31b2defebbfa1635274fc7f7e8a3a0ee748a06249589acd0b33a0d58144b8238ff77afc3220f8d403a96fcc13aa + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10/c987be3ec061348cdb3c2bfb924bec86dea1eacad10550a85ca23edb0fe3556c3a61c7399114f3331ccb3499d7fd0285ab24566e5745929412983494c3926e15 + languageName: node + linkType: hard + +"copy-anything@npm:^4": + version: 4.0.5 + resolution: "copy-anything@npm:4.0.5" + dependencies: + is-what: "npm:^5.2.0" + checksum: 10/1ee7e6f55c1016a47871ecd09aa765ca825c1ec89c46e6f58686016c80c6fe3d36452a6010d8498c766ea5d60bc5d892d9511b41310a7355b48ac10b39c90c9a + languageName: node + linkType: hard + +"core-util-is@npm:~1.0.0": + version: 1.0.3 + resolution: "core-util-is@npm:1.0.3" + checksum: 10/9de8597363a8e9b9952491ebe18167e3b36e7707569eed0ebf14f8bba773611376466ae34575bca8cfe3c767890c859c74056084738f09d4e4a6f902b2ad7d99 + languageName: node + linkType: hard + +"cpu-features@npm:~0.0.10": + version: 0.0.10 + resolution: "cpu-features@npm:0.0.10" + dependencies: + buildcheck: "npm:~0.0.6" + nan: "npm:^2.19.0" + node-gyp: "npm:latest" + checksum: 10/941b828ffe77582b2bdc03e894c913e2e2eeb5c6043ccb01338c34446d026f6888dc480ecb85e684809f9c3889d245f3648c7907eb61a92bdfc6aed039fcda8d + languageName: node + linkType: hard + +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: 10/824f696a5baaf617809aa9cd033313c8f94f12d15ebffa69f10202480396be44aef9831d900ab291638a8022ed91c360696dd5b1ba691eb3f34e60be8835b7c3 + languageName: node + linkType: hard + +"crc32-stream@npm:^6.0.0": + version: 6.0.0 + resolution: "crc32-stream@npm:6.0.0" + dependencies: + crc-32: "npm:^1.2.0" + readable-stream: "npm:^4.0.0" + checksum: 10/e6edc2f81bc387daef6d18b2ac18c2ffcb01b554d3b5c7d8d29b177505aafffba574658fdd23922767e8dab1183d1962026c98c17e17fb272794c33293ef607c + languageName: node + linkType: hard + +"create-require@npm:^1.1.0": + version: 1.1.1 + resolution: "create-require@npm:1.1.1" + checksum: 10/a9a1503d4390d8b59ad86f4607de7870b39cad43d929813599a23714831e81c520bddf61bcdd1f8e30f05fd3a2b71ae8538e946eb2786dc65c2bbc520f692eff + languageName: node + linkType: hard + +"cross-fetch@npm:^4.0.0, cross-fetch@npm:^4.1.0": + version: 4.1.0 + resolution: "cross-fetch@npm:4.1.0" + dependencies: + node-fetch: "npm:^2.7.0" + checksum: 10/07624940607b64777d27ec9c668ddb6649e8c59ee0a5a10e63a51ce857e2bbb1294a45854a31c10eccb91b65909a5b199fcb0217339b44156f85900a7384f489 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.6": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" + dependencies: + path-key: "npm:^3.1.0" + shebang-command: "npm:^2.0.0" + which: "npm:^2.0.1" + checksum: 10/0d52657d7ae36eb130999dffff1168ec348687b48dd38e2ff59992ed916c88d328cf1d07ff4a4a10bc78de5e1c23f04b306d569e42f7a2293915c081e4dfee86 + languageName: node + linkType: hard + +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 10/0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c + languageName: node + linkType: hard + +"dateformat@npm:^4.6.3": + version: 4.6.3 + resolution: "dateformat@npm:4.6.3" + checksum: 10/5c149c91bf9ce2142c89f84eee4c585f0cb1f6faf2536b1af89873f862666a28529d1ccafc44750aa01384da2197c4f76f4e149a3cc0c1cb2c46f5cc45f2bcb5 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.4, debug@npm:^4.3.5": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10/46fe6e83e2cb1d85ba50bd52803c68be9bd953282fa7096f51fc29edd5d67ff84ff753c51966061e5ba7cb5e47ef6d36a91924eddb7f3f3483b1c560f77a0020 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 + languageName: node + linkType: hard + +"diff@npm:^4.0.1": + version: 4.0.2 + resolution: "diff@npm:4.0.2" + checksum: 10/ec09ec2101934ca5966355a229d77afcad5911c92e2a77413efda5455636c4cf2ce84057e2d7715227a2eeeda04255b849bd3ae3a4dd22eb22e86e76456df069 + languageName: node + linkType: hard + +"docker-compose@npm:^0.24.8": + version: 0.24.8 + resolution: "docker-compose@npm:0.24.8" + dependencies: + yaml: "npm:^2.2.2" + checksum: 10/2b8526f9797a55c819ff2d7dcea57085b012b3a3d77bc2e1a6b45c3fc9e82196312f5298cbe8299966462454a5ac8f68814bb407736b4385e0d226a2a39e877a + languageName: node + linkType: hard + +"docker-modem@npm:^5.0.7": + version: 5.0.7 + resolution: "docker-modem@npm:5.0.7" + dependencies: + debug: "npm:^4.1.1" + readable-stream: "npm:^3.5.0" + split-ca: "npm:^1.0.1" + ssh2: "npm:^1.15.0" + checksum: 10/8c0dc9908e10fbc91c35b187fc6a67a0dcbe4b33a2198dfa67cd8304e0f2452325e1639215674d6e441731d0bf27f06339550f6c3767585b877601d2f16e43e2 + languageName: node + linkType: hard + +"dockerode@npm:^4.0.5": + version: 4.0.12 + resolution: "dockerode@npm:4.0.12" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + "@grpc/grpc-js": "npm:^1.11.1" + "@grpc/proto-loader": "npm:^0.7.13" + docker-modem: "npm:^5.0.7" + protobufjs: "npm:^7.3.2" + tar-fs: "npm:^2.1.4" + uuid: "npm:^10.0.0" + checksum: 10/e08b15ba2ba41e93e61cac472e525efff48851b0eaaba75e5075cf540760099658f57883b08334ccc3fee021c4ca286013c76a00890b5d0716892b8ff678b2d1 + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10/5add88a3d68d42d6e6130a0cac450b7c2edbe73364bbd2fc334564418569bea97c6943a8fcd70e27130bf32afc236f30982fc4905039b703f23e9e0433c29934 + languageName: node + linkType: hard + +"effect@npm:^3.19.19, effect@npm:^3.20.0": + version: 3.21.2 + resolution: "effect@npm:3.21.2" + dependencies: + "@standard-schema/spec": "npm:^1.0.0" + fast-check: "npm:^3.23.1" + checksum: 10/e1bf90d9010e6b4d8389937e80e96884e49164b8b1658230cf2aaf9d2a3844d1698a6854fd8183a82a0335bdcbc37879d9af84491b52a57bf16ab52052cf6f46 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10/c72d67a6821be15ec11997877c437491c313d924306b8da5d87d2a2bcc2cec9903cb5b04ee1a088460501d8e5b44f10df82fdc93c444101a7610b80c8b6938e1 + languageName: node + linkType: hard + +"encoding@npm:^0.1.13": + version: 0.1.13 + resolution: "encoding@npm:0.1.13" + dependencies: + iconv-lite: "npm:^0.6.2" + checksum: 10/bb98632f8ffa823996e508ce6a58ffcf5856330fde839ae42c9e1f436cc3b5cc651d4aeae72222916545428e54fd0f6aa8862fd8d25bdbcc4589f1e3f3715e7f + languageName: node + linkType: hard + +"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1": + version: 1.4.5 + resolution: "end-of-stream@npm:1.4.5" + dependencies: + once: "npm:^1.4.0" + checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10/65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e + languageName: node + linkType: hard + +"err-code@npm:^2.0.2": + version: 2.0.3 + resolution: "err-code@npm:2.0.3" + checksum: 10/1d20d825cdcce8d811bfbe86340f4755c02655a7feb2f13f8c880566d9d72a3f6c92c192a6867632e490d6da67b678271f46e01044996a6443e870331100dfdd + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10/f8dc9e660d90919f11084db0a893128f3592b781ce967e4fccfb8f3106cb83e400a4032c559184ec52ee1dbd4b01e7776c7cd0b3327b1961b1a4a7008920fe78 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10/96e65d640156f91b707517e8cdc454dd7d47c32833aa3e85d79f24f9eb7ea85f39b63e36216ef0114996581969b59fe609a94e30316b08f5f4df1d44134cf8d5 + languageName: node + linkType: hard + +"es-module-lexer@npm:^2.0.0": + version: 2.1.0 + resolution: "es-module-lexer@npm:2.1.0" + checksum: 10/554c4374e78a812a1fa3673871ce7d42236438c414ea80c2ec35521cd9bb26d1d9155287529057d07431fd91df50d6a26d9bee5afd755fb7f6f7c81905a03956 + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" dependencies: - tslib: "npm:^2.4.0" - checksum: 10/d12f1dafe12d7a573c406b35ffef0038042b9cc9fbcc74d657267eb635499b956276afc05eebdbd81bea582e1c4c921421a1dd7243a93daaa8c8216b19395c23 + es-errors: "npm:^1.3.0" + checksum: 10/54fe77de288451dae51c37bfbfe3ec86732dc3778f98f3eb3bdb4bf48063b2c0b8f9c93542656986149d08aa5be3204286e2276053d19582b76753f1a2728867 languageName: node linkType: hard -"@types/chai@npm:^5.2.2": - version: 5.2.3 - resolution: "@types/chai@npm:5.2.3" +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" dependencies: - "@types/deep-eql": "npm:*" - assertion-error: "npm:^2.0.1" - checksum: 10/e79947307dc235953622e65f83d2683835212357ca261389116ab90bed369ac862ba28b146b4fed08b503ae1e1a12cb93ce783f24bb8d562950469f4320e1c7c + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10/86814bf8afbcd8966653f731415888019d4bc4aca6b6c354132a7a75bb87566751e320369654a101d23a91c87a85c79b178bcf40332839bd347aff437c4fb65f languageName: node linkType: hard -"@types/deep-eql@npm:*": - version: 4.0.2 - resolution: "@types/deep-eql@npm:4.0.2" - checksum: 10/249a27b0bb22f6aa28461db56afa21ec044fa0e303221a62dff81831b20c8530502175f1a49060f7099e7be06181078548ac47c668de79ff9880241968d43d0c +"escalade@npm:^3.1.1": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10/9d7169e3965b2f9ae46971afa392f6e5a25545ea30f2e2dd99c9b0a95a3f52b5653681a84f5b2911a413ddad2d7a93d3514165072f349b5ffc59c75a899970d6 languageName: node linkType: hard -"@types/estree@npm:^1.0.0": - version: 1.0.9 - resolution: "@types/estree@npm:1.0.9" - checksum: 10/16aabfa703b5bdac83f719b07ce92a11b2d3c9b8628eacc92889d8af46cab2d78fc45c7b5378de383d0500585cea5c2f79125eeddfe5fbc6bd6a27eb0c8ccee5 +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10/a65728d5727b71de172c5df323385755a16c0fdab8234dc756c3854cfee343261ddfbb72a809a5660fac8c75d960bb3e21aa898c2d7e9b19bb298482ca58a3af languageName: node linkType: hard -"@types/node@npm:25.9.1": - version: 25.9.1 - resolution: "@types/node@npm:25.9.1" +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10/49ff46c3a7facbad3decb31f597063e761785d7fdb3920d4989d7b08c97a61c2f51183e2f3a03130c9088df88d4b489b1b79ab632219901f184f85158508f4c8 + languageName: node + linkType: hard + +"eventemitter3@npm:^5.0.1": + version: 5.0.4 + resolution: "eventemitter3@npm:5.0.4" + checksum: 10/54f5c8c543650d65f92d03dbef1bb73a682a920490c44699ad8f863a6b19bbca42fb7409aa09ca09cb98a44149d9a7bc1dffd55ca88a740bd928c7be0ad666a0 + languageName: node + linkType: hard + +"events-universal@npm:^1.0.0": + version: 1.0.1 + resolution: "events-universal@npm:1.0.1" dependencies: - undici-types: "npm:>=7.24.0 <7.24.7" - checksum: 10/8a1ccf60f0c0ca856d3324a690ee35776f26dfc1d51c3763aecdf246a3246a7971a0156bf6eb3239aa22dfa940eb361d048212f5c3204264d31ef4c41d17416a + bare-events: "npm:^2.7.0" + checksum: 10/71b2e6079b4dc030c613ef73d99f1acb369dd3ddb6034f49fd98b3e2c6632cde9f61c15fb1351004339d7c79672252a4694ecc46a6124dc794b558be50a83867 languageName: node linkType: hard -"@types/object-inspect@npm:^1.8.1": - version: 1.13.0 - resolution: "@types/object-inspect@npm:1.13.0" - checksum: 10/8caf52c815947540b5246e0b5b2d455a2183791fe9427537eab8a40b465392400cee6ce50beaeb35465e167e9cb405ccfde90eb5317ee2c9df85af7508f0a320 +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10/a3d47e285e28d324d7180f1e493961a2bbb4cad6412090e4dec114f4db1f5b560c7696ee8e758f55e23913ede856e3689cd3aa9ae13c56b5d8314cd3b3ddd1be languageName: node linkType: hard -"@types/shell-quote@npm:^1.7.5": - version: 1.7.5 - resolution: "@types/shell-quote@npm:1.7.5" - checksum: 10/32b4d697c7d23dbadf40713692c47f1595f083a3b3deea76cb18e30a05d197aa9205d2b87f6d92edb60cda120b51e35d32bda96ed9b0a7e32921eed2deb4559e +"expect-type@npm:^1.3.0": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10/a5fada3d0c621649261f886e7d93e6bf80ce26d8a86e5d517e38301b8baec8450ab2cb94ba6e7a0a6bf2fc9ee55f54e1b06938ef1efa52ddcfeffbfa01acbbcc + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10/ca25962b4bbab943b7c4ed0b5228e263833a5063c65e1cdeac4be9afad350aae5466e8e619b5051f4f8d37b2144a2d6e8fcc771b6cc82934f7dade2f964f652c languageName: node linkType: hard -"@vitest/expect@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/expect@npm:4.1.7" +"fast-check@npm:^3.23.1": + version: 3.23.2 + resolution: "fast-check@npm:3.23.2" dependencies: - "@standard-schema/spec": "npm:^1.1.0" - "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.1.7" - "@vitest/utils": "npm:4.1.7" - chai: "npm:^6.2.2" - tinyrainbow: "npm:^3.1.0" - checksum: 10/a609af6c0497cd510ce8aed099f18faf6d6642bc8eb3432b688f2b39d7354a04d1c4ee9dc28bcfb9d4be701ceac88384d586592a520a324b3773ea43e8a1e677 + pure-rand: "npm:^6.1.0" + checksum: 10/dab344146b778e8bc2973366ea55528d1b58d3e3037270262b877c54241e800c4d744957722c24705c787020d702aece11e57c9e3dbd5ea19c3e10926bf1f3fe languageName: node linkType: hard -"@vitest/mocker@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/mocker@npm:4.1.7" +"fast-check@npm:^4.5.2": + version: 4.5.2 + resolution: "fast-check@npm:4.5.2" dependencies: - "@vitest/spy": "npm:4.1.7" - estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.21" + pure-rand: "npm:^7.0.0" + checksum: 10/20193f70f4087641f82ca8aede1a395fb974fe170c317f589bf2614e2bb98ef16873b27091e8359c2cc79d59f9c32f042e84b1eef13053068ee94150bf08a0bb + languageName: node + linkType: hard + +"fast-copy@npm:^4.0.0": + version: 4.0.3 + resolution: "fast-copy@npm:4.0.3" + checksum: 10/1e74e8b18a83f125b697b0dc7d802b4c73ec2aba7b181458e5e72d46a261faefcdee22ad9fa682c77f4606133451342f95de9835c2c804c481472585fa6ded26 + languageName: node + linkType: hard + +"fast-fifo@npm:^1.2.0, fast-fifo@npm:^1.3.2": + version: 1.3.2 + resolution: "fast-fifo@npm:1.3.2" + checksum: 10/6bfcba3e4df5af7be3332703b69a7898a8ed7020837ec4395bb341bd96cc3a6d86c3f6071dd98da289618cf2234c70d84b2a6f09a33dd6f988b1ff60d8e54275 + languageName: node + linkType: hard + +"fast-safe-stringify@npm:^2.1.1": + version: 2.1.1 + resolution: "fast-safe-stringify@npm:2.1.1" + checksum: 10/dc1f063c2c6ac9533aee14d406441f86783a8984b2ca09b19c2fe281f9ff59d315298bc7bc22fd1f83d26fe19ef2f20e2ddb68e96b15040292e555c5ced0c1e4 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + picomatch: ^3 || ^4 peerDependenciesMeta: - msw: + picomatch: optional: true - vite: + checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1 + languageName: node + linkType: hard + +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: "npm:^1.0.0" + web-streams-polyfill: "npm:^3.0.3" + checksum: 10/5264ecceb5fdc19eb51d1d0359921f12730941e333019e673e71eb73921146dceabcb0b8f534582be4497312d656508a439ad0f5edeec2b29ab2e10c72a1f86b + languageName: node + linkType: hard + +"fetch-retry@npm:^6.0.0": + version: 6.0.0 + resolution: "fetch-retry@npm:6.0.0" + checksum: 10/0c8d3082e2d76fff2df75adef6280bc854bc36fd3ef38506674f0216d0d819e2efd14da7477d3f1732415aea1d2cfde7cd3e1aeae46f45f2adbfc5133296e8de + languageName: node + linkType: hard + +"find-my-way-ts@npm:^0.1.6": + version: 0.1.6 + resolution: "find-my-way-ts@npm:0.1.6" + checksum: 10/b95bf644011f0d341e5963aa4cac55b2ee59e2435d3f65ae5cf9ee80e52f0fc7db0cee9a55e7420a62a2cec7d8bec7538399dada45e024c05488daa754451bcc + languageName: node + linkType: hard + +"follow-redirects@npm:^1.16.0": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" + peerDependenciesMeta: + debug: optional: true - checksum: 10/124d0ec9cc099fde1fca4b065b81a389e9ba2204ecba9729751a0a022d0ffaa34609d9dc60c1f8494ee972c2209035a4476ff1dddc1790e07d1ca28a1103b30d + checksum: 10/3fbe3d80b3b544c22705d837aa5d4a0d07a740d913534a2620b0a004c610af4148e3b58723536dd099aaa1c9d3a155964bde9665d6e5cb331460809a1fc572fd languageName: node linkType: hard -"@vitest/pretty-format@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/pretty-format@npm:4.1.7" +"foreground-child@npm:^3.3.1": + version: 3.3.1 + resolution: "foreground-child@npm:3.3.1" dependencies: - tinyrainbow: "npm:^3.1.0" - checksum: 10/79c86c39173577250955744c3444d8c0c9304c95c7d351b91a916229252c3733a0e969741a8f3441a5c4777b5a4371707ecb747ea4bfd2c07e72ddf1ef621293 + cross-spawn: "npm:^7.0.6" + signal-exit: "npm:^4.0.1" + checksum: 10/427b33f997a98073c0424e5c07169264a62cda806d8d2ded159b5b903fdfc8f0a1457e06b5fc35506497acb3f1e353f025edee796300209ac6231e80edece835 languageName: node linkType: hard -"@vitest/runner@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/runner@npm:4.1.7" +"form-data@npm:^4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" dependencies: - "@vitest/utils": "npm:4.1.7" - pathe: "npm:^2.0.3" - checksum: 10/429f1e0cc93f66a681d8acc816e21ac41258b07550f9139d004aab103bb06be53e3d91fc66886cef1ba1460a120f5fe4b12d6fe32dafdb1b06740dd119d70f7e + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10/52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd languageName: node linkType: hard -"@vitest/snapshot@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/snapshot@npm:4.1.7" +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" dependencies: - "@vitest/pretty-format": "npm:4.1.7" - "@vitest/utils": "npm:4.1.7" - magic-string: "npm:^0.30.21" - pathe: "npm:^2.0.3" - checksum: 10/ef7001add6724c025772891616338e6081ecdb11a92c084ca1d09c4662cf632e5877bec4cb38056aabc311f29fbe149c89fbf332975829087f3817554fe92cde + fetch-blob: "npm:^3.1.2" + checksum: 10/9b5001d2edef3c9449ac3f48bd4f8cc92e7d0f2e7c1a5c8ba555ad4e77535cc5cf621fabe49e97f304067037282dd9093b9160a3cb533e46420b446c4e6bc06f languageName: node linkType: hard -"@vitest/spy@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/spy@npm:4.1.7" - checksum: 10/49a9959c615f45ec593379a6d1a238190d08524857a6c4819b724134ce8a1a96d94e20144723d245941ce1ada54d8b00552573810d629880ecb8c3ff03b6d1ad +"fs-constants@npm:^1.0.0": + version: 1.0.0 + resolution: "fs-constants@npm:1.0.0" + checksum: 10/18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d languageName: node linkType: hard -"@vitest/utils@npm:4.1.7": - version: 4.1.7 - resolution: "@vitest/utils@npm:4.1.7" +"fs-minipass@npm:^3.0.0": + version: 3.0.3 + resolution: "fs-minipass@npm:3.0.3" dependencies: - "@vitest/pretty-format": "npm:4.1.7" - convert-source-map: "npm:^2.0.0" - tinyrainbow: "npm:^3.1.0" - checksum: 10/9cc729618dade24de3ad6862c288c22e9daac3fda5cae0abc9b6ce87035cc8e7efa2b66c3c124ae08beef462b36761b062e792bbc619798b832a7ea9382ed12a + minipass: "npm:^7.0.3" + checksum: 10/af143246cf6884fe26fa281621d45cfe111d34b30535a475bfa38dafe343dadb466c047a924ffc7d6b7b18265df4110224ce3803806dbb07173bf2087b648d7f languageName: node linkType: hard -"abbrev@npm:^4.0.0": - version: 4.0.0 - resolution: "abbrev@npm:4.0.0" - checksum: 10/e2f0c6a6708ad738b3e8f50233f4800de31ad41a6cdc50e0cbe51b76fed69fd0213516d92c15ce1a9985fca71a14606a9be22bf00f8475a58987b9bfb671c582 +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10/4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 + conditions: os=darwin languageName: node linkType: hard -"acorn-walk@npm:^8.1.1": - version: 8.3.5 - resolution: "acorn-walk@npm:8.3.5" +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: - acorn: "npm:^8.11.0" - checksum: 10/f52a158a1c1f00c82702c7eb9b8ae8aad79748a7689241dcc2d797dce680f1dcb15c78f312f687eeacdfb3a4cac4b87d04af470f0201bd56c6661fca6f94b195 + node-gyp: "npm:latest" + conditions: os=darwin languageName: node linkType: hard -"acorn@npm:^8.11.0, acorn@npm:^8.4.1": - version: 8.16.0 - resolution: "acorn@npm:8.16.0" +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10/185e20d20f10c8d661d59aac0f3b63b31132d492e1b11fcc2a93cb2c47257ebaee7407c38513efd2b35cafdf972d9beb2ea4593c1e0f3bf8f2744836928d7454 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10/eb7e7eb896c5433f3d40982b2ccacdb3dd990dd3499f14040e002b5d54572476513be8a2e6f9609f6e41ab29f2c4469307611ddbfc37ff4e46b765c326663805 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.3.0": + version: 1.4.0 + resolution: "get-east-asian-width@npm:1.4.0" + checksum: 10/c9ae85bfc2feaf4cc71cdb236e60f1757ae82281964c206c6aa89a25f1987d326ddd8b0de9f9ccd56e37711b9fcd988f7f5137118b49b0b45e19df93c3be8f45 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.6": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10/bb579dda84caa4a3a41611bdd483dade7f00f246f2a7992eb143c5861155290df3fdb48a8406efa3dfb0b434e2c8fafa4eebd469e409d0439247f85fc3fa2cc1 + languageName: node + linkType: hard + +"get-port@npm:^7.1.0": + version: 7.2.0 + resolution: "get-port@npm:7.2.0" + checksum: 10/f8785ccdcc52b1e03f1b1de3fcd46dbc41fe4079e234f2727c3e154ca76bb94318fb0d341daa28a6c87eff24ad4016eaa8b1b4e26eff0d6a2196dd1c1ffc63a1 + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10/4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + +"glob@npm:^11.0.0": + version: 11.1.0 + resolution: "glob@npm:11.1.0" + dependencies: + foreground-child: "npm:^3.3.1" + jackspeak: "npm:^4.1.1" + minimatch: "npm:^10.1.1" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" bin: - acorn: bin/acorn - checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b + glob: dist/esm/bin.mjs + checksum: 10/da4501819633daff8822c007bb3f93d5c4d2cbc7b15a8e886660f4497dd251a1fb4f53a85fba1e760b31704eff7164aeb2c7a82db10f9f2c362d12c02fe52cf3 languageName: node linkType: hard -"ansi-regex@npm:^6.2.2": - version: 6.2.2 - resolution: "ansi-regex@npm:6.2.2" - checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10/94e296d69f92dc1c0768fcfeecfb3855582ab59a7c75e969d5f96ce50c3d201fd86d5a2857c22565764d5bb8a816c7b1e58f133ec318cd56274da36c5e3fb1a1 languageName: node linkType: hard -"arg@npm:^4.1.0": - version: 4.1.3 - resolution: "arg@npm:4.1.3" - checksum: 10/969b491082f20cad166649fa4d2073ea9e974a4e5ac36247ca23d2e5a8b3cb12d60e9ff70a8acfe26d76566c71fd351ee5e6a9a6595157eb36f92b1fd64e1599 +"graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 languageName: node linkType: hard -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10/a0789dd882211b87116e81e2648ccb7f60340b34f19877dd020b39ebb4714e475eb943e14ba3e22201c221ef6645b7bfe10297e76b6ac95b48a9898c1211ce66 +"graphql-http@npm:^1.22.4": + version: 1.22.4 + resolution: "graphql-http@npm:1.22.4" + peerDependencies: + graphql: ">=0.11 <=16" + checksum: 10/ef81c3d86ac75743509d225aaf88a79262adee8801035712e5af655deedd5755afb0060e68306ca54aa54067c4ef0a382a03b2ecde016e0fb43454b73184a04d languageName: node linkType: hard -"chai@npm:^6.2.2": - version: 6.2.2 - resolution: "chai@npm:6.2.2" - checksum: 10/13cda42cc40aa46da04a41cf7e5c61df6b6ae0b4e8a8c8b40e04d6947e4d7951377ea8c14f9fa7fe5aaa9e8bd9ba414f11288dc958d4cee6f5221b9436f2778f +"graphql-tag@npm:^2.12.6": + version: 2.12.6 + resolution: "graphql-tag@npm:2.12.6" + dependencies: + tslib: "npm:^2.1.0" + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + checksum: 10/23a2bc1d3fbeae86444204e0ac08522e09dc369559ba75768e47421a7321b59f352fb5b2c9a5c37d3cf6de890dca4e5ac47e740c7cc622e728572ecaa649089e languageName: node linkType: hard -"chalk@npm:^5.6.2": - version: 5.6.2 - resolution: "chalk@npm:5.6.2" - checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 +"graphql-ws@npm:^6.0.7, graphql-ws@npm:^6.0.8": + version: 6.0.8 + resolution: "graphql-ws@npm:6.0.8" + peerDependencies: + "@fastify/websocket": ^10 || ^11 + crossws: ~0.3 + graphql: ^15.10.1 || ^16 + ws: ^8 + peerDependenciesMeta: + "@fastify/websocket": + optional: true + crossws: + optional: true + ws: + optional: true + checksum: 10/503d581c7dab4b9a884dad844fa9642a896803161aa1f1c8d3f12619e4e428f43cb39fe06a198c30bb685a521689d525b2870539c07bd68bb4bf704d039bdd9a languageName: node linkType: hard -"chownr@npm:^3.0.0": - version: 3.0.0 - resolution: "chownr@npm:3.0.0" - checksum: 10/b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c +"graphql@npm:^16.13.0, graphql@npm:^16.13.2": + version: 16.14.0 + resolution: "graphql@npm:16.14.0" + checksum: 10/019bed00a1d62c90d38bd8971f827af9be479bd1935ac990b62edce8dbe5d9e1d93cae72e986199fdeb7108ee83e3f73c7492989ec08fcaf446b6bd79d533741 languageName: node linkType: hard -"cli-cursor@npm:^5.0.0": +"has-flag@npm:^4.0.0": + version: 4.0.0 + resolution: "has-flag@npm:4.0.0" + checksum: 10/261a1357037ead75e338156b1f9452c016a37dcd3283a972a30d9e4a87441ba372c8b81f818cd0fbcd9c0354b4ae7e18b9e1afa1971164aef6d18c2b6095a8ad + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10/959385c98696ebbca51e7534e0dc723ada325efa3475350951363cce216d27373e0259b63edb599f72eb94d6cde8577b4b2375f080b303947e560f85692834fa + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10/c74c5f5ceee3c8a5b8bc37719840dc3749f5b0306d818974141dda2471a1a2ca6c8e46b9d6ac222c5345df7a901c9b6f350b1e6d62763fec877e26609a401bfe + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.3 + resolution: "hasown@npm:2.0.3" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10/619526379cda755409d856cbf3c65b82ea342151719a0a550920cf7d6a7f58f7cf079e5a78f3acd162324fc784a3d3d6f6f61aff613b47a0163c16fbe09ea89f + languageName: node + linkType: hard + +"help-me@npm:^5.0.0": version: 5.0.0 - resolution: "cli-cursor@npm:5.0.0" + resolution: "help-me@npm:5.0.0" + checksum: 10/5f99bd91dae93d02867175c3856c561d7e3a24f16999b08f5fc79689044b938d7ed58457f4d8c8744c01403e6e0470b7896baa344d112b2355842fd935a75d69 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10/034d74029dcca544a34fb6135e98d427acd73019796ffc17383eaa3ec2fe1c0471dcbbc8f8ed39e46e86d43ccd753a160631615e4048285e313569609b66d5b7 + languageName: node + linkType: hard + +"http-cache-semantics@npm:^4.1.1": + version: 4.2.0 + resolution: "http-cache-semantics@npm:4.2.0" + checksum: 10/4efd2dfcfeea9d5e88c84af450b9980be8a43c2c8179508b1c57c7b4421c855f3e8efe92fa53e0b3f4a43c85824ada930eabbc306d1b3beab750b6dcc5187693 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" dependencies: - restore-cursor: "npm:^5.0.0" - checksum: 10/1eb9a3f878b31addfe8d82c6d915ec2330cec8447ab1f117f4aa34f0137fbb3137ec3466e1c9a65bcb7557f6e486d343f2da57f253a2f668d691372dfa15c090 + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10/d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848 languageName: node linkType: hard -"cli-spinners@npm:^3.2.0": - version: 3.4.0 - resolution: "cli-spinners@npm:3.4.0" - checksum: 10/6a4021c1999011fc34ae714f055dcdafb56309abc1f8fb021ea7d9370dfc524485fe8684226015e5fe6053dd30544e74270184ff7edc3fa4d37043b8efd0a054 +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10/f0dce7bdcac5e8eaa0be3c7368bb8836ed010fb5b6349ffb412b172a203efe8f807d9a6681319105ea1b6901e1972c7b5ea899672a7b9aad58309f766dcbe0df languageName: node linkType: hard -"compact-tools-monorepo@workspace:.": - version: 0.0.0-use.local - resolution: "compact-tools-monorepo@workspace:." +"https-proxy-agent@npm:^7.0.1": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" dependencies: - "@biomejs/biome": "npm:2.4.16" - "@types/node": "npm:25.9.1" - ts-node: "npm:^10.9.2" - turbo: "npm:^2.9.14" - typescript: "npm:^6.0.3" - vitest: "npm:^4.1.6" - languageName: unknown - linkType: soft + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2": + version: 0.6.3 + resolution: "iconv-lite@npm:0.6.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10/24e3292dd3dadaa81d065c6f8c41b274a47098150d444b96e5f53b4638a9a71482921ea6a91a1f59bb71d9796de25e04afd05919fa64c360347ba65d3766f10f + languageName: node + linkType: hard + +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4 + languageName: node + linkType: hard + +"imurmurhash@npm:^0.1.4": + version: 0.1.4 + resolution: "imurmurhash@npm:0.1.4" + checksum: 10/2d30b157a91fe1c1d7c6f653cbf263f039be6c5bfa959245a16d4ee191fc0f2af86c08545b6e6beeb041c56b574d2d5b9f95343d378ab49c0f37394d541e7fc8 + languageName: node + linkType: hard + +"inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 + languageName: node + linkType: hard + +"ip-address@npm:^10.0.1": + version: 10.2.0 + resolution: "ip-address@npm:10.2.0" + checksum: 10/12fec399e1af5753ac322e47a6d81a50d3a528b3abb17c09525b2a2edcaedcca628c40520706f7037bc4d8e951b0296c47e7b86d0a8e6e2335c8f0ba4afcfac1 + languageName: node + linkType: hard + +"is-buffer@npm:^2.0.5": + version: 2.0.5 + resolution: "is-buffer@npm:2.0.5" + checksum: 10/3261a8b858edcc6c9566ba1694bf829e126faa88911d1c0a747ea658c5d81b14b6955e3a702d59dabadd58fdd440c01f321aa71d6547105fd21d03f94d0597e7 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10/44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348 + languageName: node + linkType: hard -"convert-source-map@npm:^2.0.0": +"is-interactive@npm:^2.0.0": version: 2.0.0 - resolution: "convert-source-map@npm:2.0.0" - checksum: 10/c987be3ec061348cdb3c2bfb924bec86dea1eacad10550a85ca23edb0fe3556c3a61c7399114f3331ccb3499d7fd0285ab24566e5745929412983494c3926e15 + resolution: "is-interactive@npm:2.0.0" + checksum: 10/e8d52ad490bed7ae665032c7675ec07732bbfe25808b0efbc4d5a76b1a1f01c165f332775c63e25e9a03d319ebb6b24f571a9e902669fc1e40b0a60b5be6e26c languageName: node linkType: hard -"create-require@npm:^1.1.0": - version: 1.1.1 - resolution: "create-require@npm:1.1.1" - checksum: 10/a9a1503d4390d8b59ad86f4607de7870b39cad43d929813599a23714831e81c520bddf61bcdd1f8e30f05fd3a2b71ae8538e946eb2786dc65c2bbc520f692eff +"is-stream@npm:^2.0.1": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: 10/b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 languageName: node linkType: hard -"detect-libc@npm:^2.0.3": - version: 2.1.2 - resolution: "detect-libc@npm:2.1.2" - checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 +"is-unicode-supported@npm:^2.0.0, is-unicode-supported@npm:^2.1.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: 10/f254e3da6b0ab1a57a94f7273a7798dd35d1d45b227759f600d0fa9d5649f9c07fa8d3c8a6360b0e376adf916d151ec24fc9a50c5295c58bae7ca54a76a063f9 languageName: node linkType: hard -"diff@npm:^4.0.1": - version: 4.0.4 - resolution: "diff@npm:4.0.4" - checksum: 10/5019b3f5ae124ea9e95137119e1a83a59c252c75ddac873cc967832fd7a834570a58a4d58b941bdbd07832ebf98dcb232b27c561b7f5584357da6dae59bcac62 +"is-what@npm:^5.2.0": + version: 5.5.0 + resolution: "is-what@npm:5.5.0" + checksum: 10/d53a6ea1aebf953f3bcf711a28e8463bfe79fc0e4e87575d77c692a30fd3d98f87b88d4c006c06753bf85f771c9d2c1d05b2c6b03c246883261fe190526195d9 languageName: node linkType: hard -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 10/65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e +"isarray@npm:~1.0.0": + version: 1.0.0 + resolution: "isarray@npm:1.0.0" + checksum: 10/f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab languageName: node linkType: hard -"es-module-lexer@npm:^2.0.0": - version: 2.1.0 - resolution: "es-module-lexer@npm:2.1.0" - checksum: 10/554c4374e78a812a1fa3673871ce7d42236438c414ea80c2ec35521cd9bb26d1d9155287529057d07431fd91df50d6a26d9bee5afd755fb7f6f7c81905a03956 +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10/7c9f715c03aff08f35e98b1fadae1b9267b38f0615d501824f9743f3aab99ef10e303ce7db3f186763a0b70a19de5791ebfc854ff884d5a8c4d92211f642ec92 languageName: node linkType: hard -"estree-walker@npm:^3.0.3": - version: 3.0.3 - resolution: "estree-walker@npm:3.0.3" - dependencies: - "@types/estree": "npm:^1.0.0" - checksum: 10/a65728d5727b71de172c5df323385755a16c0fdab8234dc756c3854cfee343261ddfbb72a809a5660fac8c75d960bb3e21aa898c2d7e9b19bb298482ca58a3af +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 10/7fe1931ee4e88eb5aa524cd3ceb8c882537bc3a81b02e438b240e47012eef49c86904d0f0e593ea7c3a9996d18d0f1f3be8d3eaa92333977b0c3a9d353d5563e languageName: node linkType: hard -"expect-type@npm:^1.3.0": - version: 1.3.0 - resolution: "expect-type@npm:1.3.0" - checksum: 10/a5fada3d0c621649261f886e7d93e6bf80ce26d8a86e5d517e38301b8baec8450ab2cb94ba6e7a0a6bf2fc9ee55f54e1b06938ef1efa52ddcfeffbfa01acbbcc +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: 10/e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 languageName: node linkType: hard -"exponential-backoff@npm:^3.1.1": - version: 3.1.3 - resolution: "exponential-backoff@npm:3.1.3" - checksum: 10/ca25962b4bbab943b7c4ed0b5228e263833a5063c65e1cdeac4be9afad350aae5466e8e619b5051f4f8d37b2144a2d6e8fcc771b6cc82934f7dade2f964f652c +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10/40bbdd1e937dfd8c830fa286d0f665e81b7a78bdabcd4565f6d5667c99828bda3db7fb7ac6b96a3e2e8a2461ddbc5452d9f8bc7d00cb00075fa6a3e99f5b6a81 languageName: node linkType: hard -"fast-check@npm:^4.5.2": - version: 4.8.0 - resolution: "fast-check@npm:4.8.0" +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" dependencies: - pure-rand: "npm:^8.0.0" - checksum: 10/32101d27e69f7f0a3ea486f1a6feee6b34c824ba14bf3efce2ad1741fcd412817ac3ed14f53ef57a55d6fe2e00038e399c4a9c62444446a6e7a1a4b04bc6b88d + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10/86a83421ca1cf2109a9f6d193c06c31ef04a45e72a74579b11060b1e7bb9b6337a4e6f04abfb8857e2d569c271273c65e855ee429376a0d7c91ad91db42accd1 languageName: node linkType: hard -"fdir@npm:^6.5.0": - version: 6.5.0 - resolution: "fdir@npm:6.5.0" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1 +"istanbul-reports@npm:^3.2.0": + version: 3.2.0 + resolution: "istanbul-reports@npm:3.2.0" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10/6773a1d5c7d47eeec75b317144fe2a3b1da84a44b6282bebdc856e09667865e58c9b025b75b3d87f5bc62939126cbba4c871ee84254537d934ba5da5d4c4ec4e languageName: node linkType: hard -"fsevents@npm:~2.3.3": - version: 2.3.3 - resolution: "fsevents@npm:2.3.3" +"jackspeak@npm:^4.1.1": + version: 4.2.3 + resolution: "jackspeak@npm:4.2.3" dependencies: - node-gyp: "npm:latest" - checksum: 10/4c1ade961ded57cdbfbb5cac5106ec17bc8bccd62e16343c569a0ceeca83b9dfef87550b4dc5cbb89642da412b20c5071f304c8c464b80415446e8e155a038c0 - conditions: os=darwin + "@isaacs/cliui": "npm:^9.0.0" + checksum: 10/b88e3fe5fa04d34f0f939a15b7cef4a8589999b7a366ef89a3e0f2c45d2a7666066b67cbf46d57c3a4796a76d27b9d869b23d96a803dd834200d222c2a70de7e languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": - version: 2.3.3 - resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" - dependencies: - node-gyp: "npm:latest" - conditions: os=darwin +"joycon@npm:^3.1.1": + version: 3.1.1 + resolution: "joycon@npm:3.1.1" + checksum: 10/4b36e3479144ec196425f46b3618f8a96ce7e1b658f091a309cd4906215f5b7a402d7df331a3e0a09681381a658d0c5f039cb3cf6907e0a1e17ed847f5d37775 languageName: node linkType: hard -"get-east-asian-width@npm:^1.5.0": - version: 1.6.0 - resolution: "get-east-asian-width@npm:1.6.0" - checksum: 10/3e5370b5df1f0020db711d8a3f9ee2cbfc9c7542daa99a699e9d7b9acf66e7868b89084741565a45d30d80afedf6e1218e0fb8bef7a583924a449c2816777380 +"js-tokens@npm:^10.0.0": + version: 10.0.0 + resolution: "js-tokens@npm:10.0.0" + checksum: 10/88f536ec89f076fc230d29df255b3c55531237669d746d1868fca716b1e3f5f2e4abf8e5b8701903216e3f00d2dc3918d078b35da87772d433ab6a513c3bf76d languageName: node linkType: hard -"graceful-fs@npm:^4.2.6": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 +"json-stringify-safe@npm:^5.0.1": + version: 5.0.1 + resolution: "json-stringify-safe@npm:5.0.1" + checksum: 10/59169a081e4eeb6f9559ae1f938f656191c000e0512aa6df9f3c8b2437a4ab1823819c6b9fd1818a4e39593ccfd72e9a051fdd3e2d1e340ed913679e888ded8c languageName: node linkType: hard -"is-interactive@npm:^2.0.0": - version: 2.0.0 - resolution: "is-interactive@npm:2.0.0" - checksum: 10/e8d52ad490bed7ae665032c7675ec07732bbfe25808b0efbc4d5a76b1a1f01c165f332775c63e25e9a03d319ebb6b24f571a9e902669fc1e40b0a60b5be6e26c +"lazystream@npm:^1.0.0": + version: 1.0.1 + resolution: "lazystream@npm:1.0.1" + dependencies: + readable-stream: "npm:^2.0.5" + checksum: 10/35f8cf8b5799c76570b211b079d4d706a20cbf13a4936d44cc7dbdacab1de6b346ab339ed3e3805f4693155ee5bbebbda4050fa2b666d61956e89a573089e3d4 languageName: node linkType: hard -"is-unicode-supported@npm:^2.0.0, is-unicode-supported@npm:^2.1.0": - version: 2.1.0 - resolution: "is-unicode-supported@npm:2.1.0" - checksum: 10/f254e3da6b0ab1a57a94f7273a7798dd35d1d45b227759f600d0fa9d5649f9c07fa8d3c8a6360b0e376adf916d151ec24fc9a50c5295c58bae7ca54a76a063f9 +"level-supports@npm:^6.2.0": + version: 6.2.0 + resolution: "level-supports@npm:6.2.0" + checksum: 10/450c04839cf42ac7c73085b4928f1c1c51d9ab179aac9102cc8ef2389faf2d06cebaf57df2d025da89d78465004ccf29bfd972a04b0b35d5d423fa3f4516f906 languageName: node linkType: hard -"isexe@npm:^4.0.0": - version: 4.0.0 - resolution: "isexe@npm:4.0.0" - checksum: 10/2ead327ef596042ef9c9ec5f236b316acfaedb87f4bb61b3c3d574fb2e9c8a04b67305e04733bde52c24d9622fdebd3270aadb632adfbf9cadef88fe30f479e5 +"level-transcoder@npm:^1.0.1": + version: 1.0.1 + resolution: "level-transcoder@npm:1.0.1" + dependencies: + buffer: "npm:^6.0.3" + module-error: "npm:^1.0.1" + checksum: 10/2fb41a1d8037fc279f851ead8cdc3852b738f1f935ac2895183cd606aae3e57008e085c7c2bd2b2d43cfd057333108cfaed604092e173ac2abdf5ab1b8333f9e + languageName: node + linkType: hard + +"level@npm:^10.0.0": + version: 10.0.0 + resolution: "level@npm:10.0.0" + dependencies: + abstract-level: "npm:^3.1.0" + browser-level: "npm:^3.0.0" + classic-level: "npm:^3.0.0" + checksum: 10/c04a81530e0472b7dbcd061ee32fb498675574b45e1121ec3ed8407734ed45a7b4ca7ef72a70a710c53b35a3d77223fc90092877e807e9f21a557c5219e9d54b languageName: node linkType: hard @@ -925,118 +3720,491 @@ __metadata: lightningcss-win32-arm64-msvc: "npm:1.32.0" lightningcss-win32-x64-msvc: "npm:1.32.0" dependenciesMeta: - lightningcss-android-arm64: - optional: true - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10/098e61007f0d0ec8b5c50884e33b543b551d1ff21bc7b062434b6638fd0b8596858f823b60dfc2a4aa756f3cb120ad79f2b7f4a55b1bda2c0269ab8cf476f114 + languageName: node + linkType: hard + +"lodash.camelcase@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.camelcase@npm:4.3.0" + checksum: 10/c301cc379310441dc73cd6cebeb91fb254bea74e6ad3027f9346fc43b4174385153df420ffa521654e502fd34c40ef69ca4e7d40ee7129a99e06f306032bfc65 + languageName: node + linkType: hard + +"lodash@npm:^4.17.15": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10/306fea53dfd39dad1f03d45ba654a2405aebd35797b673077f401edb7df2543623dc44b9effbb98f69b32152295fff725a4cec99c684098947430600c6af0c3f + languageName: node + linkType: hard + +"log-symbols@npm:^7.0.0, log-symbols@npm:^7.0.1": + version: 7.0.1 + resolution: "log-symbols@npm:7.0.1" + dependencies: + is-unicode-supported: "npm:^2.0.0" + yoctocolors: "npm:^2.1.1" + checksum: 10/0862313d84826b551582e39659b8586c56b65130c5f4f976420e2c23985228334f2a26fc4251ac22bf0a5b415d9430e86bf332557d934c10b036f9a549d63a09 + languageName: node + linkType: hard + +"long@npm:^5.0.0, long@npm:^5.3.2": + version: 5.3.2 + resolution: "long@npm:5.3.2" + checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 + languageName: node + linkType: hard + +"lru-cache@npm:^10.0.1": + version: 10.4.3 + resolution: "lru-cache@npm:10.4.3" + checksum: 10/e6e90267360476720fa8e83cc168aa2bf0311f3f2eea20a6ba78b90a885ae72071d9db132f40fda4129c803e7dcec3a6b6a6fbb44ca90b081630b810b5d6a41a + languageName: node + linkType: hard + +"lru-cache@npm:^11.0.0": + version: 11.4.0 + resolution: "lru-cache@npm:11.4.0" + checksum: 10/c6bb5bb7cd1938c6a96ec70e8cae4b2181bca3852013b51b64c3a40dadb14271f1a3337d5f34350d03d9506970e73be5161eddcf7df524fdf4ad0e390e7d534c + languageName: node + linkType: hard + +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221 + languageName: node + linkType: hard + +"magicast@npm:^0.5.2": + version: 0.5.3 + resolution: "magicast@npm:0.5.3" + dependencies: + "@babel/parser": "npm:^7.29.3" + "@babel/types": "npm:^7.29.0" + source-map-js: "npm:^1.2.1" + checksum: 10/436ad518726b691cf9ac1a14ab14705784f28075892a092b06e8b17ac7303fe57e8a2789989c68b560653a909a8df49d1582bb73f9bdad4bcbab892201251049 + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10/bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + +"make-error@npm:^1.1.1": + version: 1.3.6 + resolution: "make-error@npm:1.3.6" + checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 + languageName: node + linkType: hard + +"make-fetch-happen@npm:^14.0.3": + version: 14.0.3 + resolution: "make-fetch-happen@npm:14.0.3" + dependencies: + "@npmcli/agent": "npm:^3.0.0" + cacache: "npm:^19.0.1" + http-cache-semantics: "npm:^4.1.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^4.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^1.0.0" + proc-log: "npm:^5.0.0" + promise-retry: "npm:^2.0.1" + ssri: "npm:^12.0.0" + checksum: 10/fce0385840b6d86b735053dfe941edc2dd6468fda80fe74da1eeff10cbd82a75760f406194f2bc2fa85b99545b2bc1f84c08ddf994b21830775ba2d1a87e8bdf + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd + languageName: node + linkType: hard + +"maybe-combine-errors@npm:^1.0.0": + version: 1.0.0 + resolution: "maybe-combine-errors@npm:1.0.0" + checksum: 10/16bb6d3dcf79fc61f5a04abe948c4c81cae0da6ee5da9a1d8196f1723b069d6ab60f752bc208e18481e2b82de146e068bc462558c65ecdf96fed0d021a1aa6ab + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10/54bb60bf39e6f8689f6622784e668a3d7f8bed6b0d886f5c3c446cb3284be28b30bf707ed05d0fe44a036f8469976b2629bbea182684977b084de9da274694d7 + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10/89aa9651b67644035de2784a6e665fc685d79aba61857e02b9c8758da874a754aed4a9aced9265f5ed1171fd934331e5516b84a7f0218031b6fa0270eca1e51a + languageName: node + linkType: hard + +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10/eb5893c99e902ccebbc267c6c6b83092966af84682957f79313311edb95e8bb5f39fb048d77132b700474d1c86d90ccc211e99bae0935447a4834eb4c882982c + languageName: node + linkType: hard + +"minimatch@npm:^10.1.1": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + +"minimatch@npm:^5.1.0": + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/23b4feb64dcb77ba93b70a72be551eb2e2677ac02178cf1ed3d38836cc4cd84802d90b77f60ef87f2bac64d270d2d8eba242e428f0554ea4e36bfdb7e9d25d0c + languageName: node + linkType: hard + +"minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f + languageName: node + linkType: hard + +"minipass-collect@npm:^2.0.1": + version: 2.0.1 + resolution: "minipass-collect@npm:2.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10/b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342 + languageName: node + linkType: hard + +"minipass-fetch@npm:^4.0.0": + version: 4.0.1 + resolution: "minipass-fetch@npm:4.0.1" + dependencies: + encoding: "npm:^0.1.13" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^1.0.3" + minizlib: "npm:^3.0.1" + dependenciesMeta: + encoding: + optional: true + checksum: 10/7ddfebdbb87d9866e7b5f7eead5a9e3d9d507992af932a11d275551f60006cf7d9178e66d586dbb910894f3e3458d27c0ddf93c76e94d49d0a54a541ddc1263d + languageName: node + linkType: hard + +"minipass-flush@npm:^1.0.5": + version: 1.0.5 + resolution: "minipass-flush@npm:1.0.5" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10/56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf + languageName: node + linkType: hard + +"minipass-pipeline@npm:^1.2.4": + version: 1.2.4 + resolution: "minipass-pipeline@npm:1.2.4" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10/b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b + languageName: node + linkType: hard + +"minipass-sized@npm:^1.0.3": + version: 1.0.3 + resolution: "minipass-sized@npm:1.0.3" + dependencies: + minipass: "npm:^3.0.0" + checksum: 10/40982d8d836a52b0f37049a0a7e5d0f089637298e6d9b45df9c115d4f0520682a78258905e5c8b180fb41b593b0a82cc1361d2c74b45f7ada66334f84d1ecfdd + languageName: node + linkType: hard + +"minipass@npm:^3.0.0": + version: 3.3.6 + resolution: "minipass@npm:3.3.6" + dependencies: + yallist: "npm:^4.0.0" + checksum: 10/a5c6ef069f70d9a524d3428af39f2b117ff8cd84172e19b754e7264a33df460873e6eb3d6e55758531580970de50ae950c496256bb4ad3691a2974cddff189f0 + languageName: node + linkType: hard + +"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950 + languageName: node + linkType: hard + +"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10/f47365cc2cb7f078cbe7e046eb52655e2e7e97f8c0a9a674f4da60d94fb0624edfcec9b5db32e8ba5a99a5f036f595680ae6fe02a262beaa73026e505cc52f99 + languageName: node + linkType: hard + +"mkdirp-classic@npm:^0.5.2": + version: 0.5.3 + resolution: "mkdirp-classic@npm:0.5.3" + checksum: 10/3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac + languageName: node + linkType: hard + +"mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: 10/d71b8dcd4b5af2fe13ecf3bd24070263489404fe216488c5ba7e38ece1f54daf219e72a833a3a2dc404331e870e9f44963a33399589490956bff003a3404d3b2 + languageName: node + linkType: hard + +"mock-socket@npm:^9.3.1": + version: 9.3.1 + resolution: "mock-socket@npm:9.3.1" + checksum: 10/c5c07568f2859db6926d79cb61580c07e67958b5cd6b52d1270fdfa17ae066d7f74a18a4208fc4386092eea4e1ee001aa23f015c88a1774265994e4fae34d18e + languageName: node + linkType: hard + +"module-error@npm:^1.0.1": + version: 1.0.2 + resolution: "module-error@npm:1.0.2" + checksum: 10/5d653e35bd55b3e95f8aee2cdac108082ea892e71b8f651be92cde43e4ee86abee4fa8bd7fc3fe5e68b63926d42f63c54cd17b87a560c31f18739295575a3962 + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d + languageName: node + linkType: hard + +"msgpackr-extract@npm:^3.0.2": + version: 3.0.3 + resolution: "msgpackr-extract@npm:3.0.3" + dependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.3" + "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.3" + "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.3" + "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.3" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.2.2" + dependenciesMeta: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: + "@msgpackr-extract/msgpackr-extract-darwin-x64": optional: true - lightningcss-linux-arm64-musl: + "@msgpackr-extract/msgpackr-extract-linux-arm": optional: true - lightningcss-linux-x64-gnu: + "@msgpackr-extract/msgpackr-extract-linux-arm64": optional: true - lightningcss-linux-x64-musl: + "@msgpackr-extract/msgpackr-extract-linux-x64": optional: true - lightningcss-win32-arm64-msvc: + "@msgpackr-extract/msgpackr-extract-win32-x64": optional: true - lightningcss-win32-x64-msvc: + bin: + download-msgpackr-prebuilds: bin/download-prebuilds.js + checksum: 10/4bfe45cf6968310570765951691f1b8e85b6a837e5197b8232fc9285eef4b457992e73118d9d07c92a52cc23f9e837897b135e17ea0f73e3604540434051b62f + languageName: node + linkType: hard + +"msgpackr@npm:^1.11.10, msgpackr@npm:^1.11.4": + version: 1.11.12 + resolution: "msgpackr@npm:1.11.12" + dependencies: + msgpackr-extract: "npm:^3.0.2" + dependenciesMeta: + msgpackr-extract: optional: true - checksum: 10/098e61007f0d0ec8b5c50884e33b543b551d1ff21bc7b062434b6638fd0b8596858f823b60dfc2a4aa756f3cb120ad79f2b7f4a55b1bda2c0269ab8cf476f114 + checksum: 10/8077d7ebf661df831ba119a277588b7e00149d25b6f5630e311c2415504553ce695347a351a7198cdf1f596feaaf91121adc3181e483f7d2c9822484b73babf2 languageName: node linkType: hard -"log-symbols@npm:^7.0.0, log-symbols@npm:^7.0.1": - version: 7.0.1 - resolution: "log-symbols@npm:7.0.1" +"multipasta@npm:^0.2.7": + version: 0.2.7 + resolution: "multipasta@npm:0.2.7" + checksum: 10/244a7194ff508b3c5c1724f11c303f1c446cf6142cdbe82e57d5e59c44abb4942b1b983dd8c0d9c63080e684b2a8fa10f511df70d42dbef4d215ed7d41e76fcc + languageName: node + linkType: hard + +"nan@npm:^2.19.0, nan@npm:^2.23.0": + version: 2.27.0 + resolution: "nan@npm:2.27.0" dependencies: - is-unicode-supported: "npm:^2.0.0" - yoctocolors: "npm:^2.1.1" - checksum: 10/0862313d84826b551582e39659b8586c56b65130c5f4f976420e2c23985228334f2a26fc4251ac22bf0a5b415d9430e86bf332557d934c10b036f9a549d63a09 + node-gyp: "npm:latest" + checksum: 10/bdce0630e417740501394c412bd9f0ed1c287825e3b8f9b7efb95cc3acd3ef69de60479b5f00a2d039b79321e5ce29b672b0b263cfe0e4d8f47c8f810a24a5ee languageName: node linkType: hard -"magic-string@npm:^0.30.21": - version: 0.30.21 - resolution: "magic-string@npm:0.30.21" +"nanoid@npm:^3.3.12": + version: 3.3.12 + resolution: "nanoid@npm:3.3.12" + bin: + nanoid: bin/nanoid.cjs + checksum: 10/6eec280694e2088d18fb802b1e3bfc4578e27b665b7ecfbe36c7356612fea2f814277056e671e2a1529dff551588a652efdc0bfa39f8a3185bc2247be311872e + languageName: node + linkType: hard + +"napi-macros@npm:^2.2.2": + version: 2.2.2 + resolution: "napi-macros@npm:2.2.2" + checksum: 10/2cdb9c40ad4b424b14fbe5e13c5329559e2b511665acf41cdcda172fd2270202dc747a2d288b687c72bc70f654c797bc24a93adb67631128d62461588d7cc070 + languageName: node + linkType: hard + +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 10/b5734e87295324fabf868e36fb97c84b7d7f3156ec5f4ee5bf6e488079c11054f818290fc33804cef7b1ee21f55eeb14caea83e7dafae6492a409b3e573153e5 + languageName: node + linkType: hard + +"nock@npm:^13.5.5": + version: 13.5.6 + resolution: "nock@npm:13.5.6" dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221 + debug: "npm:^4.1.0" + json-stringify-safe: "npm:^5.0.1" + propagate: "npm:^2.0.0" + checksum: 10/a57c265b75e5f7767e2f8baf058773cdbf357c31c5fea2761386ec03a008a657f9df921899fe2a9502773b47145b708863b32345aef529b3c45cba4019120f88 languageName: node linkType: hard -"make-error@npm:^1.1.1": - version: 1.3.6 - resolution: "make-error@npm:1.3.6" - checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10/e332522f242348c511640c25a6fc7da4f30e09e580c70c6b13cb0be83c78c3e71c8d4665af2527e869fc96848924a4316ae7ec9014c091e2156f41739d4fa233 languageName: node linkType: hard -"mimic-function@npm:^5.0.0": - version: 5.0.1 - resolution: "mimic-function@npm:5.0.1" - checksum: 10/eb5893c99e902ccebbc267c6c6b83092966af84682957f79313311edb95e8bb5f39fb048d77132b700474d1c86d90ccc211e99bae0935447a4834eb4c882982c +"node-fetch@npm:^2.7.0": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10/b24f8a3dc937f388192e59bcf9d0857d7b6940a2496f328381641cb616efccc9866e89ec43f2ec956bbd6c3d3ee05524ce77fe7b29ccd34692b3a16f237d6676 languageName: node linkType: hard -"minipass@npm:^7.0.4, minipass@npm:^7.1.2": - version: 7.1.3 - resolution: "minipass@npm:7.1.3" - checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6 +"node-fetch@npm:^3.3.2": + version: 3.3.2 + resolution: "node-fetch@npm:3.3.2" + dependencies: + data-uri-to-buffer: "npm:^4.0.0" + fetch-blob: "npm:^3.1.4" + formdata-polyfill: "npm:^4.0.10" + checksum: 10/24207ca8c81231c7c59151840e3fded461d67a31cf3e3b3968e12201a42f89ce4a0b5fb7079b1fa0a4655957b1ca9257553200f03a9f668b45ebad265ca5593d languageName: node linkType: hard -"minizlib@npm:^3.1.0": - version: 3.1.0 - resolution: "minizlib@npm:3.1.0" +"node-gyp-build-optional-packages@npm:5.2.2": + version: 5.2.2 + resolution: "node-gyp-build-optional-packages@npm:5.2.2" dependencies: - minipass: "npm:^7.1.2" - checksum: 10/f47365cc2cb7f078cbe7e046eb52655e2e7e97f8c0a9a674f4da60d94fb0624edfcec9b5db32e8ba5a99a5f036f595680ae6fe02a262beaa73026e505cc52f99 + detect-libc: "npm:^2.0.1" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10/f448a328cf608071dc8cc4426ac5be0daec4788e4e1759e9f7ffcd286822cc799384edce17a8c79e610c4bbfc8e3aff788f3681f1d88290e0ca7aaa5342a090f languageName: node linkType: hard -"nanoid@npm:^3.3.12": - version: 3.3.12 - resolution: "nanoid@npm:3.3.12" +"node-gyp-build@npm:^4.3.0": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" bin: - nanoid: bin/nanoid.cjs - checksum: 10/6eec280694e2088d18fb802b1e3bfc4578e27b665b7ecfbe36c7356612fea2f814277056e671e2a1529dff551588a652efdc0bfa39f8a3185bc2247be311872e + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10/6a7d62289d1afc419fc8fc9bd00aa4e554369e50ca0acbc215cb91446148b75ff7e2a3b53c2c5b2c09a39d416d69f3d3237937860373104b5fe429bf30ad9ac5 languageName: node linkType: hard "node-gyp@npm:latest": - version: 12.3.0 - resolution: "node-gyp@npm:12.3.0" + version: 11.5.0 + resolution: "node-gyp@npm:11.5.0" dependencies: env-paths: "npm:^2.2.0" exponential-backoff: "npm:^3.1.1" graceful-fs: "npm:^4.2.6" - nopt: "npm:^9.0.0" - proc-log: "npm:^6.0.0" + make-fetch-happen: "npm:^14.0.3" + nopt: "npm:^8.0.0" + proc-log: "npm:^5.0.0" semver: "npm:^7.3.5" - tar: "npm:^7.5.4" + tar: "npm:^7.4.3" tinyglobby: "npm:^0.2.12" - undici: "npm:^6.25.0" - which: "npm:^6.0.0" + which: "npm:^5.0.0" bin: node-gyp: bin/node-gyp.js - checksum: 10/cd97bf17f0f3e6288c42cc23a6db8528a98e7530abdb72ab558272906d603362e4558069f99f8a5250bc78f65ff305b1438caca4f1b31c81904a8798c242603e + checksum: 10/15a600b626116e1e528c49f73027c5ff84dbf6986df77b0fb61d6eb079ab4230c39f245295cb67f0590e6541a848cbd267e00c5769e8fb8bf88a5cca3701b551 languageName: node linkType: hard -"nopt@npm:^9.0.0": - version: 9.0.0 - resolution: "nopt@npm:9.0.0" +"nopt@npm:^8.0.0": + version: 8.1.0 + resolution: "nopt@npm:8.1.0" dependencies: - abbrev: "npm:^4.0.0" + abbrev: "npm:^3.0.0" bin: nopt: bin/nopt.js - checksum: 10/56a1ccd2ad711fb5115918e2c96828703cddbe12ba2c3bd00591758f6fa30e6f47dd905c59dbfcf9b773f3a293b45996609fb6789ae29d6bfcc3cf3a6f7d9fda + checksum: 10/26ab456c51a96f02a9e5aa8d1b80ef3219f2070f3f3528a040e32fb735b1e651e17bdf0f1476988d3a46d498f35c65ed662d122f340d38ce4a7e71dd7b20c4bc + languageName: node + linkType: hard + +"normalize-path@npm:^3.0.0": + version: 3.0.0 + resolution: "normalize-path@npm:3.0.0" + checksum: 10/88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 languageName: node linkType: hard @@ -1054,6 +4222,22 @@ __metadata: languageName: node linkType: hard +"on-exit-leak-free@npm:^2.1.0": + version: 2.1.2 + resolution: "on-exit-leak-free@npm:2.1.2" + checksum: 10/f7b4b7200026a08f6e4a17ba6d72e6c5cbb41789ed9cf7deaf9d9e322872c7dc5a7898549a894651ee0ee9ae635d34a678115bf8acdfba8ebd2ba2af688b563c + languageName: node + linkType: hard + +"once@npm:^1.3.1, once@npm:^1.4.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10/cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68 + languageName: node + linkType: hard + "onetime@npm:^7.0.0": version: 7.0.0 resolution: "onetime@npm:7.0.0" @@ -1063,9 +4247,21 @@ __metadata: languageName: node linkType: hard +"optimism@npm:^0.18.0": + version: 0.18.1 + resolution: "optimism@npm:0.18.1" + dependencies: + "@wry/caches": "npm:^1.0.0" + "@wry/context": "npm:^0.7.0" + "@wry/trie": "npm:^0.5.0" + tslib: "npm:^2.3.0" + checksum: 10/d805f5995d61a417d4fd49a923749db1aa310d1ae8de084ec3a5f589f8b185d9a41b7b4422d33ee75ce43115c264e14bca086f8be2bb182c76448ad08997213a + languageName: node + linkType: hard + "ora@npm:^9.0.0": - version: 9.4.0 - resolution: "ora@npm:9.4.0" + version: 9.0.0 + resolution: "ora@npm:9.0.0" dependencies: chalk: "npm:^5.6.2" cli-cursor: "npm:^5.0.0" @@ -1073,9 +4269,41 @@ __metadata: is-interactive: "npm:^2.0.0" is-unicode-supported: "npm:^2.1.0" log-symbols: "npm:^7.0.1" - stdin-discarder: "npm:^0.3.2" + stdin-discarder: "npm:^0.2.2" string-width: "npm:^8.1.0" - checksum: 10/48fe48f98764d1132a77d845862fab1b1f8d7aacd4c38c39ad42a874a55686c5949194eeb836a7ee4c43027a471ffb93937eff69f4db7353d6800b678073de55 + strip-ansi: "npm:^7.1.2" + checksum: 10/b6074c9cec4a39c1b4f41c2ce2741982a99c53c86bd6f07a28fb6274857263af7fe1a340136629939934b553af35b03fc62ca2a88baa6803b2f9bfdf269fb850 + languageName: node + linkType: hard + +"p-map@npm:^7.0.2": + version: 7.0.3 + resolution: "p-map@npm:7.0.3" + checksum: 10/2ef48ccfc6dd387253d71bf502604f7893ed62090b2c9d73387f10006c342606b05233da0e4f29388227b61eb5aeface6197e166520c465c234552eeab2fe633 + languageName: node + linkType: hard + +"package-json-from-dist@npm:^1.0.0": + version: 1.0.1 + resolution: "package-json-from-dist@npm:1.0.1" + checksum: 10/58ee9538f2f762988433da00e26acc788036914d57c71c246bf0be1b60cdbd77dd60b6a3e1a30465f0b248aeb80079e0b34cb6050b1dfa18c06953bb1cbc7602 + languageName: node + linkType: hard + +"path-key@npm:^3.1.0": + version: 3.1.1 + resolution: "path-key@npm:3.1.1" + checksum: 10/55cd7a9dd4b343412a8386a743f9c746ef196e57c823d90ca3ab917f90ab9f13dd0ded27252ba49dbdfcab2b091d998bc446f6220cd3cea65db407502a740020 + languageName: node + linkType: hard + +"path-scurry@npm:^2.0.0": + version: 2.0.2 + resolution: "path-scurry@npm:2.0.2" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3 languageName: node linkType: hard @@ -1100,6 +4328,75 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^2.0.0": + version: 2.0.0 + resolution: "pino-abstract-transport@npm:2.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10/e5699ecb06c7121055978e988e5cecea5b6892fc2589c64f1f86df5e7386bbbfd2ada268839e911b021c6b3123428aed7c6be3ac7940eee139556c75324c7e83 + languageName: node + linkType: hard + +"pino-abstract-transport@npm:^3.0.0": + version: 3.0.0 + resolution: "pino-abstract-transport@npm:3.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10/f42b85b2663c8520839124a55b27801e88c89c65e9569384b49bb4c81b022ae24860020c2375b92a03db699113969007cc155e1fb2dfe53754403920c1cbe18c + languageName: node + linkType: hard + +"pino-pretty@npm:^13.0.0": + version: 13.1.3 + resolution: "pino-pretty@npm:13.1.3" + dependencies: + colorette: "npm:^2.0.7" + dateformat: "npm:^4.6.3" + fast-copy: "npm:^4.0.0" + fast-safe-stringify: "npm:^2.1.1" + help-me: "npm:^5.0.0" + joycon: "npm:^3.1.1" + minimist: "npm:^1.2.6" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^3.0.0" + pump: "npm:^3.0.0" + secure-json-parse: "npm:^4.0.0" + sonic-boom: "npm:^4.0.1" + strip-json-comments: "npm:^5.0.2" + bin: + pino-pretty: bin.js + checksum: 10/4bb721e1ece378c1c9000457e4fe4a914ea5b8e036551608f5681ca58c8fbacc6b8a31807e93bc0c66d17fb5d96e74b3e4051fb53152955dc51ac58848428e27 + languageName: node + linkType: hard + +"pino-std-serializers@npm:^7.0.0": + version: 7.1.0 + resolution: "pino-std-serializers@npm:7.1.0" + checksum: 10/6e27f6f885927b6df3b424ddb8a9e0e9854f3b59f4abd51afa74e1c2cf33436a505277b004bb00ce61884a962c8fdfd977391205c7baab885d6afb35fce7396a + languageName: node + linkType: hard + +"pino@npm:^9.7.0": + version: 9.14.0 + resolution: "pino@npm:9.14.0" + dependencies: + "@pinojs/redact": "npm:^0.4.0" + atomic-sleep: "npm:^1.0.0" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^5.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^3.0.0" + bin: + pino: bin.js + checksum: 10/918e1fc764885150cb2b4fae8249a0ece53275020a7ca389f994fa2fbbb17b6353cd736c2db3a3794fbac0351f8e3d58411fabe127e875e24151a8fa4cd0b2b5 + languageName: node + linkType: hard + "postcss@npm:^8.5.15": version: 8.5.15 resolution: "postcss@npm:8.5.15" @@ -1111,17 +4408,188 @@ __metadata: languageName: node linkType: hard -"proc-log@npm:^6.0.0": +"proc-log@npm:^5.0.0": + version: 5.0.0 + resolution: "proc-log@npm:5.0.0" + checksum: 10/35610bdb0177d3ab5d35f8827a429fb1dc2518d9e639f2151ac9007f01a061c30e0c635a970c9b00c39102216160f6ec54b62377c92fac3b7bfc2ad4b98d195c + languageName: node + linkType: hard + +"process-nextick-args@npm:~2.0.0": + version: 2.0.1 + resolution: "process-nextick-args@npm:2.0.1" + checksum: 10/1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf + languageName: node + linkType: hard + +"process-warning@npm:^5.0.0": + version: 5.0.0 + resolution: "process-warning@npm:5.0.0" + checksum: 10/10f3e00ac9fc1943ec4566ff41fff2b964e660f853c283e622257719839d340b4616e707d62a02d6aa0038761bb1fa7c56bc7308d602d51bd96f05f9cd305dcd + languageName: node + linkType: hard + +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10/dbaa7e8d1d5cf375c36963ff43116772a989ef2bb47c9bdee20f38fd8fc061119cf38140631cf90c781aca4d3f0f0d2c834711952b728953f04fd7d238f59f5b + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10/96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4 + languageName: node + linkType: hard + +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: 10/8c761c16e8232f82f6d015d3e01e8bd4109f47ad804f904d950f6fe319813b448ca112246b6bfdc182b400424b155b0b7c4525a9bb009e6fa950200157569c14 + languageName: node + linkType: hard + +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10/000a4875f543f591872b36ca94531af8a6463ddb0174f41c0b004d19e231d7445268b422ff1ea595e43d238655c702250cd3d27f408e7b9d97b56f1533ba26bf + languageName: node + linkType: hard + +"properties-reader@npm:^2.3.0": + version: 2.3.0 + resolution: "properties-reader@npm:2.3.0" + dependencies: + mkdirp: "npm:^1.0.4" + checksum: 10/0b41eb4136dc278ae0d97968ccce8de2d48d321655b319192e31f2424f1c6e052182204671e65aa8967216360cb3e7cbd9129830062e058fe9d6a1d74964c29a + languageName: node + linkType: hard + +"protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.2, protobufjs@npm:^7.5.5": + version: 7.6.0 + resolution: "protobufjs@npm:7.6.0" + dependencies: + "@protobufjs/aspromise": "npm:^1.1.2" + "@protobufjs/base64": "npm:^1.1.2" + "@protobufjs/codegen": "npm:^2.0.5" + "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/fetch": "npm:^1.1.1" + "@protobufjs/float": "npm:^1.0.2" + "@protobufjs/inquire": "npm:^1.1.2" + "@protobufjs/path": "npm:^1.1.2" + "@protobufjs/pool": "npm:^1.1.0" + "@protobufjs/utf8": "npm:^1.1.1" + "@types/node": "npm:>=13.7.0" + long: "npm:^5.3.2" + checksum: 10/2becdf429fa148b2f3c9ee5e52c7b8249d2b775d158ce9e5bcf82d2f9d979bf95667818f5c70487636f775e5712aecf20775ac6e86a019e146fb95ed4063dfdc + languageName: node + linkType: hard + +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10/fbbaf4dab2a6231dc9e394903a5f66f20475e36b734335790b46feb9da07c37d6b32e2c02e3e2ea4d4b23774c53d8562e5b7cc73282cb43f4a597b7eacaee2ee + languageName: node + linkType: hard + +"pump@npm:^3.0.0": + version: 3.0.4 + resolution: "pump@npm:3.0.4" + dependencies: + end-of-stream: "npm:^1.1.0" + once: "npm:^1.3.1" + checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49 + languageName: node + linkType: hard + +"pure-rand@npm:^6.1.0": version: 6.1.0 - resolution: "proc-log@npm:6.1.0" - checksum: 10/9033f30f168ed5a0991b773d0c50ff88384c4738e9a0a67d341de36bf7293771eed648ab6a0562f62276da12fde91f3bbfc75ffff6e71ad49aafd74fc646be66 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + +"pure-rand@npm:^7.0.0": + version: 7.0.1 + resolution: "pure-rand@npm:7.0.1" + checksum: 10/c61a576fda5032ec9763ecb000da4a8f19263b9e2f9ae9aa2759c8fbd9dc6b192b2ce78391ebd41abb394a5fedb7bcc4b03c9e6141ac8ab20882dd5717698b80 + languageName: node + linkType: hard + +"quick-format-unescaped@npm:^4.0.3": + version: 4.0.4 + resolution: "quick-format-unescaped@npm:4.0.4" + checksum: 10/591eca457509a99368b623db05248c1193aa3cedafc9a077d7acab09495db1231017ba3ad1b5386e5633271edd0a03b312d8640a59ee585b8516a42e15438aa7 + languageName: node + linkType: hard + +"readable-stream@npm:^2.0.5": + version: 2.3.8 + resolution: "readable-stream@npm:2.3.8" + dependencies: + core-util-is: "npm:~1.0.0" + inherits: "npm:~2.0.3" + isarray: "npm:~1.0.0" + process-nextick-args: "npm:~2.0.0" + safe-buffer: "npm:~5.1.1" + string_decoder: "npm:~1.1.1" + util-deprecate: "npm:~1.0.1" + checksum: 10/8500dd3a90e391d6c5d889256d50ec6026c059fadee98ae9aa9b86757d60ac46fff24fafb7a39fa41d54cb39d8be56cc77be202ebd4cd8ffcf4cb226cbaa40d4 + languageName: node + linkType: hard + +"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048 + languageName: node + linkType: hard + +"readable-stream@npm:^4.0.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10/bdf096c8ff59452ce5d08f13da9597f9fcfe400b4facfaa88e74ec057e5ad1fdfa140ffe28e5ed806cf4d2055f0b812806e962bca91dce31bc4cef08e53be3a4 + languageName: node + linkType: hard + +"readdir-glob@npm:^1.1.2": + version: 1.1.3 + resolution: "readdir-glob@npm:1.1.3" + dependencies: + minimatch: "npm:^5.1.0" + checksum: 10/ca3a20aa1e715d671302d4ec785a32bf08e59d6d0dd25d5fc03e9e5a39f8c612cdf809ab3e638a79973db7ad6868492edf38504701e313328e767693671447d6 + languageName: node + linkType: hard + +"real-require@npm:^0.2.0": + version: 0.2.0 + resolution: "real-require@npm:0.2.0" + checksum: 10/ddf44ee76301c774e9c9f2826da8a3c5c9f8fc87310f4a364e803ef003aa1a43c378b4323051ced212097fff1af459070f4499338b36a7469df1d4f7e8c0ba4c languageName: node linkType: hard -"pure-rand@npm:^8.0.0": - version: 8.4.0 - resolution: "pure-rand@npm:8.4.0" - checksum: 10/baaee81b6647c89e426458fa0a6fcf51a42315db3db6a2c1c48d676813dd7cebf46dc56d352ce352c3bdfacd5a965b9c7783ba2a92f38cf2e64a9a8186469c3e +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10/a72468e2589270d91f06c7d36ec97a88db53ae5d6fe3787fadc943f0b0276b10347f89b363b2a82285f650bdcc135ad4a257c61bdd4d00d6df1fa24875b0ddaf languageName: node linkType: hard @@ -1135,26 +4603,33 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.2": - version: 1.0.2 - resolution: "rolldown@npm:1.0.2" - dependencies: - "@oxc-project/types": "npm:=0.132.0" - "@rolldown/binding-android-arm64": "npm:1.0.2" - "@rolldown/binding-darwin-arm64": "npm:1.0.2" - "@rolldown/binding-darwin-x64": "npm:1.0.2" - "@rolldown/binding-freebsd-x64": "npm:1.0.2" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.2" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.2" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.2" - "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.2" - "@rolldown/binding-linux-s390x-gnu": "npm:1.0.2" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.2" - "@rolldown/binding-linux-x64-musl": "npm:1.0.2" - "@rolldown/binding-openharmony-arm64": "npm:1.0.2" - "@rolldown/binding-wasm32-wasi": "npm:1.0.2" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.2" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.2" +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10/1f914879f97e7ee931ad05fe3afa629bd55270fc6cf1c1e589b6a99fab96d15daad0fa1a52a00c729ec0078045fe3e399bd4fd0c93bcc906957bdc17f89cb8e6 + languageName: node + linkType: hard + +"rolldown@npm:1.0.3": + version: 1.0.3 + resolution: "rolldown@npm:1.0.3" + dependencies: + "@oxc-project/types": "npm:=0.133.0" + "@rolldown/binding-android-arm64": "npm:1.0.3" + "@rolldown/binding-darwin-arm64": "npm:1.0.3" + "@rolldown/binding-darwin-x64": "npm:1.0.3" + "@rolldown/binding-freebsd-x64": "npm:1.0.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-x64-musl": "npm:1.0.3" + "@rolldown/binding-openharmony-arm64": "npm:1.0.3" + "@rolldown/binding-wasm32-wasi": "npm:1.0.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.3" "@rolldown/pluginutils": "npm:^1.0.0" dependenciesMeta: "@rolldown/binding-android-arm64": @@ -1189,16 +4664,92 @@ __metadata: optional: true bin: rolldown: ./bin/cli.mjs - checksum: 10/2e51f0b2332eef4001262dad360886ca11376558ce270fbddad6182870395200b123ad75d412e60cb4328650d1df2cb74ae374e79edf930c030bfb693c9b1891 + checksum: 10/4dbe2c055104c47c15c051b713068cf4660acd473841904d3f7118f730922b2e498176610a45826cbc1ffe36842a29a076385d3bfcd5acb0f7ef8ad06b8feefb + languageName: node + linkType: hard + +"rxjs@npm:^7.5.0, rxjs@npm:^7.8.1, rxjs@npm:^7.8.2": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10/03dff09191356b2b87d94fbc1e97c4e9eb3c09d4452399dddd451b09c2f1ba8d56925a40af114282d7bc0c6fe7514a2236ca09f903cf70e4bbf156650dddb49d + languageName: node + linkType: hard + +"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": + version: 5.1.2 + resolution: "safe-buffer@npm:5.1.2" + checksum: 10/7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a + languageName: node + linkType: hard + +"safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 + languageName: node + linkType: hard + +"safe-stable-stringify@npm:^2.3.1": + version: 2.5.0 + resolution: "safe-stable-stringify@npm:2.5.0" + checksum: 10/2697fa186c17c38c3ca5309637b4ac6de2f1c3d282da27cd5e1e3c88eca0fb1f9aea568a6aabdf284111592c8782b94ee07176f17126031be72ab1313ed46c5c + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0, safer-buffer@npm:~2.1.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10/7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83 + languageName: node + linkType: hard + +"scale-ts@npm:^1.6.0": + version: 1.6.1 + resolution: "scale-ts@npm:1.6.1" + checksum: 10/f1f9bf1d9abfcfcaf8ae2ae326270beca5c2456cc72f6b6b8230aa175a30bdcd6387678746a4d873c834efbba9c8e015698d42ee67bd71b70f7adfe2e0ba1d39 + languageName: node + linkType: hard + +"secure-json-parse@npm:^4.0.0": + version: 4.1.0 + resolution: "secure-json-parse@npm:4.1.0" + checksum: 10/1025c6fd0b8fa0e8c6ac7225fc0b79ecc528b2e51a8446e4bb73bfc47a2450b9e9e9813b84bc9e6735ce30c947b52e5b9d90771521aa9bb2ec216afd24c2da4e + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.7.3 + resolution: "semver@npm:7.7.3" + bin: + semver: bin/semver.js + checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + languageName: node + linkType: hard + +"semver@npm:^7.5.3": + version: 7.8.1 + resolution: "semver@npm:7.8.1" + bin: + semver: bin/semver.js + checksum: 10/3244f6c4cb3f8126fea0426d353829ed4967e41e1f4696337c6fdcad87426466fe2badaf49d7dc85849acfc496ea0599432a4aecc33802d2d774e723acfa30e6 languageName: node linkType: hard -"semver@npm:^7.3.5": - version: 7.8.0 - resolution: "semver@npm:7.8.0" - bin: - semver: bin/semver.js - checksum: 10/039a8f68a581c03c1ac17c990316da57a79a93af9b109b712739c50cd4d464079f7e3fee31c008b472e390c7ba48a11ed2b86e91d8602bf06059d4a266db1426 +"shebang-command@npm:^2.0.0": + version: 2.0.0 + resolution: "shebang-command@npm:2.0.0" + dependencies: + shebang-regex: "npm:^3.0.0" + checksum: 10/6b52fe87271c12968f6a054e60f6bde5f0f3d2db483a1e5c3e12d657c488a15474121a1d55cd958f6df026a54374ec38a4a963988c213b7570e1d51575cea7fa + languageName: node + linkType: hard + +"shebang-regex@npm:^3.0.0": + version: 3.0.0 + resolution: "shebang-regex@npm:3.0.0" + checksum: 10/1a2bcae50de99034fcd92ad4212d8e01eedf52c7ec7830eedcf886622804fe36884278f2be8be0ea5fde3fd1c23911643a4e0f726c8685b61871c8908af01222 languageName: node linkType: hard @@ -1216,13 +4767,73 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.1.0": +"signal-exit@npm:^3.0.2": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10/c9fa63bbbd7431066174a48ba2dd9986dfd930c3a8b59de9c29d7b6854ec1c12a80d15310869ea5166d413b99f041bfa3dd80a7947bcd44ea8e6eb3ffeabfa1f languageName: node linkType: hard +"smart-buffer@npm:^4.2.0": + version: 4.2.0 + resolution: "smart-buffer@npm:4.2.0" + checksum: 10/927484aa0b1640fd9473cee3e0a0bcad6fce93fd7bbc18bac9ad0c33686f5d2e2c422fba24b5899c184524af01e11dd2bd051c2bf2b07e47aff8ca72cbfc60d2 + languageName: node + linkType: hard + +"smol-toml@npm:^1.3.4": + version: 1.6.1 + resolution: "smol-toml@npm:1.6.1" + checksum: 10/9a0d86cc7f8abef429c915b373b9a1f369fe57a87efbbec46b967fb41dc28af753a2fa62c9c4848907c3b47c282be15c8854aa4e2942ef1fa86ff95a76d13856 + languageName: node + linkType: hard + +"smoldot@npm:2.0.26": + version: 2.0.26 + resolution: "smoldot@npm:2.0.26" + dependencies: + ws: "npm:^8.8.1" + checksum: 10/b975c8ef16e2286b2eddc8c19c18080bd528f27e9abc0e2731304823e67ebe1fc71b01bed2c070d00da1f7e2f69e25c159c976d27eb1796de4a978362dae701e + languageName: node + linkType: hard + +"socks-proxy-agent@npm:^8.0.3": + version: 8.0.5 + resolution: "socks-proxy-agent@npm:8.0.5" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:^4.3.4" + socks: "npm:^2.8.3" + checksum: 10/ee99e1dacab0985b52cbe5a75640be6e604135e9489ebdc3048635d186012fbaecc20fbbe04b177dee434c319ba20f09b3e7dfefb7d932466c0d707744eac05c + languageName: node + linkType: hard + +"socks@npm:^2.8.3": + version: 2.8.7 + resolution: "socks@npm:2.8.7" + dependencies: + ip-address: "npm:^10.0.1" + smart-buffer: "npm:^4.2.0" + checksum: 10/d19366c95908c19db154f329bbe94c2317d315dc933a7c2b5101e73f32a555c84fb199b62174e1490082a593a4933d8d5a9b297bde7d1419c14a11a965f51356 + languageName: node + linkType: hard + +"sonic-boom@npm:^4.0.1": + version: 4.2.1 + resolution: "sonic-boom@npm:4.2.1" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10/161af46b3e6debc4ad3865b0db47f37289741a0b3005b8cf056f93a4e0e1a347e24ca1a2d8ccc864f7f19caa6185a766797f8382cdbfd2f3d046a0323d73a542 + languageName: node + linkType: hard + "source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" @@ -1230,6 +4841,56 @@ __metadata: languageName: node linkType: hard +"split-ca@npm:^1.0.1": + version: 1.0.1 + resolution: "split-ca@npm:1.0.1" + checksum: 10/1e7409938a95ee843fe2593156a5735e6ee63772748ee448ea8477a5a3e3abde193c3325b3696e56a5aff07c7dcf6b1f6a2f2a036895b4f3afe96abb366d893f + languageName: node + linkType: hard + +"split2@npm:^4.0.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10/09bbefc11bcf03f044584c9764cd31a252d8e52cea29130950b26161287c11f519807c5e54bd9e5804c713b79c02cefe6a98f4688630993386be353e03f534ab + languageName: node + linkType: hard + +"ssh-remote-port-forward@npm:^1.0.4": + version: 1.0.4 + resolution: "ssh-remote-port-forward@npm:1.0.4" + dependencies: + "@types/ssh2": "npm:^0.5.48" + ssh2: "npm:^1.4.0" + checksum: 10/c6c04c5ddfde7cb06e9a8655a152bd28fe6771c6fe62ff0bc08be229491546c410f30b153c968b8d6817a57d38678a270c228f30143ec0fe1be546efc4f6b65a + languageName: node + linkType: hard + +"ssh2@npm:^1.15.0, ssh2@npm:^1.4.0": + version: 1.17.0 + resolution: "ssh2@npm:1.17.0" + dependencies: + asn1: "npm:^0.2.6" + bcrypt-pbkdf: "npm:^1.0.2" + cpu-features: "npm:~0.0.10" + nan: "npm:^2.23.0" + dependenciesMeta: + cpu-features: + optional: true + nan: + optional: true + checksum: 10/5a7e911f234f73c4332f2b436cc6131c164962d2eac71f463ab401b54c4b8627875d9c9be1c55e0bfd1a0eae108cfa33217bc73939287e4a5e81f34f532b1036 + languageName: node + linkType: hard + +"ssri@npm:^12.0.0": + version: 12.0.0 + resolution: "ssri@npm:12.0.0" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10/7024c1a6e39b3f18aa8f1c8290e884fe91b0f9ca5a6c6d410544daad54de0ba664db879afe16412e187c6c292fd60b937f047ee44292e5c2af2dcc6d8e1a9b48 + languageName: node + linkType: hard + "stackback@npm:0.0.2": version: 0.0.2 resolution: "stackback@npm:0.0.2" @@ -1244,42 +4905,220 @@ __metadata: languageName: node linkType: hard -"stdin-discarder@npm:^0.3.2": - version: 0.3.2 - resolution: "stdin-discarder@npm:0.3.2" - checksum: 10/63c6912146efe079fd048ecc02e5c3bf5aaa4cb268ad4e365603d845444dd3048daa45868c2690c5fe2d020ba47273c8a20df684a8c424fb4bd7f359c795c2f5 +"stdin-discarder@npm:^0.2.2": + version: 0.2.2 + resolution: "stdin-discarder@npm:0.2.2" + checksum: 10/642ffd05bd5b100819d6b24a613d83c6e3857c6de74eb02fc51506fa61dc1b0034665163831873868157c4538d71e31762bcf319be86cea04c3aba5336470478 + languageName: node + linkType: hard + +"streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": + version: 2.25.0 + resolution: "streamx@npm:2.25.0" + dependencies: + events-universal: "npm:^1.0.0" + fast-fifo: "npm:^1.3.2" + text-decoder: "npm:^1.1.0" + checksum: 10/d00dd38a1b73e4dac5225344aee421eb12ba9dded3f0ee3427d358d663677af185bc2310f46cb85ff3da31e032a50514d6f66348ba756154fe8a89b845273a3c + languageName: node + linkType: hard + +"string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10/e52c10dc3fbfcd6c3a15f159f54a90024241d0f149cf8aed2982a2d801d2e64df0bf1dc351cf8e95c3319323f9f220c16e740b06faecd53e2462df1d2b5443fb languageName: node linkType: hard "string-width@npm:^8.1.0": - version: 8.2.1 - resolution: "string-width@npm:8.2.1" + version: 8.1.0 + resolution: "string-width@npm:8.1.0" dependencies: - get-east-asian-width: "npm:^1.5.0" - strip-ansi: "npm:^7.1.2" - checksum: 10/cfadcc454b357d1a2ef88afb85068c7605900c9920362a16df9b4c320cf411983cee51b9832b70772d138674c2851d506f39c7e669c961a1cdd1258207580805 + get-east-asian-width: "npm:^1.3.0" + strip-ansi: "npm:^7.1.0" + checksum: 10/51ee97c4ffee7b94f8a2ee785fac14f81ec9809b9fcec9a4db44e25c717c263af0cc4387c111aef76195c0718dc43766f3678c07fb542294fb0244f7bfbde883 + languageName: node + linkType: hard + +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10/54d23f4a6acae0e93f999a585e673be9e561b65cd4cca37714af1e893ab8cd8dfa52a9e4f58f48f87b4a44918d3a9254326cb80ed194bf2e4c226e2b21767e56 + languageName: node + linkType: hard + +"string_decoder@npm:~1.1.1": + version: 1.1.1 + resolution: "string_decoder@npm:1.1.1" + dependencies: + safe-buffer: "npm:~5.1.0" + checksum: 10/7c41c17ed4dea105231f6df208002ebddd732e8e9e2d619d133cecd8e0087ddfd9587d2feb3c8caf3213cbd841ada6d057f5142cae68a4e62d3540778d9819b4 + languageName: node + linkType: hard + +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10/ae3b5436d34fadeb6096367626ce987057713c566e1e7768818797e00ac5d62023d0f198c4e681eae9e20701721980b26a64a8f5b91238869592a9c6800719a2 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.1.2 + resolution: "strip-ansi@npm:7.1.2" + dependencies: + ansi-regex: "npm:^6.0.1" + checksum: 10/db0e3f9654e519c8a33c50fc9304d07df5649388e7da06d3aabf66d29e5ad65d5e6315d8519d409c15b32fa82c1df7e11ed6f8cd50b0e4404463f0c9d77c8d0b + languageName: node + linkType: hard + +"strip-json-comments@npm:^5.0.2": + version: 5.0.3 + resolution: "strip-json-comments@npm:5.0.3" + checksum: 10/3ccbf26f278220f785e4b71f8a719a6a063d72558cc63cb450924254af258a4f4c008b8c9b055373a680dc7bd525be9e543ad742c177f8a7667e0b726258e0e4 + languageName: node + linkType: hard + +"superjson@npm:^2.0.0": + version: 2.2.6 + resolution: "superjson@npm:2.2.6" + dependencies: + copy-anything: "npm:^4" + checksum: 10/7bb6446b70e8a37ec9aa2f2d08295ae4e7e8268b86c89d83a306b3798cd0cc60d89016c0c5fa83b558db23e8de8863c585a4cf52d18c4834c48bad7d2b6ee25b languageName: node linkType: hard -"strip-ansi@npm:^7.1.2": +"supports-color@npm:^7.1.0": version: 7.2.0 - resolution: "strip-ansi@npm:7.2.0" + resolution: "supports-color@npm:7.2.0" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10/c8bb7afd564e3b26b50ca6ee47572c217526a1389fe018d00345856d4a9b08ffbd61fadaf283a87368d94c3dcdb8f5ffe2650a5a65863e21ad2730ca0f05210a + languageName: node + linkType: hard + +"tar-fs@npm:^2.1.4": + version: 2.1.4 + resolution: "tar-fs@npm:2.1.4" + dependencies: + chownr: "npm:^1.1.1" + mkdirp-classic: "npm:^0.5.2" + pump: "npm:^3.0.0" + tar-stream: "npm:^2.1.4" + checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be + languageName: node + linkType: hard + +"tar-fs@npm:^3.0.7": + version: 3.1.2 + resolution: "tar-fs@npm:3.1.2" + dependencies: + bare-fs: "npm:^4.0.1" + bare-path: "npm:^3.0.0" + pump: "npm:^3.0.0" + tar-stream: "npm:^3.1.5" + dependenciesMeta: + bare-fs: + optional: true + bare-path: + optional: true + checksum: 10/b358fb7061eebb42bfa6f122cf62d1bdd40dc619117863f3b59eeaa4f880dc03707014905bdb592e77176703d9045956d1ba27adda4458805f9f7cbf62015cbd + languageName: node + linkType: hard + +"tar-stream@npm:^2.1.4": + version: 2.2.0 + resolution: "tar-stream@npm:2.2.0" + dependencies: + bl: "npm:^4.0.3" + end-of-stream: "npm:^1.4.1" + fs-constants: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.1.1" + checksum: 10/1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a + languageName: node + linkType: hard + +"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": + version: 3.2.0 + resolution: "tar-stream@npm:3.2.0" dependencies: - ansi-regex: "npm:^6.2.2" - checksum: 10/96da3bc6d73cfba1218625a3d66cf7d37a69bf0920d8735b28f9eeaafcdb6c1fe8440e1ae9eb1ba0ca355dbe8702da872e105e2e939fa93e7851b3cb5dd7d316 + b4a: "npm:^1.6.4" + bare-fs: "npm:^4.5.5" + fast-fifo: "npm:^1.2.0" + streamx: "npm:^2.15.0" + checksum: 10/ce57a81521de73ae7a3b7d55a08da50d6771427c249bfa89a208518e48faf5254c8fa7201a8f5419ab8bde9601a74e6dd512b31a13ec89774aec96178f99a8d3 languageName: node linkType: hard -"tar@npm:^7.5.4": - version: 7.5.15 - resolution: "tar@npm:7.5.15" +"tar@npm:^7.4.3": + version: 7.5.14 + resolution: "tar@npm:7.5.14" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10/b4cb6acd822159867f81ebda8d765c6941ec8292f1cf2f870d3713f4933c14bf0ed7bf4a92338143c31e8815ca0a1fdd62aa03ddb48a42ae187f7ef696583ffe + checksum: 10/4399a2cedf42cf8fbc24b1d732a0bc709ed5cda6bef5bd8b60ccbff94d213bf33f81afb5b99e849dd51626954c048062631b23f6619255be9199a3ceb5950cad + languageName: node + linkType: hard + +"teex@npm:^1.0.1": + version: 1.0.1 + resolution: "teex@npm:1.0.1" + dependencies: + streamx: "npm:^2.12.5" + checksum: 10/36bf7ce8bb5eb428ad7b14b695ee7fb0a02f09c1a9d8181cc42531208543a920b299d711bf78dad4ff9bcf36ac437ae8e138053734746076e3e0e7d6d76eef64 + languageName: node + linkType: hard + +"testcontainers@npm:^10.28.0": + version: 10.28.0 + resolution: "testcontainers@npm:10.28.0" + dependencies: + "@balena/dockerignore": "npm:^1.0.2" + "@types/dockerode": "npm:^3.3.35" + archiver: "npm:^7.0.1" + async-lock: "npm:^1.4.1" + byline: "npm:^5.0.0" + debug: "npm:^4.3.5" + docker-compose: "npm:^0.24.8" + dockerode: "npm:^4.0.5" + get-port: "npm:^7.1.0" + proper-lockfile: "npm:^4.1.2" + properties-reader: "npm:^2.3.0" + ssh-remote-port-forward: "npm:^1.0.4" + tar-fs: "npm:^3.0.7" + tmp: "npm:^0.2.3" + undici: "npm:^5.29.0" + checksum: 10/434d3677e10a114805420f2420831a8eae4091acdaf242787fb100a8755140af0e11eab3932cdb29267f0869af22d0b572532f72ee5450d60f63f3fed30d098c + languageName: node + linkType: hard + +"text-decoder@npm:^1.1.0": + version: 1.2.7 + resolution: "text-decoder@npm:1.2.7" + dependencies: + b4a: "npm:^1.6.4" + checksum: 10/151f89339a497353ad579b32536be94bf90a0785fd2aa2dc0a5ec8a4b71ed59998f4adb872201bdc536805425aa8c5cf8f4a936c449be614c1d3c4527688b3d0 + languageName: node + linkType: hard + +"thread-stream@npm:^3.0.0": + version: 3.1.0 + resolution: "thread-stream@npm:3.1.0" + dependencies: + real-require: "npm:^0.2.0" + checksum: 10/ea2d816c4f6077a7062fac5414a88e82977f807c82ee330938fb9691fe11883bb03f078551c0518bb649c239e47ba113d44014fcbb5db42c5abd5996f35e4213 languageName: node linkType: hard @@ -1291,19 +5130,29 @@ __metadata: linkType: hard "tinyexec@npm:^1.0.2": - version: 1.1.2 - resolution: "tinyexec@npm:1.1.2" - checksum: 10/2bbe37f9001c6f5723ab39eb8dc1e88f77e830d7cf2e8f34bb75019eb505fcfe3b061b4799c502ff31fa63aa1a9adc649add5ff1e17b7fbd8c16e1afb75d0b9e + version: 1.0.2 + resolution: "tinyexec@npm:1.0.2" + checksum: 10/cb709ed4240e873d3816e67f851d445f5676e0ae3a52931a60ff571d93d388da09108c8057b62351766133ee05ff3159dd56c3a0fbd39a5933c6639ce8771405 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.16": - version: 0.2.16 - resolution: "tinyglobby@npm:0.2.16" +"tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" dependencies: fdir: "npm:^6.5.0" picomatch: "npm:^4.0.4" - checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8 + checksum: 10/f85e8a217d675c3f78d5f0ad25ea4557e7e023ed13ddc2b014da10bd0312eea53a34cd52356af07ccdff777f1243012547656282a4ca70936f68bf5065fbaa71 languageName: node linkType: hard @@ -1314,6 +5163,20 @@ __metadata: languageName: node linkType: hard +"tmp@npm:^0.2.3": + version: 0.2.5 + resolution: "tmp@npm:0.2.5" + checksum: 10/dd4b78b32385eab4899d3ae296007b34482b035b6d73e1201c4a9aede40860e90997a1452c65a2d21aee73d53e93cd167d741c3db4015d90e63b6d568a93d7ec + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10/8f1f5aa6cb232f9e1bdc86f485f916b7aa38caee8a778b378ffec0b70d9307873f253f5cbadbe2955ece2ac5c83d0dc14a77513166ccd0a0c7fe197e21396695 + languageName: node + linkType: hard + "ts-node@npm:^10.9.2": version: 10.9.2 resolution: "ts-node@npm:10.9.2" @@ -1352,7 +5215,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0": +"tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.7.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -1360,15 +5223,15 @@ __metadata: linkType: hard "turbo@npm:^2.9.14": - version: 2.9.14 - resolution: "turbo@npm:2.9.14" - dependencies: - "@turbo/darwin-64": "npm:2.9.14" - "@turbo/darwin-arm64": "npm:2.9.14" - "@turbo/linux-64": "npm:2.9.14" - "@turbo/linux-arm64": "npm:2.9.14" - "@turbo/windows-64": "npm:2.9.14" - "@turbo/windows-arm64": "npm:2.9.14" + version: 2.9.16 + resolution: "turbo@npm:2.9.16" + dependencies: + "@turbo/darwin-64": "npm:2.9.16" + "@turbo/darwin-arm64": "npm:2.9.16" + "@turbo/linux-64": "npm:2.9.16" + "@turbo/linux-arm64": "npm:2.9.16" + "@turbo/windows-64": "npm:2.9.16" + "@turbo/windows-arm64": "npm:2.9.16" dependenciesMeta: "@turbo/darwin-64": optional: true @@ -1384,7 +5247,24 @@ __metadata: optional: true bin: turbo: bin/turbo - checksum: 10/99ce1f10c79ec840b542cc628d44f6febc18fcff8fed08f7f966a983a71b03c7febcf3e16066667a01f9d8d7faca133ebd541c57555bd509364f669ceb46807f + checksum: 10/a151be0f24bd66802d36b0303eb5ed21bbe05077265831ea457554fb7b859b7cfdd35938e3b3812c5aa86721935032c9f1d03c247b17786a2ea7aecb65b82e1d + languageName: node + linkType: hard + +"tweetnacl@npm:^0.14.3": + version: 0.14.5 + resolution: "tweetnacl@npm:0.14.5" + checksum: 10/04ee27901cde46c1c0a64b9584e04c96c5fe45b38c0d74930710751ea991408b405747d01dfae72f80fc158137018aea94f9c38c651cb9c318f0861a310c3679 + languageName: node + linkType: hard + +"typescript@npm:^5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/c089d9d3da2729fd4ac517f9b0e0485914c4b3c26f80dc0cffcb5de1719a17951e92425d55db59515c1a7ddab65808466debb864d0d56dcf43f27007d0709594 languageName: node linkType: hard @@ -1398,6 +5278,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/696e1b017bc2635f4e0c94eb4435357701008e2f272f553d06e35b494b8ddc60aa221145e286c28ace0c89ee32827a28c2040e3a69bdc108b1a5dc8fb40b72e3 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" @@ -1415,13 +5305,61 @@ __metadata: languageName: node linkType: hard -"undici@npm:^6.25.0": +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10/0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd + languageName: node + linkType: hard + +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10/db43439f69c2d94cc29f75cbfe9de86df87061d6b0c577ebe9bb3255f49b22c50162a7d7eb413b0458b6510b8ca299ac7cff38c3a29fbd31af9f504bcf7fbc0d + languageName: node + linkType: hard + +"undici@npm:^6.24.0": version: 6.25.0 resolution: "undici@npm:6.25.0" checksum: 10/a475e45da3e1d1073283bb70531666f09a432eabff2b857bd7063d469a1ee1486192ff61dc0dadbb526673ce1120fee14d66a59b6b17d1e0bd3a4d5f0a52d0a6 languageName: node linkType: hard +"unique-filename@npm:^4.0.0": + version: 4.0.0 + resolution: "unique-filename@npm:4.0.0" + dependencies: + unique-slug: "npm:^5.0.0" + checksum: 10/6a62094fcac286b9ec39edbd1f8f64ff92383baa430af303dfed1ffda5e47a08a6b316408554abfddd9730c78b6106bef4ca4d02c1231a735ddd56ced77573df + languageName: node + linkType: hard + +"unique-slug@npm:^5.0.0": + version: 5.0.0 + resolution: "unique-slug@npm:5.0.0" + dependencies: + imurmurhash: "npm:^0.1.4" + checksum: 10/beafdf3d6f44990e0a5ce560f8f881b4ee811be70b6ba0db25298c31c8cf525ed963572b48cd03be1c1349084f9e339be4241666d7cf1ebdad20598d3c652b27 + languageName: node + linkType: hard + +"util-deprecate@npm:^1.0.1, util-deprecate@npm:~1.0.1": + version: 1.0.2 + resolution: "util-deprecate@npm:1.0.2" + checksum: 10/474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 + languageName: node + linkType: hard + +"uuid@npm:^13.0.0": + version: 13.0.2 + resolution: "uuid@npm:13.0.2" + bin: + uuid: dist-node/bin/uuid + checksum: 10/567dddca18a8520796dd3cd1e4513f4c7c522f25602c15381615395d60c7892f330366680fc21373f19fb83c991f3da8413f57dbd85bf976069cf0818aa6c61c + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" @@ -1430,15 +5368,15 @@ __metadata: linkType: hard "vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": - version: 8.0.14 - resolution: "vite@npm:8.0.14" + version: 8.0.16 + resolution: "vite@npm:8.0.16" dependencies: fsevents: "npm:~2.3.3" lightningcss: "npm:^1.32.0" picomatch: "npm:^4.0.4" postcss: "npm:^8.5.15" - rolldown: "npm:1.0.2" - tinyglobby: "npm:^0.2.16" + rolldown: "npm:1.0.3" + tinyglobby: "npm:^0.2.17" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 "@vitejs/devtools": ^0.1.18 @@ -1482,21 +5420,21 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/3747c9b9dabdfa5b840630c39b2c764afb3c3762816f3148afe7d516edc1889b60b666adeb4e98761c26fb8ed5ba3a9770df5c0450443daf4cdfac110bc6df1c + checksum: 10/a5d91d26f6110672a292a06ca161af9a58279fe9d27106c8c0afb725a942b0b47091c440c3b1e7ebc8e0fe901f64ac6a2ffee3cdae2f899339686dbecd0c0266 languageName: node linkType: hard "vitest@npm:^4.0.15, vitest@npm:^4.1.6": - version: 4.1.7 - resolution: "vitest@npm:4.1.7" - dependencies: - "@vitest/expect": "npm:4.1.7" - "@vitest/mocker": "npm:4.1.7" - "@vitest/pretty-format": "npm:4.1.7" - "@vitest/runner": "npm:4.1.7" - "@vitest/snapshot": "npm:4.1.7" - "@vitest/spy": "npm:4.1.7" - "@vitest/utils": "npm:4.1.7" + version: 4.1.8 + resolution: "vitest@npm:4.1.8" + dependencies: + "@vitest/expect": "npm:4.1.8" + "@vitest/mocker": "npm:4.1.8" + "@vitest/pretty-format": "npm:4.1.8" + "@vitest/runner": "npm:4.1.8" + "@vitest/snapshot": "npm:4.1.8" + "@vitest/spy": "npm:4.1.8" + "@vitest/utils": "npm:4.1.8" es-module-lexer: "npm:^2.0.0" expect-type: "npm:^1.3.0" magic-string: "npm:^0.30.21" @@ -1514,12 +5452,12 @@ __metadata: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.7 - "@vitest/browser-preview": 4.1.7 - "@vitest/browser-webdriverio": 4.1.7 - "@vitest/coverage-istanbul": 4.1.7 - "@vitest/coverage-v8": 4.1.7 - "@vitest/ui": 4.1.7 + "@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.0.0 || ^7.0.0 || ^8.0.0 @@ -1550,18 +5488,60 @@ __metadata: optional: false bin: vitest: vitest.mjs - checksum: 10/23ce0ce8bf81856c1acf983c6138efda5d01b60cbdc5734abd0948f3b39cde14ea7bf0981a2ec8a6b05fe7f3658b211116997fd658fcd20c2f5740b5465502ca + checksum: 10/b9f1308436717da9558b36e149cac6bab8e3730aa7e90b49f9d7a84ba853e353d8afba7d406dc0abec731fb2a9ea9e92b89aba06b94b1a2802203048b43468af languageName: node linkType: hard -"which@npm:^6.0.0": - version: 6.0.1 - resolution: "which@npm:6.0.1" +"web-streams-polyfill@npm:^3.0.3": + version: 3.3.3 + resolution: "web-streams-polyfill@npm:3.3.3" + checksum: 10/8e7e13501b3834094a50abe7c0b6456155a55d7571312b89570012ef47ec2a46d766934768c50aabad10a9c30dd764a407623e8bfcc74fcb58495c29130edea9 + languageName: node + linkType: hard + +"web-worker@npm:^1.5.0": + version: 1.5.0 + resolution: "web-worker@npm:1.5.0" + checksum: 10/1209461e2c731fe8e8297c95a8a324c6dd00fd9f3c489ed79d18a15592731324762b7b06c8b6bc404596259aa13cd413119e0153e12a80f47a7f374960461e0d + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10/b65b9f8d6854572a84a5c69615152b63371395f0c5dcd6729c45789052296df54314db2bc3e977df41705eacb8bc79c247cee139a63fa695192f95816ed528ad + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10/f95adbc1e80820828b45cc671d97da7cd5e4ef9deb426c31bcd5ab00dc7103042291613b3ef3caec0a2335ed09e0d5ed026c940755dbb6d404e2b27f940fdf07 + languageName: node + linkType: hard + +"which@npm:^2.0.1": + version: 2.0.2 + resolution: "which@npm:2.0.2" dependencies: - isexe: "npm:^4.0.0" + isexe: "npm:^2.0.0" + bin: + node-which: ./bin/node-which + checksum: 10/4782f8a1d6b8fc12c65e968fea49f59752bf6302dc43036c3bf87da718a80710f61a062516e9764c70008b487929a73546125570acea95c5b5dcc8ac3052c70f + languageName: node + linkType: hard + +"which@npm:^5.0.0": + version: 5.0.0 + resolution: "which@npm:5.0.0" + dependencies: + isexe: "npm:^3.1.1" bin: node-which: bin/which.js - checksum: 10/dbea77c7d3058bf6c78bf9659d2dce4d2b57d39a15b826b2af6ac2e5a219b99dc8a831b79fdbc453c0598adb4f3f84cf9c2491fd52beb9f5d2dececcad117f68 + checksum: 10/6ec99e89ba32c7e748b8a3144e64bfc74aa63e2b2eacbb61a0060ad0b961eb1a632b08fb1de067ed59b002cec3e21de18299216ebf2325ef0f78e0f121e14e90 languageName: node linkType: hard @@ -1577,6 +5557,68 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10/cebdaeca3a6880da410f75209e68cd05428580de5ad24535f22696d7d9cab134d1f8498599f344c3cf0fb37c1715807a183778d8c648d6cc0cb5ff2bb4236540 + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10/159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5 + languageName: node + linkType: hard + +"ws@npm:^8.16.0, ws@npm:^8.18.0, ws@npm:^8.8.1": + version: 8.20.1 + resolution: "ws@npm:8.20.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/8c4d2b06dc65381b6bfab1f2e584275dabd30a99a5ce058b4dc76f3d03fad1921cef3a21d8f53127d30a808cfd1864aa2fe6890a5d43359f682457315baec873 + languageName: node + linkType: hard + +"ws@npm:^8.20.0": + version: 8.21.0 + resolution: "ws@npm:8.21.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/088411956432c8f876158409d5a285cb9ad1382f593391f51d3a599bd0a5b277f876609ebd00fc3596321c4a4c9064d6fffe1ebad960e8ea7fd9ae25324f35c2 + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10/5f1b5f95e3775de4514edbb142398a2c37849ccfaf04a015be5d75521e9629d3be29bd4432d23c57f37e5b61ade592fb0197022e9993f81a06a5afbdcda9346d + languageName: node + linkType: hard + +"yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10/4cb02b42b8a93b5cf50caf5d8e9beb409400a8a4d85e83bb0685c1457e9ac0b7a00819e9f5991ac25ffabb56a78e2f017c1acc010b3a1babfe6de690ba531abd + languageName: node + linkType: hard + "yallist@npm:^5.0.0": version: 5.0.0 resolution: "yallist@npm:5.0.0" @@ -1584,6 +5626,37 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.2.2": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10/9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10/9dc2c217ea3bf8d858041252d43e074f7166b53f3d010a8c711275e09cd3d62a002969a39858b92bbda2a6a63a585c7127014534a560b9c69ed2d923d113406e + languageName: node + linkType: hard + +"yargs@npm:^17.7.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10/abb3e37678d6e38ea85485ed86ebe0d1e3464c640d7d9069805ea0da12f69d5a32df8e5625e370f9c96dd1c2dc088ab2d0a4dd32af18222ef3c4224a19471576 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" @@ -1597,3 +5670,21 @@ __metadata: checksum: 10/6ee42d665a4cc161c7de3f015b2a65d6c65d2808bfe3b99e228bd2b1b784ef1e54d1907415c025fc12b400f26f372bfc1b71966c6c738d998325ca422eb39363 languageName: node linkType: hard + +"zip-stream@npm:^6.0.1": + version: 6.0.1 + resolution: "zip-stream@npm:6.0.1" + dependencies: + archiver-utils: "npm:^5.0.0" + compress-commons: "npm:^6.0.2" + readable-stream: "npm:^4.0.0" + checksum: 10/aa5abd6a89590eadeba040afbc375f53337f12637e5e98330012a12d9886cde7a3ccc28bd91aafab50576035bbb1de39a9a316eecf2411c8b9009c9f94f0db27 + languageName: node + linkType: hard + +"zod@npm:^3.23.8": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995 + languageName: node + linkType: hard From 15619e6d95bfa970174ab33bee7b70f3d403e90d Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Mon, 8 Jun 2026 13:35:57 +0200 Subject: [PATCH 2/9] fix(deployer): address CodeRabbit review feedback Harden the deployer tool and CLI against the issues raised in the #86 review. Each fix verified against current code and covered by tests. * cli/logger: route `--json` pino logs to STDERR (fd 2) so STDOUT carries only the single result object. * cli/prompt: write the passphrase prompt + newline to STDERR, and settle the promise on stdin `end`/`error` so non-interactive input (closed/newline-less pipe) can no longer hang the CLI. * config/schema: mark the file/module ref union members `.strict()` so an ambiguous `{ file, module }` is rejected instead of silently dropping a key. * deployments: write the ledger atomically (temp file + rename) so a crash mid-write can't truncate `*.json`. * loaders/artifact: bind compiled assets to `dirname(entry)` rather than a hardcoded `contract/`, fixing the top-level-`index.js` case. * loaders/constructor-meta: allow an empty named-args object for a no-arg constructor (resolves to `[]`); `reorderNamedArgs` still rejects unexpected keys. * loaders/contract-resolve: use `path.isAbsolute` instead of `startsWith('/')` for OS-correct absolute-path detection. * providers/network: drop `mainnet` from the allow-list while the deployer is testnet/preview-only. * providers/proof-server: reject partial-numeric and out-of-range `PROOF_SERVER_PORT` values (full `\d+` match + 1..65535 bounds). * wallet/handler: use `path.dirname`/`basename` instead of manual `/` splitting so cache paths work on Windows. * wallet/keystore: validate shape in `fromJSON` and throw `WalletError` (not a raw `TypeError`) on malformed keystore JSON. Refs: OpenZeppelin/compact-tools#86 --- packages/cli/src/logger.ts | 15 +++++--- packages/cli/src/prompt.ts | 30 +++++++++++++--- packages/cli/test/logger.test.ts | 27 +++++++++----- packages/cli/test/prompt.test.ts | 35 +++++++++++++++---- packages/deployer/src/config/schema.test.ts | 14 ++++++++ packages/deployer/src/config/schema.ts | 12 ++++--- packages/deployer/src/deployments.ts | 12 +++++-- packages/deployer/src/loaders/artifact.ts | 9 +++-- .../src/loaders/constructor-meta.test.ts | 10 ++++++ .../deployer/src/loaders/constructor-meta.ts | 11 +++--- .../deployer/src/loaders/contract-resolve.ts | 4 +-- .../deployer/src/providers/network.test.ts | 8 ++++- packages/deployer/src/providers/network.ts | 1 - .../src/providers/proof-server.test.ts | 16 +++++++++ .../deployer/src/providers/proof-server.ts | 7 ++-- packages/deployer/src/wallet/handler.ts | 8 ++--- packages/deployer/src/wallet/keystore.test.ts | 11 ++++++ packages/deployer/src/wallet/keystore.ts | 28 +++++++++------ 18 files changed, 196 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/logger.ts b/packages/cli/src/logger.ts index 4f6b267..70d809b 100644 --- a/packages/cli/src/logger.ts +++ b/packages/cli/src/logger.ts @@ -3,10 +3,11 @@ import { join } from 'node:path'; import pino, { type Logger } from 'pino'; /** - * Pino factory for the three CLI modes: `--json` (raw JSON, no transports), - * default (pretty `info+`), `--verbose` (pretty `info+` to stdout AND - * `debug+` mirrored to `.compact/logs/.log` so the transcript survives - * spinner overwrites). + * Pino factory for the three CLI modes: `--json` (raw JSON to STDERR, no + * transports — STDOUT is reserved for the single result object), default + * (pretty `info+`), `--verbose` (pretty `info+` to stdout AND `debug+` + * mirrored to `.compact/logs/.log` so the transcript survives spinner + * overwrites). */ export interface CreateLoggerOptions { verbose: boolean; @@ -16,7 +17,11 @@ export interface CreateLoggerOptions { export function createLogger(opts: CreateLoggerOptions): Logger { if (opts.json) { - return pino({ level: opts.verbose ? 'debug' : 'info' }); + // fd 2 = STDERR; keeps STDOUT carrying only the final JSON result. + return pino( + { level: opts.verbose ? 'debug' : 'info' }, + pino.destination(2), + ); } if (opts.verbose) { diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index a1e1f3b..4ecc486 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -1,8 +1,12 @@ -import { stdin, stdout } from 'node:process'; +import { stderr, stdin } from 'node:process'; -/** Prompt for a keystore passphrase with terminal echo suppressed; falls back to plain line-read off a TTY. */ +/** + * Prompt for a keystore passphrase with terminal echo suppressed; falls back + * to plain line-read off a TTY. Prompt text and the trailing newline go to + * STDERR so `--json` (and any piped) callers keep a clean single-object STDOUT. + */ export async function promptPassphrase(label: string): Promise { - stdout.write(`Passphrase for ${label}: `); + stderr.write(`Passphrase for ${label}: `); return readMaskedLine(); } @@ -15,7 +19,23 @@ function readMaskedLine(): Promise { if (isTTY) stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData); - stdout.write('\n'); + stdin.removeListener('end', onEnd); + stdin.removeListener('error', onError); + stderr.write('\n'); + }; + + // Without these, a non-interactive stdin that closes without a trailing + // newline (e.g. piped input, or a closed pipe) would never settle the + // promise and the CLI would hang. + const onEnd = () => { + cleanup(); + if (buffer.length > 0) resolveFn(buffer); + else rejectFn(new Error('Aborted')); + }; + + const onError = (err: Error) => { + cleanup(); + rejectFn(err); }; const onData = (chunk: Buffer) => { @@ -44,5 +64,7 @@ function readMaskedLine(): Promise { stdin.resume(); stdin.setEncoding('utf8'); stdin.on('data', onData); + stdin.on('end', onEnd); + stdin.on('error', onError); }); } diff --git a/packages/cli/test/logger.test.ts b/packages/cli/test/logger.test.ts index 0420f38..7694819 100644 --- a/packages/cli/test/logger.test.ts +++ b/packages/cli/test/logger.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockMkdirSync, mockPino, mockTransport, fakeLogger } = vi.hoisted( - () => { +const { mockMkdirSync, mockPino, mockTransport, mockDestination, fakeLogger } = + vi.hoisted(() => { const fakeLogger = { trace: vi.fn(), debug: vi.fn(), @@ -15,10 +15,10 @@ const { mockMkdirSync, mockPino, mockTransport, fakeLogger } = vi.hoisted( mockMkdirSync: vi.fn(), mockPino: vi.fn(() => fakeLogger), mockTransport: vi.fn((cfg: unknown) => ({ __transport: cfg })), + mockDestination: vi.fn((fd: number) => ({ __destination: fd })), fakeLogger, }; - }, -); + }); vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); @@ -29,6 +29,8 @@ vi.mock('pino', () => { const pinoFn = (...args: unknown[]) => mockPino(...(args as [])) as unknown; (pinoFn as unknown as { transport: typeof mockTransport }).transport = mockTransport; + (pinoFn as unknown as { destination: typeof mockDestination }).destination = + mockDestination; return { default: pinoFn }; }); @@ -44,20 +46,29 @@ describe('createLogger', () => { }); describe('json mode', () => { - it('should return logger at info level when verbose false', () => { + it('should return logger at info level routed to STDERR when verbose false', () => { const logger = createLogger({ verbose: false, json: true }); expect(mockPino).toHaveBeenCalledTimes(1); - expect(mockPino).toHaveBeenCalledWith({ level: 'info' }); + // fd 2 = STDERR; STDOUT stays reserved for the single JSON result. + expect(mockDestination).toHaveBeenCalledWith(2); + expect(mockPino).toHaveBeenCalledWith( + { level: 'info' }, + { __destination: 2 }, + ); expect(mockTransport).not.toHaveBeenCalled(); expect(mockMkdirSync).not.toHaveBeenCalled(); expect(logger).toBe(fakeLogger); }); - it('should return logger at debug level when verbose true', () => { + it('should return logger at debug level routed to STDERR when verbose true', () => { const logger = createLogger({ verbose: true, json: true }); - expect(mockPino).toHaveBeenCalledWith({ level: 'debug' }); + expect(mockDestination).toHaveBeenCalledWith(2); + expect(mockPino).toHaveBeenCalledWith( + { level: 'debug' }, + { __destination: 2 }, + ); expect(mockTransport).not.toHaveBeenCalled(); expect(mockMkdirSync).not.toHaveBeenCalled(); expect(logger).toBe(fakeLogger); diff --git a/packages/cli/test/prompt.test.ts b/packages/cli/test/prompt.test.ts index e3eb0e8..42bde24 100644 --- a/packages/cli/test/prompt.test.ts +++ b/packages/cli/test/prompt.test.ts @@ -1,7 +1,7 @@ import type { EventEmitter } from 'node:events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockStdin, mockStdout } = await vi.hoisted(async () => { +const { mockStdin, mockStderr } = await vi.hoisted(async () => { const { EventEmitter } = await import('node:events'); type FakeStdin = InstanceType & { isTTY: boolean; @@ -21,13 +21,13 @@ const { mockStdin, mockStdout } = await vi.hoisted(async () => { stdin, ) as FakeStdin['removeListener']; - const stdout = { write: vi.fn() }; - return { mockStdin: stdin, mockStdout: stdout }; + const stderr = { write: vi.fn() }; + return { mockStdin: stdin, mockStderr: stderr }; }); vi.mock('node:process', () => ({ stdin: mockStdin, - stdout: mockStdout, + stderr: mockStderr, })); import { promptPassphrase } from '../src/prompt.ts'; @@ -39,7 +39,7 @@ function resetStdin(opts: { tty: boolean } = { tty: true }): void { (mockStdin.pause as ReturnType).mockClear(); (mockStdin.resume as ReturnType).mockClear(); (mockStdin.setEncoding as ReturnType).mockClear(); - mockStdout.write.mockClear(); + mockStderr.write.mockClear(); } describe('promptPassphrase', () => { @@ -57,7 +57,7 @@ describe('promptPassphrase', () => { mockStdin.emit('data', Buffer.from('x\n')); await promise; - expect(mockStdout.write).toHaveBeenCalledWith( + expect(mockStderr.write).toHaveBeenCalledWith( 'Passphrase for Alice keystore: ', ); expect(mockStdin.setRawMode).toHaveBeenCalledWith(true); @@ -86,7 +86,7 @@ describe('promptPassphrase', () => { expect(pp).toBe('hunter2'); expect(mockStdin.setRawMode).toHaveBeenLastCalledWith(false); expect(mockStdin.pause).toHaveBeenCalled(); - expect(mockStdout.write).toHaveBeenLastCalledWith('\n'); + expect(mockStderr.write).toHaveBeenLastCalledWith('\n'); }); it('should resolve on LF (0x0a)', async () => { @@ -147,4 +147,25 @@ describe('promptPassphrase', () => { await expect(promise).rejects.toThrow('Aborted'); }); }); + + describe('stream close path', () => { + it('should reject with "Aborted" when stdin ends with an empty buffer', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('end'); + await expect(promise).rejects.toThrow('Aborted'); + }); + + it('should resolve with the buffer when stdin ends without a trailing newline', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('piped-secret')); + mockStdin.emit('end'); + await expect(promise).resolves.toBe('piped-secret'); + }); + + it('should reject when stdin emits an error', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('error', new Error('stream boom')); + await expect(promise).rejects.toThrow('stream boom'); + }); + }); }); diff --git a/packages/deployer/src/config/schema.test.ts b/packages/deployer/src/config/schema.test.ts index 7466e7d..014f90e 100644 --- a/packages/deployer/src/config/schema.test.ts +++ b/packages/deployer/src/config/schema.test.ts @@ -208,6 +208,20 @@ describe('configSchema — contract args', () => { export: 'default', }); }); + + it('should reject an ambiguous ref carrying both file and module', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + contracts: { + Counter: { + ...validContract, + args: { file: 'args.json', module: 'args.ts' }, + }, + }, + }), + ).toThrow(); + }); }); describe('configSchema — required fields', () => { diff --git a/packages/deployer/src/config/schema.ts b/packages/deployer/src/config/schema.ts index ea571b8..b7619aa 100644 --- a/packages/deployer/src/config/schema.ts +++ b/packages/deployer/src/config/schema.ts @@ -40,11 +40,13 @@ const walletObjectSchema = z.object({ }); const walletSchema = walletObjectSchema.optional(); -const fileRefSchema = z.object({ file: z.string().min(1) }); -const moduleRefSchema = z.object({ - module: z.string().min(1), - export: z.string().default('default'), -}); +const fileRefSchema = z.object({ file: z.string().min(1) }).strict(); +const moduleRefSchema = z + .object({ + module: z.string().min(1), + export: z.string().default('default'), + }) + .strict(); const fileOrModuleRefSchema = z.union([fileRefSchema, moduleRefSchema]); const argsSchema = z.union([z.array(z.unknown()), fileOrModuleRefSchema]); diff --git a/packages/deployer/src/deployments.ts b/packages/deployer/src/deployments.ts index 701ad26..e3db135 100644 --- a/packages/deployer/src/deployments.ts +++ b/packages/deployer/src/deployments.ts @@ -1,5 +1,6 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; /** @@ -108,6 +109,13 @@ async function readJson(path: string, fallback: T): Promise { return JSON.parse(raw) as T; } +// Write atomically: a crash mid-write would otherwise leave a truncated +// `*.json`, breaking subsequent reads and losing durable deploy state. +// Write to a sibling temp file, then rename it into place (atomic on the +// same filesystem). async function writeJson(path: string, value: unknown): Promise { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); + await rename(tmp, path); } diff --git a/packages/deployer/src/loaders/artifact.ts b/packages/deployer/src/loaders/artifact.ts index 68d471c..43206c8 100644 --- a/packages/deployer/src/loaders/artifact.ts +++ b/packages/deployer/src/loaders/artifact.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from 'node:fs'; -import { isAbsolute, resolve } from 'node:path'; +import { dirname, isAbsolute, resolve } from 'node:path'; import { CompiledContract, type Contract } from '@midnight-ntwrk/compact-js'; import type { Types } from 'effect'; import { @@ -62,13 +62,16 @@ export class Artifact { throw new ArtifactNotFoundError(artifactPath); } - const contractDir = resolve(artifactPath, 'contract'); - const entry = findEntry(contractDir, artifactPath); + const entry = findEntry(resolve(artifactPath, 'contract'), artifactPath); if (!entry) { throw new ArtifactNotFoundError( `${artifactPath} (no contract/index.{cjs,js} or index.{cjs,js} found)`, ); } + // Bind compiled assets to the directory the entry actually lives in: + // `findEntry` may resolve a top-level `index.{cjs,js}` rather than the + // `contract/` subdir, in which case the hardcoded path would be wrong. + const contractDir = dirname(entry); const keysDir = resolve(artifactPath, 'keys'); const zkirDir = resolve(artifactPath, 'zkir'); diff --git a/packages/deployer/src/loaders/constructor-meta.test.ts b/packages/deployer/src/loaders/constructor-meta.test.ts index 55ab6cc..773c7a1 100644 --- a/packages/deployer/src/loaders/constructor-meta.test.ts +++ b/packages/deployer/src/loaders/constructor-meta.test.ts @@ -72,4 +72,14 @@ describe('reorderNamedArgs', () => { /unknown constructor parameter\(s\): x/, ); }); + + it('accepts an empty named object for a no-arg constructor', () => { + expect(reorderNamedArgs({}, [])).toEqual([]); + }); + + it('rejects a non-empty named object for a no-arg constructor', () => { + expect(() => reorderNamedArgs({ a: 1 }, [])).toThrow( + /unknown constructor parameter\(s\): a/, + ); + }); }); diff --git a/packages/deployer/src/loaders/constructor-meta.ts b/packages/deployer/src/loaders/constructor-meta.ts index 484fff7..f446b97 100644 --- a/packages/deployer/src/loaders/constructor-meta.ts +++ b/packages/deployer/src/loaders/constructor-meta.ts @@ -17,13 +17,10 @@ export function loadConstructorParamNames(artifactPath: string): string[] { ); } const source = readFileSync(dtsPath, 'utf8'); - const names = parseConstructorParamNames(source); - if (names.length === 0) { - throw new ConfigError( - `Contract ${artifactPath} has a no-arg constructor; named args object should be empty`, - ); - } - return names; + // A no-arg constructor yields `[]`. `reorderNamedArgs` then accepts an + // empty named-args object and rejects any unexpected keys, so we don't + // need to special-case the empty result here. + return parseConstructorParamNames(source); } /** Reorders a named-object args record into a positional tuple. */ diff --git a/packages/deployer/src/loaders/contract-resolve.ts b/packages/deployer/src/loaders/contract-resolve.ts index 091b9f4..66c0af4 100644 --- a/packages/deployer/src/loaders/contract-resolve.ts +++ b/packages/deployer/src/loaders/contract-resolve.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { isAbsolute, resolve } from 'node:path'; import type { CompactConfig } from '../config/compact-config.ts'; import { ConfigError } from '../errors.ts'; import { LoaderContext } from './context.ts'; @@ -85,7 +85,7 @@ function resolveUnderRoot( artifact: string, artifactsDir: string, ): string { - if (artifact.startsWith('/')) return artifact; + if (isAbsolute(artifact)) return artifact; const direct = resolve(rootDir, artifact); if (existsSync(direct)) return direct; return resolve(rootDir, artifactsDir, artifact); diff --git a/packages/deployer/src/providers/network.test.ts b/packages/deployer/src/providers/network.test.ts index 5c55d22..ee7dedf 100644 --- a/packages/deployer/src/providers/network.test.ts +++ b/packages/deployer/src/providers/network.test.ts @@ -41,7 +41,6 @@ describe('applyNetwork', () => { 'testnet', 'preview', 'preprod', - 'mainnet', ])('should accept known network id %s', (id) => { expect(() => applyNetwork({ ...baseNetwork, network_id: id }, 'http://ps'), @@ -49,6 +48,13 @@ describe('applyNetwork', () => { expect(setNetworkId).toHaveBeenLastCalledWith(id); }); + it('should reject mainnet while the deployer is testnet/preview-only', () => { + expect(() => + applyNetwork({ ...baseNetwork, network_id: 'mainnet' }, 'http://ps'), + ).toThrow(ConfigError); + expect(setNetworkId).not.toHaveBeenCalled(); + }); + it('should reject an unknown network id with ConfigError', () => { expect(() => applyNetwork({ ...baseNetwork, network_id: 'bogus-net' }, 'http://ps'), diff --git a/packages/deployer/src/providers/network.ts b/packages/deployer/src/providers/network.ts index 95c3d8a..58a3306 100644 --- a/packages/deployer/src/providers/network.ts +++ b/packages/deployer/src/providers/network.ts @@ -16,7 +16,6 @@ const KNOWN_NETWORK_IDS: ReadonlySet = new Set([ 'testnet', 'preview', 'preprod', - 'mainnet', ]); export interface ResolvedEnvironment { diff --git a/packages/deployer/src/providers/proof-server.test.ts b/packages/deployer/src/providers/proof-server.test.ts index df28f0f..03644c5 100644 --- a/packages/deployer/src/providers/proof-server.test.ts +++ b/packages/deployer/src/providers/proof-server.test.ts @@ -112,6 +112,22 @@ describe('ProofServer.start — precedence chain', () => { ).rejects.toThrow(ConfigError); }); + it('(4) should reject a partially-numeric PROOF_SERVER_PORT (e.g. "6300abc")', async () => { + process.env.PROOF_SERVER_PORT = '6300abc'; + await expect( + ProofServer.start({ network: baseNetwork, logger: makeLogger() }), + ).rejects.toThrow(ConfigError); + expect(StaticProofServerContainer).not.toHaveBeenCalled(); + }); + + it('(4) should reject an out-of-range PROOF_SERVER_PORT', async () => { + process.env.PROOF_SERVER_PORT = '70000'; + await expect( + ProofServer.start({ network: baseNetwork, logger: makeLogger() }), + ).rejects.toThrow(ConfigError); + expect(StaticProofServerContainer).not.toHaveBeenCalled(); + }); + it('(5) should fall back to http://127.0.0.1:6300 when nothing is configured', async () => { const ps = await ProofServer.start({ network: baseNetwork, diff --git a/packages/deployer/src/providers/proof-server.ts b/packages/deployer/src/providers/proof-server.ts index 8c6a7d0..c9983c1 100644 --- a/packages/deployer/src/providers/proof-server.ts +++ b/packages/deployer/src/providers/proof-server.ts @@ -63,8 +63,11 @@ export class ProofServer { const port = process.env.PROOF_SERVER_PORT; if (port !== undefined) { - const parsed = Number.parseInt(port, 10); - if (Number.isNaN(parsed)) { + if (!/^\d+$/.test(port)) { + throw new ConfigError(`Invalid PROOF_SERVER_PORT: ${port}`); + } + const parsed = Number(port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new ConfigError(`Invalid PROOF_SERVER_PORT: ${port}`); } logger.debug(`Using PROOF_SERVER_PORT=${parsed}`); diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts index 7e00f0f..4618fbe 100644 --- a/packages/deployer/src/wallet/handler.ts +++ b/packages/deployer/src/wallet/handler.ts @@ -7,7 +7,7 @@ import { renameSync, writeFileSync, } from 'node:fs'; -import { resolve } from 'node:path'; +import { basename, dirname, resolve } from 'node:path'; import { gzipSync } from 'node:zlib'; import { DustSecretKey, ZswapSecretKeys } from '@midnight-ntwrk/ledger-v8'; import { @@ -446,11 +446,9 @@ function buildDustConfig( } function pathDir(p: string): string { - const i = p.lastIndexOf('/'); - return i === -1 ? '.' : p.slice(0, i); + return dirname(p); } function pathBase(p: string): string { - const i = p.lastIndexOf('/'); - return i === -1 ? p : p.slice(i + 1); + return basename(p); } diff --git a/packages/deployer/src/wallet/keystore.test.ts b/packages/deployer/src/wallet/keystore.test.ts index 393a28f..29b19d0 100644 --- a/packages/deployer/src/wallet/keystore.test.ts +++ b/packages/deployer/src/wallet/keystore.test.ts @@ -147,5 +147,16 @@ describe('Keystore', () => { } as unknown as MidnightKeystore; expect(() => Keystore.fromJSON(tampered)).toThrow(/Unsupported cipher/); }); + + it('should reject a non-object with WalletError (not a raw TypeError)', () => { + expect(() => Keystore.fromJSON(null)).toThrow(WalletError); + expect(() => Keystore.fromJSON('nope')).toThrow(/expected an object/); + }); + + it('should reject JSON missing the crypto section with WalletError', () => { + expect(() => Keystore.fromJSON({ version: 'midnight-1' })).toThrow( + /missing crypto section/, + ); + }); }); }); diff --git a/packages/deployer/src/wallet/keystore.ts b/packages/deployer/src/wallet/keystore.ts index 9a2a2de..deb71f0 100644 --- a/packages/deployer/src/wallet/keystore.ts +++ b/packages/deployer/src/wallet/keystore.ts @@ -118,27 +118,35 @@ export class Keystore { `Invalid JSON in keystore ${path}: ${(e as Error).message}`, ); } - return Keystore.fromJSON(parsed as MidnightKeystore); + return Keystore.fromJSON(parsed); } - /** Wrap parsed keystore JSON; validates version/cipher/KDF eagerly. */ - static fromJSON(data: MidnightKeystore): Keystore { - if (data.version !== VERSION) { + /** Wrap parsed keystore JSON; validates shape + version/cipher/KDF eagerly. */ + static fromJSON(data: unknown): Keystore { + if (!data || typeof data !== 'object') { + throw new WalletError('Invalid keystore: expected an object'); + } + const d = data as Partial; + if (!d.crypto || typeof d.crypto !== 'object') { + throw new WalletError('Invalid keystore: missing crypto section'); + } + const crypto = d.crypto as MidnightKeystore['crypto']; + if (d.version !== VERSION) { throw new WalletError( - `Unsupported keystore version: ${data.version} (expected ${VERSION})`, + `Unsupported keystore version: ${String(d.version)} (expected ${VERSION})`, ); } - if (data.crypto.kdf !== 'scrypt') { + if (crypto.kdf !== 'scrypt') { throw new WalletError( - `Unsupported KDF: ${data.crypto.kdf} (expected scrypt)`, + `Unsupported KDF: ${String(crypto.kdf)} (expected scrypt)`, ); } - if (data.crypto.cipher !== 'aes-128-ctr') { + if (crypto.cipher !== 'aes-128-ctr') { throw new WalletError( - `Unsupported cipher: ${data.crypto.cipher} (expected aes-128-ctr)`, + `Unsupported cipher: ${String(crypto.cipher)} (expected aes-128-ctr)`, ); } - return new Keystore(data); + return new Keystore(d as MidnightKeystore); } /** Recover the hex-encoded seed. Throws {@link WalletError} on MAC mismatch. */ From 6b2f432ac7b77c807bd5dfb9ce126359279fdcff Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 17 Jun 2026 14:00:11 +0200 Subject: [PATCH 3/9] fix(deployer): unblock preprod wallet sync preprod's dust history is a ~1M-event global stream that every client replays on sync. Deploying any contract there failed before a tx was ever built: the dust wallet OOMed mid-replay, and once that was worked around the sync gate never completed. Both trace to the pre-fix wallet-sdk-dust-wallet@4.0.0 pin (midnightntwrk/midnight-wallet#425). * wallet/handler: set batchUpdates = { size: 5000, timeout: 1, spacing: 4 } on the shared wallet config so the fresh-sync and the cache-restore paths (shielded + dust alike) stream the replay in larger chunks instead of exhausting the V8 heap at the SDK default batch size of 10. * deployer: replace the strict FacadeState.isSynced tip gate with isCompleteWithin(50) on each sub-wallet. On a live chain the dust stream advances continuously, so strict completion never fires and the gate timed out on a fully usable wallet. The gate still waits on all three sub-wallets to avoid the custom-error-170 regression. * cli + README: document the NODE_OPTIONS max-old-space-size bump still useful for a first preprod sync, plus the tolerant tip gate. Verified on preprod: ShieldedFungibleToken dry-run synced to tip in ~1m12s from a warm cache (dust ~1.08M events, no OOM) and the gate completed even though dust never reached strict completion. Closes #115 --- packages/cli/src/runDeploy.ts | 15 ++ packages/deployer/README.md | 3 +- packages/deployer/src/deployer.test.ts | 172 +++++++++++++++++-- packages/deployer/src/deployer.ts | 37 +++- packages/deployer/src/wallet/handler.test.ts | 26 +++ packages/deployer/src/wallet/handler.ts | 36 ++++ 6 files changed, 269 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index c907fb4..50833ce 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -299,6 +299,21 @@ function showUsage(): void { console.log( chalk.yellow(' compact-deploy Token --network preprod --dry-run --json'), ); + console.log( + chalk.yellow( + '\nNote: a first sync on a long-history network (e.g. preprod) can exceed', + ), + ); + console.log( + chalk.yellow( + " Node's default heap. On 'JavaScript heap out of memory', raise it:", + ), + ); + console.log( + chalk.yellow( + ' NODE_OPTIONS=--max-old-space-size=8192 compact-deploy …', + ), + ); } function packageVersion(): string { diff --git a/packages/deployer/README.md b/packages/deployer/README.md index 21b682e..4ef104e 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -45,6 +45,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea - **First sync is slow** (~3 min on preview, 30–60 min on preprod from genesis). Cache makes reruns near-instant. - **Bump sync timeout**: `--sync-timeout 3600` (default 10 min). - **Bump Node heap** for long-history chains: `NODE_OPTIONS="--max-old-space-size=8192"`. +- **Tip gate is tolerant**: sync completes once every sub-wallet is within 50 events of the tip, not at an exact gap of 0. On a live network the global dust stream advances continuously, so an exact-match gate would never fire. - **Seed source**: `--seed-file`, `MN_DEPLOYER_SEED`, or `[wallet].keystore`. The `wallet = { source = "local" }` shorthand is dev-preset only. ## Wallet cache @@ -153,7 +154,7 @@ signing_key_file = "./deploy/Vault.signingkey" 4. **Dust fee overhead default breaks faucet wallets.** testkit-js default `additionalFeeOverhead` is `5e20` vs a faucet wallet's `~3e15` dust → `Insufficient Funds: could not balance dust`. Deployer overrides to `5e14` for non-mainnet. Library users constructing their own provider must mirror this. -5. **Long-history dust sync exhausts default Node heap.** Use `NODE_OPTIONS="--max-old-space-size=16384"` for the first sync. Cache fixes subsequent runs. +5. **Long-history dust sync exhausts default Node heap.** The deployer now raises the dust/shielded sync batch size (`batchUpdates = { size: 5000, … }`) so the replay no longer OOMs mid-stream on `wallet-sdk-dust-wallet@4.0.0` ([midnightntwrk/midnight-wallet#425](https://github.com/midnightntwrk/midnight-wallet/issues/425)). The restored dust tree plus shielded trial-decryption can still spike past V8's ~2 GB default old-space on a first preprod sync, so set `NODE_OPTIONS="--max-old-space-size=8192"` for that run. Cache fixes subsequent runs. ## Programmatic API diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts index 2a39ae1..3a35510 100644 --- a/packages/deployer/src/deployer.test.ts +++ b/packages/deployer/src/deployer.test.ts @@ -129,14 +129,27 @@ function fakeProvider(coinKey = '0xCOIN'): FakeProvider { isSynced: true, shielded: { balances: anyKeyHasBalance, - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, }, unshielded: { balances: anyKeyHasBalance, - progress: { isStrictlyComplete: () => true }, + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, }, dust: { - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, balance: () => 1n, }, }; @@ -213,14 +226,27 @@ function syncedState( isSynced: true, shielded: { balances: shielded, - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, }, unshielded: { balances: unshielded, - progress: { isStrictlyComplete: () => true }, + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, }, dust: { - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, balance: () => 0n, }, }; @@ -414,7 +440,7 @@ describe('Deployer', () => { }); describe('syncAndVerifyFunds (owned-wallet branch)', () => { - it('should reject with a timeout error when the wallet never reports isSynced', async () => { + it('should reject with a timeout error when the wallet never reaches chain tip', async () => { const built = fakeOwnedFromProvider( fakeProviderWithState(Rx.NEVER, '0xSTUCK'), ); @@ -430,6 +456,110 @@ describe('Deployer', () => { ).rejects.toThrow(/Wallet sync timeout after 50ms/); }); + it('should complete sync when sub-wallets are within the gap but not strictly complete', async () => { + // Live-chain shape (issue #115): the global dust stream keeps + // advancing, so dust is never strictly complete (gap 0) but settles + // within the tolerated gap. The old strict `isSynced` gate would hang + // here forever; the tolerant `isCompleteWithin` gate must proceed. + const anyBal = new Proxy({} as Record, { + get: () => 1n, + }); + const liveTipState = { + isSynced: false, + shielded: { + balances: anyBal, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, + }, + unshielded: { + balances: anyBal, + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, + dust: { + state: { + progress: { + isStrictlyComplete: () => false, + isCompleteWithin: () => true, + }, + }, + balance: () => 1n, + }, + }; + const built = fakeOwnedFromProvider( + fakeProviderWithState(Rx.of(liveTipState as unknown), '0xLIVE-TIP'), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xLIVE-TIP'); + }); + + it('should NOT complete sync while any sub-wallet is outside the gap', async () => { + // Dust still outside the tolerated gap: the gate must keep waiting and + // ultimately time out rather than deploy against a half-synced wallet. + const anyBal = new Proxy({} as Record, { + get: () => 1n, + }); + const laggingState = { + isSynced: false, + shielded: { + balances: anyBal, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, + }, + unshielded: { + balances: anyBal, + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, + dust: { + state: { + progress: { + isStrictlyComplete: () => false, + isCompleteWithin: () => false, + }, + }, + balance: () => 1n, + }, + }; + // Emit the lagging state, then hang: the gate filters it out and must + // keep waiting (not complete the sequence) so the timeout can fire. + const built = fakeOwnedFromProvider( + fakeProviderWithState( + Rx.concat(Rx.of(laggingState as unknown), Rx.NEVER), + '0xLAGGING', + ), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await expect( + Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 50, + }), + ).rejects.toThrow(/Wallet sync timeout after 50ms/); + }); + it('should throw UnfundedWalletError when shielded and unshielded balances are both empty', async () => { const built = fakeOwnedFromProvider( fakeProviderWithState(Rx.of(syncedState({}, {})), '0xEMPTY'), @@ -684,10 +814,10 @@ describe('Deployer', () => { describe('describeProgress branches', () => { it('should render the progress percentage when highest > 0', async () => { - // Mid-sync state (NOT yet `isSynced`) that drives the progress + // Mid-sync state (still short of the tip) that drives the progress // subscription's "else" branch (highest > 0). Then a follow-up - // synced state lets `firstValueFrom(filter(isSynced))` resolve so - // the prepare call terminates. + // tip-reached state lets the `isCompleteWithin` gate resolve so the + // prepare call terminates. const midState = { isSynced: false, shielded: { @@ -695,6 +825,7 @@ describe('Deployer', () => { state: { progress: { isStrictlyComplete: () => false, + isCompleteWithin: () => false, appliedIndex: 10n, highestIndex: 100n, isConnected: true, @@ -705,6 +836,7 @@ describe('Deployer', () => { balances: {} as Record, progress: { isStrictlyComplete: () => false, + isCompleteWithin: () => false, appliedId: 5n, highestTransactionId: 50n, isConnected: true, @@ -714,6 +846,7 @@ describe('Deployer', () => { state: { progress: { isStrictlyComplete: () => false, + isCompleteWithin: () => false, appliedIndex: 1n, highestIndex: 10n, isConnected: true, @@ -729,14 +862,27 @@ describe('Deployer', () => { isSynced: true, shielded: { balances: anyKeyHasBalance, - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, }, unshielded: { balances: anyKeyHasBalance, - progress: { isStrictlyComplete: () => true }, + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, }, dust: { - state: { progress: { isStrictlyComplete: () => true } }, + state: { + progress: { + isStrictlyComplete: () => true, + isCompleteWithin: () => true, + }, + }, balance: () => 1n, }, }; diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index 4f76296..d77a5e8 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -29,6 +29,19 @@ import { */ const DEFAULT_SYNC_TIMEOUT_MS = 10 * 60 * 1000; +/** + * Tolerated sync gap, in events, for the chain-tip gate. + * `FacadeState.isSynced` requires every sub-wallet to be *strictly* + * complete (gap 0). On a live network the global dust stream advances + * continuously, so the dust wallet sits a few events behind the tip + * indefinitely and strict `isSynced` never flips. The gate would then time + * out on a wallet that is in fact fully usable. `isCompleteWithin(50)` is the + * SDK default (and what the unshielded wallet uses internally): it treats + * "within 50 events of tip" as synced, which a live wallet reaches and + * holds. See OpenZeppelin/compact-tools#115. + */ +const SYNC_MAX_GAP = 50n; + import { ConstructorArgs } from './loaders/args.ts'; import { Artifact } from './loaders/artifact.ts'; import { InitialPrivateState } from './loaders/init-state.ts'; @@ -469,11 +482,15 @@ function describeProgress(p: { isStrictlyComplete: () => boolean }): string { } /** - * Drive the wallet to chain tip and assert spendable funds. Uses - * `state.isSynced` (strict-complete on all three sub-wallets) as the - * gate. Looser gates regressed on local with `Invalid Transaction - * (custom error 170)`. Throttles progress logs to once per 30 s. - * Throws {@link UnfundedWalletError} on empty wallet. + * Drive the wallet to chain tip and assert spendable funds. Gates on each + * sub-wallet being within {@link SYNC_MAX_GAP} events of the tip rather + * than strictly complete: on a live network the global dust stream never + * settles to gap 0, so strict `FacadeState.isSynced` never fires and the + * gate times out on a perfectly usable wallet (#115). The gate still waits + * on all three sub-wallets; dropping the dust/shielded wait entirely + * regressed on local with `Invalid Transaction (custom error 170)`. + * Throttles progress logs to once per 30 s. Throws + * {@link UnfundedWalletError} on empty wallet. */ async function syncAndVerifyFunds(args: { wallet: MidnightWalletProvider; @@ -577,7 +594,15 @@ async function syncAndVerifyFunds(args: { try { synced = await Rx.firstValueFrom( state$.pipe( - Rx.filter((s: FacadeState) => s.isSynced), + // Tolerant tip gate: all three sub-wallets within SYNC_MAX_GAP + // events of the tip. Strict `s.isSynced` never fires on a live + // chain because the global dust stream keeps advancing (#115). + Rx.filter( + (s: FacadeState) => + s.shielded.state.progress.isCompleteWithin(SYNC_MAX_GAP) && + s.dust.state.progress.isCompleteWithin(SYNC_MAX_GAP) && + s.unshielded.progress.isCompleteWithin(SYNC_MAX_GAP), + ), Rx.timeout({ each: timeoutMs, with: () => diff --git a/packages/deployer/src/wallet/handler.test.ts b/packages/deployer/src/wallet/handler.test.ts index d11fe1e..4cebe1d 100644 --- a/packages/deployer/src/wallet/handler.test.ts +++ b/packages/deployer/src/wallet/handler.test.ts @@ -210,6 +210,32 @@ describe('WalletHandler', () => { }); }); + describe('sync batching', () => { + it('should set a large batchUpdates size on the shared config for both sub-wallets', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build(logger, fakeEnv('preprod'), { + kind: 'hex', + value: '00', + }); + // The shared config is mutated in place and threaded into every + // sub-wallet factory, so asserting on the dust + shielded calls proves + // the OOM workaround (issue #115) covers both. Default SDK batch size + // is 10, which OOMs replaying preprod's ~1M-event dust stream. + const withBatch = expect.objectContaining({ + batchUpdates: { size: 5000, timeout: 1, spacing: 4 }, + }); + expect(WalletFactory.createDustWallet).toHaveBeenCalledWith( + withBatch, + expect.any(Uint8Array), + expect.anything(), + ); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledWith( + withBatch, + expect.any(Uint8Array), + ); + }); + }); + describe('provider wiring', () => { it('should expose the wallet built by MidnightWalletProvider.withWallet via .provider', async () => { const provider = fakeProvider(); diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts index 4618fbe..2c7e232 100644 --- a/packages/deployer/src/wallet/handler.ts +++ b/packages/deployer/src/wallet/handler.ts @@ -35,6 +35,32 @@ import type { Logger } from 'pino'; import { WalletError } from '../errors.ts'; import type { WalletSeed } from './seeds.ts'; +/** + * Subset of the wallet-SDK sync `batchUpdates` knob we set. The SDK only + * exports it from an unstable deep path (`wallet-sdk-/dist/v1/Sync`), + * so we declare the shape we use locally. + */ +interface BatchUpdatesConfig { + size?: number; + timeout?: number; + spacing?: number; +} + +/** + * Sync batch sizing applied to the shielded + dust sub-wallets. The SDK + * default (size 10) exhausts the V8 heap replaying preprod's ~1M-event + * global dust stream on `wallet-sdk-dust-wallet@4.0.0` + * (midnightntwrk/midnight-wallet#425, "Ineffective mark-compacts near heap + * limit"). A larger batch streams the replay in bigger chunks; size 5000 + * was validated at ~146 MB peak, ~1000 events/sec. Harmless on the + * upstream-fixed 4.1.0, which no longer needs the workaround. + */ +const SYNC_BATCH_UPDATES: BatchUpdatesConfig = { + size: 5000, + timeout: 1, + spacing: 4, +}; + export interface WalletHandlerBuildOptions { /** Force a fresh sync from genesis (skip the on-disk cache). Default `false`. */ skipWalletCache?: boolean; @@ -116,6 +142,16 @@ export class WalletHandler implements AsyncDisposable { const config = (builderForConfig as unknown as { config: ConfigShape }) .config; + // Raise the sync batch size on the *shared* config so it applies to + // every sub-wallet built off it — shielded and dust, fresh-sync and + // cache-restore alike. `buildDustConfig` spreads this config, so the + // restore path inherits it too. Setting it only on the fresh-build + // path would be bypassed the moment an on-disk snapshot exists, which + // is exactly when preprod's dust replay OOMs. See {@link + // SYNC_BATCH_UPDATES} and OpenZeppelin/compact-tools#115. + (config as { batchUpdates?: BatchUpdatesConfig }).batchUpdates = + SYNC_BATCH_UPDATES; + const unshieldedKeystore: UnshieldedKeystore = createKeystore( walletSeeds.unshielded, env.walletNetworkId as Parameters[1], From 7cbf3a718de857fd7a746fde2175b5704533d4c6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 17 Jun 2026 14:17:42 +0200 Subject: [PATCH 4/9] feat(deployer): make sync batch size configurable The dust/shielded sync batch size was hard-coded to 5000 (the #115 OOM workaround). Expose it as a knob so operators can trade memory against replay speed without recompiling, while keeping 5000 as the default. * wallet/handler: add `syncBatchSize` to WalletHandlerBuildOptions; fall back to DEFAULT_SYNC_BATCH_SIZE (5000). timeout/spacing stay at the validated values. * deployer: thread `syncBatchSize` from DeployerOptions through to the owned-wallet build (ignored when a wallet is injected). * cli: add `--sync-batch-size ` (positive-integer validated, mirrors --sync-timeout) and document it in --help and the README. Refs: OpenZeppelin/compact-tools#115 --- packages/cli/src/runDeploy.ts | 18 ++++++++++++ packages/cli/test/runDeploy.test.ts | 29 ++++++++++++++++++ packages/deployer/README.md | 2 ++ packages/deployer/src/deployer.test.ts | 22 ++++++++++++++ packages/deployer/src/deployer.ts | 8 +++++ packages/deployer/src/wallet/handler.test.ts | 25 +++++++++++++++- packages/deployer/src/wallet/handler.ts | 31 +++++++++++++------- 7 files changed, 123 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index 50833ce..7609aef 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -22,6 +22,7 @@ interface ParsedArgs { seedFile?: string; proofServer?: string; syncTimeoutSec?: number; + syncBatchSize?: number; seedCacheFromDust?: string; seedCacheFromShielded?: string; noCache: boolean; @@ -103,6 +104,17 @@ function parseArgs(argv: string[]): ParsedArgs { out.syncTimeoutSec = seconds; break; } + case '--sync-batch-size': { + const raw = expectValue(argv, ++i, '--sync-batch-size'); + const size = Number.parseInt(raw, 10); + if (!Number.isFinite(size) || size <= 0) { + throw new Error( + `--sync-batch-size requires a positive integer; got "${raw}"`, + ); + } + out.syncBatchSize = size; + break; + } default: if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); out.positional.push(arg); @@ -177,6 +189,7 @@ async function main(): Promise { skipWalletCache: args.noCache, seedCacheDust: args.seedCacheFromDust, seedCacheShielded: args.seedCacheFromShielded, + syncBatchSize: args.syncBatchSize, logger, promptPassphrase: async (path) => { if (spinner) spinner.stop(); @@ -263,6 +276,11 @@ function showUsage(): void { ' --sync-timeout Max wallet-sync seconds before failing (default 600)', ), ); + console.log( + chalk.yellow( + ' --sync-batch-size Dust/shielded sync batch size (default 5000)', + ), + ); console.log( chalk.yellow( ' --no-cache Ignore the on-disk wallet-state cache; force fresh sync', diff --git a/packages/cli/test/runDeploy.test.ts b/packages/cli/test/runDeploy.test.ts index 85e5ef5..865977a 100644 --- a/packages/cli/test/runDeploy.test.ts +++ b/packages/cli/test/runDeploy.test.ts @@ -229,6 +229,8 @@ describe('runDeploy CLI', () => { 'http://proof:6300', '--sync-timeout', '30', + '--sync-batch-size', + '5000', '--no-cache', '--seed-cache-from-dust', '/dust.json', @@ -244,6 +246,7 @@ describe('runDeploy CLI', () => { expect(opts.seedFile).toBe('/seed.hex'); expect(opts.proofServer).toBe('http://proof:6300'); expect(opts.syncTimeoutMs).toBe(30_000); + expect(opts.syncBatchSize).toBe(5000); expect(opts.skipWalletCache).toBe(true); expect(opts.seedCacheDust).toBe('/dust.json'); expect(opts.seedCacheShielded).toBe('/shielded.gz'); @@ -304,6 +307,32 @@ describe('runDeploy CLI', () => { expect(mockExit).toHaveBeenCalledWith(2); }); + it('should leave syncBatchSize undefined when --sync-batch-size is omitted', async () => { + await runMain(['Token']); + const opts = mockPrepare.mock.calls[0]?.[0] as Record; + expect(opts.syncBatchSize).toBeUndefined(); + }); + + it('should reject non-numeric --sync-batch-size', async () => { + await runMain(['Token', '--sync-batch-size', 'abc']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + '--sync-batch-size requires a positive integer', + ), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + + it('should reject zero/negative --sync-batch-size', async () => { + await runMain(['Token', '--sync-batch-size', '0']); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + '--sync-batch-size requires a positive integer', + ), + ); + expect(mockExit).toHaveBeenCalledWith(2); + }); + it('should accept -v as a shorthand for --verbose', async () => { await runMain(['Token', '-v']); expect(mockPrepare).toHaveBeenCalled(); diff --git a/packages/deployer/README.md b/packages/deployer/README.md index 4ef104e..4ffd816 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -27,6 +27,7 @@ compact-deploy --seed-file seed override (raw hex or BIP39 mnemonic, one line) --proof-server override [networks.X].proof_server --sync-timeout max wait for wallet to reach chain tip (default 600) + --sync-batch-size dust/shielded sync batch size (default 5000) --no-cache ignore on-disk wallet-state cache; force fresh sync --seed-cache-from-dust import a pre-warmed dust state file into .states/ --seed-cache-from-shielded import a pre-warmed shielded state file into .states/ @@ -45,6 +46,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea - **First sync is slow** (~3 min on preview, 30–60 min on preprod from genesis). Cache makes reruns near-instant. - **Bump sync timeout**: `--sync-timeout 3600` (default 10 min). - **Bump Node heap** for long-history chains: `NODE_OPTIONS="--max-old-space-size=8192"`. +- **Tune the sync batch size**: `--sync-batch-size ` (default 5000). Larger replays a long dust history faster but uses more memory per batch; lower it on a memory-constrained host. - **Tip gate is tolerant**: sync completes once every sub-wallet is within 50 events of the tip, not at an exact gap of 0. On a live network the global dust stream advances continuously, so an exact-match gate would never fire. - **Seed source**: `--seed-file`, `MN_DEPLOYER_SEED`, or `[wallet].keystore`. The `wallet = { source = "local" }` shorthand is dev-preset only. diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts index 3a35510..e45a4f6 100644 --- a/packages/deployer/src/deployer.test.ts +++ b/packages/deployer/src/deployer.test.ts @@ -622,6 +622,28 @@ describe('Deployer', () => { }); }); + describe('wallet build options', () => { + it('should forward syncBatchSize to WalletHandler.build', async () => { + const built = fakeOwnedWallet('0xBATCH'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + syncBatchSize: 2500, + }); + expect(d.deployer).toBe('0xBATCH'); + expect(WalletHandler.build).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ syncBatchSize: 2500 }), + ); + }); + }); + describe('explorer URL', () => { it('should return an empty explorerUrl when no explorer is configured', async () => { const injected = fakeProvider('0xDEP'); diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index d77a5e8..0135c25 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -97,6 +97,13 @@ export interface DeployerOptions { seedCacheDust?: string; /** Like {@link seedCacheDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ seedCacheShielded?: string; + /** + * Sync batch size for the shielded + dust sub-wallets. Defaults to 5000. + * Raise it to replay a long dust history faster (more memory per batch); + * lower it on a memory-constrained host. Ignored when {@link walletProvider} + * is injected. Argv: `--sync-batch-size`. + */ + syncBatchSize?: number; } /** Result of {@link Deployer.deploy} / {@link Deployer.dryRun}. On-chain fields are empty when `dryRun: true`. */ @@ -234,6 +241,7 @@ export class Deployer implements AsyncDisposable { skipWalletCache: opts.skipWalletCache, seedCacheDust: opts.seedCacheDust, seedCacheShielded: opts.seedCacheShielded, + syncBatchSize: opts.syncBatchSize, }, ); stack.use(owned); diff --git a/packages/deployer/src/wallet/handler.test.ts b/packages/deployer/src/wallet/handler.test.ts index 4cebe1d..33bed77 100644 --- a/packages/deployer/src/wallet/handler.test.ts +++ b/packages/deployer/src/wallet/handler.test.ts @@ -211,7 +211,7 @@ describe('WalletHandler', () => { }); describe('sync batching', () => { - it('should set a large batchUpdates size on the shared config for both sub-wallets', async () => { + it('should default the batchUpdates size to 5000 on the shared config for both sub-wallets', async () => { wireTestkitChain(fakeProvider()); await WalletHandler.build(logger, fakeEnv('preprod'), { kind: 'hex', @@ -234,6 +234,29 @@ describe('WalletHandler', () => { expect.any(Uint8Array), ); }); + + it('should honour a caller-supplied syncBatchSize override', async () => { + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv('preprod'), + { kind: 'hex', value: '00' }, + { syncBatchSize: 1000 }, + ); + // timeout/spacing stay at the validated values; only size changes. + const withBatch = expect.objectContaining({ + batchUpdates: { size: 1000, timeout: 1, spacing: 4 }, + }); + expect(WalletFactory.createDustWallet).toHaveBeenCalledWith( + withBatch, + expect.any(Uint8Array), + expect.anything(), + ); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledWith( + withBatch, + expect.any(Uint8Array), + ); + }); }); describe('provider wiring', () => { diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts index 2c7e232..3d9867c 100644 --- a/packages/deployer/src/wallet/handler.ts +++ b/packages/deployer/src/wallet/handler.ts @@ -47,7 +47,8 @@ interface BatchUpdatesConfig { } /** - * Sync batch sizing applied to the shielded + dust sub-wallets. The SDK + * Default sync batch size for the shielded + dust sub-wallets, overridable + * per build via {@link WalletHandlerBuildOptions.syncBatchSize}. The SDK * default (size 10) exhausts the V8 heap replaying preprod's ~1M-event * global dust stream on `wallet-sdk-dust-wallet@4.0.0` * (midnightntwrk/midnight-wallet#425, "Ineffective mark-compacts near heap @@ -55,11 +56,10 @@ interface BatchUpdatesConfig { * was validated at ~146 MB peak, ~1000 events/sec. Harmless on the * upstream-fixed 4.1.0, which no longer needs the workaround. */ -const SYNC_BATCH_UPDATES: BatchUpdatesConfig = { - size: 5000, - timeout: 1, - spacing: 4, -}; +const DEFAULT_SYNC_BATCH_SIZE = 5000; + +/** `timeout`/`spacing` companions to the batch size; left at the validated values. */ +const SYNC_BATCH_TIMING = { timeout: 1, spacing: 4 } as const; export interface WalletHandlerBuildOptions { /** Force a fresh sync from genesis (skip the on-disk cache). Default `false`. */ @@ -74,6 +74,13 @@ export interface WalletHandlerBuildOptions { seedCacheDust?: string; /** Like {@link seedCacheDust} but for the shielded sub-wallet. */ seedCacheShielded?: string; + /** + * Sync batch size for the shielded + dust sub-wallets. Defaults to + * {@link DEFAULT_SYNC_BATCH_SIZE} (5000). Raise it to replay a long dust + * history faster (more memory per batch); lower it on a memory-constrained + * host. The SDK default of 10 OOMs on preprod's ~1M-event dust stream. + */ + syncBatchSize?: number; } /** @@ -143,14 +150,16 @@ export class WalletHandler implements AsyncDisposable { .config; // Raise the sync batch size on the *shared* config so it applies to - // every sub-wallet built off it — shielded and dust, fresh-sync and + // every sub-wallet built off it: shielded and dust, fresh-sync and // cache-restore alike. `buildDustConfig` spreads this config, so the // restore path inherits it too. Setting it only on the fresh-build // path would be bypassed the moment an on-disk snapshot exists, which - // is exactly when preprod's dust replay OOMs. See {@link - // SYNC_BATCH_UPDATES} and OpenZeppelin/compact-tools#115. - (config as { batchUpdates?: BatchUpdatesConfig }).batchUpdates = - SYNC_BATCH_UPDATES; + // is exactly when preprod's dust replay OOMs. See + // {@link DEFAULT_SYNC_BATCH_SIZE} and OpenZeppelin/compact-tools#115. + (config as { batchUpdates?: BatchUpdatesConfig }).batchUpdates = { + size: opts.syncBatchSize ?? DEFAULT_SYNC_BATCH_SIZE, + ...SYNC_BATCH_TIMING, + }; const unshieldedKeystore: UnshieldedKeystore = createKeystore( walletSeeds.unshielded, From c3ea7983e67975fdc34d95b1d7cb0af1a8d12b40 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 17 Jun 2026 14:28:57 +0200 Subject: [PATCH 5/9] feat(deployer): allow sync knobs in compact.toml The sync batch size and sync timeout were CLI/programmatic only, so an operator had to pass `--sync-batch-size` / `--sync-timeout` on every run for a long-history network like preprod. Let them live in config too. * config/schema: add optional `sync_timeout` (seconds) and `sync_batch_size` to `[networks.X]`, both positive integers. * deployer: resolve each with precedence CLI/programmatic option > TOML network value > built-in default (10 min / 5000). Applies only to the owned-wallet build; ignored when a wallet is injected. * README: document the keys, the precedence, and a preprod example. Refs: OpenZeppelin/compact-tools#115 --- packages/deployer/README.md | 3 + packages/deployer/src/config/schema.test.ts | 37 +++++++++ packages/deployer/src/config/schema.ts | 7 ++ packages/deployer/src/deployer.test.ts | 88 ++++++++++++++++++++- packages/deployer/src/deployer.ts | 28 +++++-- 5 files changed, 154 insertions(+), 9 deletions(-) diff --git a/packages/deployer/README.md b/packages/deployer/README.md index 4ffd816..2029bc5 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -47,6 +47,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea - **Bump sync timeout**: `--sync-timeout 3600` (default 10 min). - **Bump Node heap** for long-history chains: `NODE_OPTIONS="--max-old-space-size=8192"`. - **Tune the sync batch size**: `--sync-batch-size ` (default 5000). Larger replays a long dust history faster but uses more memory per batch; lower it on a memory-constrained host. +- **Persist the sync knobs in TOML**: set `sync_timeout` (seconds) and/or `sync_batch_size` under `[networks.X]` so you don't pass the flags every run. The matching CLI flag overrides the TOML value when both are present (CLI > TOML > default). - **Tip gate is tolerant**: sync completes once every sub-wallet is within 50 events of the tip, not at an exact gap of 0. On a live network the global dust stream advances continuously, so an exact-match gate would never fire. - **Seed source**: `--seed-file`, `MN_DEPLOYER_SEED`, or `[wallet].keystore`. The `wallet = { source = "local" }` shorthand is dev-preset only. @@ -121,6 +122,8 @@ node = "https://rpc.preprod.midnight.network" node_ws = "wss://rpc.preprod.midnight.network" proof_server = "auto" explorer = "https://preprod.midnightexplorer.com" +sync_timeout = 5400 # seconds; overridden by --sync-timeout +sync_batch_size = 5000 # dust/shielded batch; overridden by --sync-batch-size # ---------- Wallet (non-local) ---------- [wallet] diff --git a/packages/deployer/src/config/schema.test.ts b/packages/deployer/src/config/schema.test.ts index 014f90e..86d086d 100644 --- a/packages/deployer/src/config/schema.test.ts +++ b/packages/deployer/src/config/schema.test.ts @@ -104,6 +104,43 @@ describe('configSchema — networks', () => { }); }); +describe('configSchema — network sync tuning', () => { + it('should accept sync_timeout and sync_batch_size as positive integers', () => { + const parsed = configSchema.parse({ + ...baseConfig, + networks: { + testnet: { ...validNetwork, sync_timeout: 3600, sync_batch_size: 5000 }, + }, + }); + expect(parsed.networks.testnet.sync_timeout).toBe(3600); + expect(parsed.networks.testnet.sync_batch_size).toBe(5000); + }); + + it('should leave sync_timeout and sync_batch_size undefined when omitted', () => { + const parsed = configSchema.parse(baseConfig); + expect(parsed.networks.testnet.sync_timeout).toBeUndefined(); + expect(parsed.networks.testnet.sync_batch_size).toBeUndefined(); + }); + + it('should reject a non-positive sync_batch_size', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { testnet: { ...validNetwork, sync_batch_size: 0 } }, + }), + ).toThrow(); + }); + + it('should reject a fractional sync_timeout', () => { + expect(() => + configSchema.parse({ + ...baseConfig, + networks: { testnet: { ...validNetwork, sync_timeout: 1.5 } }, + }), + ).toThrow(); + }); +}); + describe('configSchema — profile.default_network refine', () => { it('should accept default_network pointing at a defined network', () => { expect(() => diff --git a/packages/deployer/src/config/schema.ts b/packages/deployer/src/config/schema.ts index b7619aa..495280d 100644 --- a/packages/deployer/src/config/schema.ts +++ b/packages/deployer/src/config/schema.ts @@ -33,6 +33,13 @@ const networkSchema = z.object({ // When set, the CLI prints `/contracts/0x
` on a successful // deploy. Trailing slash is stripped at print time. explorer: url.optional(), + // Optional sync tuning, per network. `sync_timeout` is the max seconds to + // wait for the wallet to reach chain tip; `sync_batch_size` is the + // dust/shielded sync batch size (raise for long-history networks like + // preprod, default 5000). The matching CLI flags (`--sync-timeout`, + // `--sync-batch-size`) override these when set. + sync_timeout: z.number().int().positive().optional(), + sync_batch_size: z.number().int().positive().optional(), }); const walletObjectSchema = z.object({ diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts index e45a4f6..ff1e52c 100644 --- a/packages/deployer/src/deployer.test.ts +++ b/packages/deployer/src/deployer.test.ts @@ -272,9 +272,23 @@ interface Fixture { cleanup: () => void; } -function writeFixture(opts: { explorer?: string } = {}): Fixture { +function writeFixture( + opts: { + explorer?: string; + syncTimeout?: number; + syncBatchSize?: number; + } = {}, +): Fixture { const rootDir = mkdtempSync(join(tmpdir(), 'deployer-test-')); const explorerLine = opts.explorer ? `explorer = "${opts.explorer}"\n` : ''; + const syncTimeoutLine = + opts.syncTimeout !== undefined + ? `sync_timeout = ${opts.syncTimeout}\n` + : ''; + const syncBatchLine = + opts.syncBatchSize !== undefined + ? `sync_batch_size = ${opts.syncBatchSize}\n` + : ''; const toml = ` [profile] artifacts_dir = "artifacts" @@ -288,7 +302,7 @@ node = "http://localhost:9944" node_ws = "ws://localhost:9944" proof_server = "http://localhost:6300" wallet = { source = "local", index = 0 } -${explorerLine} +${explorerLine}${syncTimeoutLine}${syncBatchLine} [contracts.Counter] artifact = "Counter" signing_key_file = "signing-key.hex" @@ -642,6 +656,76 @@ describe('Deployer', () => { expect.objectContaining({ syncBatchSize: 2500 }), ); }); + + it('should use the TOML [networks.X].sync_batch_size when no option is passed', async () => { + const customFx = writeFixture({ syncBatchSize: 1234 }); + try { + const built = fakeOwnedWallet('0xTOML-BATCH'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + }); + expect(d.deployer).toBe('0xTOML-BATCH'); + expect(WalletHandler.build).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ syncBatchSize: 1234 }), + ); + } finally { + customFx.cleanup(); + } + }); + + it('should let the syncBatchSize option override the TOML value', async () => { + const customFx = writeFixture({ syncBatchSize: 1234 }); + try { + const built = fakeOwnedWallet('0xOVERRIDE'); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + syncTimeoutMs: 1000, + syncBatchSize: 9999, + }); + expect(d.deployer).toBe('0xOVERRIDE'); + expect(WalletHandler.build).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ syncBatchSize: 9999 }), + ); + } finally { + customFx.cleanup(); + } + }); + + it('should apply the TOML [networks.X].sync_timeout (seconds) as the sync ceiling', async () => { + // 1s TOML timeout against a never-syncing wallet must trip at 1000ms. + const customFx = writeFixture({ syncTimeout: 1 }); + try { + const built = fakeOwnedFromProvider( + fakeProviderWithState(Rx.NEVER, '0xTOML-TIMEOUT'), + ); + vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); + await expect( + Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: customFx.configPath, + logger: silentLogger, + }), + ).rejects.toThrow(/Wallet sync timeout after 1000ms/); + } finally { + customFx.cleanup(); + } + }); }); describe('explorer URL', () => { diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index 0135c25..30ee36b 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -84,7 +84,11 @@ export interface DeployerOptions { * LevelDB directory. */ privateStateProvider?: PrivateStateProvider; - /** Sync ceiling (ms). Defaults to {@link DEFAULT_SYNC_TIMEOUT_MS}. Ignored when {@link walletProvider} is injected. */ + /** + * Sync ceiling (ms). Precedence: this value > `[networks.X].sync_timeout` + * (seconds, from TOML) > {@link DEFAULT_SYNC_TIMEOUT_MS}. Ignored when + * {@link walletProvider} is injected. + */ syncTimeoutMs?: number; /** Force a fresh sync from genesis. Default `false` (cache reuse saves the 30–60 min first-preprod sync). */ skipWalletCache?: boolean; @@ -98,10 +102,11 @@ export interface DeployerOptions { /** Like {@link seedCacheDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ seedCacheShielded?: string; /** - * Sync batch size for the shielded + dust sub-wallets. Defaults to 5000. - * Raise it to replay a long dust history faster (more memory per batch); - * lower it on a memory-constrained host. Ignored when {@link walletProvider} - * is injected. Argv: `--sync-batch-size`. + * Sync batch size for the shielded + dust sub-wallets. Precedence: this + * value > `[networks.X].sync_batch_size` (TOML) > 5000. Raise it to replay + * a long dust history faster (more memory per batch); lower it on a + * memory-constrained host. Ignored when {@link walletProvider} is injected. + * Argv: `--sync-batch-size`. */ syncBatchSize?: number; } @@ -233,6 +238,15 @@ export class Deployer implements AsyncDisposable { if (!seedResolution) { throw new Error('internal: seedResolution missing for owned wallet'); } + // Sync tuning precedence: CLI/programmatic option > [networks.X] TOML + // value > built-in default. `sync_batch_size` falls through to + // WalletHandler's 5000 default when neither is set. + const syncBatchSize = opts.syncBatchSize ?? network.sync_batch_size; + const syncTimeoutMs = + opts.syncTimeoutMs ?? + (network.sync_timeout !== undefined + ? network.sync_timeout * 1000 + : DEFAULT_SYNC_TIMEOUT_MS); const owned = await WalletHandler.build( logger, env, @@ -241,7 +255,7 @@ export class Deployer implements AsyncDisposable { skipWalletCache: opts.skipWalletCache, seedCacheDust: opts.seedCacheDust, seedCacheShielded: opts.seedCacheShielded, - syncBatchSize: opts.syncBatchSize, + syncBatchSize, }, ); stack.use(owned); @@ -258,7 +272,7 @@ export class Deployer implements AsyncDisposable { await logWalletAddresses(wallet, logger); await syncAndVerifyFunds({ wallet, - timeoutMs: opts.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS, + timeoutMs: syncTimeoutMs, logger, // Periodic checkpoint: every 5 min during sync, snapshot both // sub-wallet caches. If the user interrupts a long first-run, From a2d4ead07b782fa0312022a8946dc5542ade0de6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Wed, 17 Jun 2026 15:16:42 +0200 Subject: [PATCH 6/9] docs(deployer): document npx + local-install for the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Install & run" section: the `compact-deploy` bin ships in @openzeppelin/compact-cli, and `npx compact-deploy` resolves the local install so the deployer and the compiled contracts share one compact-runtime copy (required for a real deploy). Note that fully ephemeral `npx @openzeppelin/compact-cli …` is fine for help/dry-run but not for real deploys, since its cache-tree runtime differs from the project's and the submit fails the ContractMaintenanceAuthority check. Refs: OpenZeppelin/compact-tools#115 --- packages/deployer/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/deployer/README.md b/packages/deployer/README.md index 2029bc5..132aa5e 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -18,6 +18,19 @@ compact-deploy Token --network local The deploy result lands in `deployments/compact/.json`. +## Install & run + +The `compact-deploy` bin ships in `@openzeppelin/compact-cli`. Install it as a dev dependency of the project that holds your compiled artifacts, then run it with `npx`: + +```bash +npm i -D @openzeppelin/compact-cli # or pnpm/yarn +npx compact-deploy Token --network local # resolves the local install +``` + +`npx` prefers the local `node_modules/.bin`, so the deployer and your compiled contracts share **one** `@midnight-ntwrk/compact-runtime` copy. A real deploy requires this: the deploy-tx builder creates a `ContractMaintenanceAuthority` from the runtime and hands it to your contract, and the WASM check rejects it unless both sides are the same physical copy. + +> **Ephemeral `npx @openzeppelin/compact-cli compact-deploy …` (no local install)** is fine for `--help`, `--version`, and `--dry-run` / validation, but **not reliable for a real deploy**: npx fetches the CLI into its own cache tree, so its `compact-runtime` differs from your project's and the submit fails with `expected instance of ContractMaintenanceAuthority`. Install it locally for real deploys. + ## CLI ``` From a0483c17d349420e87279fe10c3c6137ed7e6374 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 28 Aug 2026 10:58:28 +0200 Subject: [PATCH 7/9] fix(deployer): apply review fixes and drop the barrel Secret handling, dead dependency pruning, keystore hardening, and the --version flag, from the review of PR #86. Replaces src/index.ts with explicit package.json subpath exports. Each module is now reached by its own path, so the noBarrelFile suppression is gone and consumers no longer pull the whole package to import one symbol. Internals stay unexported. --- packages/cli/README.md | 2 +- packages/cli/src/prompt.ts | 4 +- packages/cli/src/runDeploy.ts | 138 ++--------- packages/cli/test/prompt.test.ts | 9 + packages/cli/test/runDeploy.test.ts | 59 +++-- packages/deployer/README.md | 43 +++- packages/deployer/package.json | 73 +++++- packages/deployer/src/config/schema.test.ts | 17 +- packages/deployer/src/config/schema.ts | 3 +- packages/deployer/src/deployer.test.ts | 24 +- packages/deployer/src/deployer.ts | 21 +- packages/deployer/src/deployments.test.ts | 34 ++- packages/deployer/src/deployments.ts | 107 +++++++-- packages/deployer/src/index.ts | 49 ---- packages/deployer/src/loaders/argv.test.ts | 104 ++++++++ packages/deployer/src/loaders/argv.ts | 134 +++++++++++ .../deployer/src/loaders/artifact-path.ts | 39 +++ packages/deployer/src/loaders/artifact.ts | 31 +-- .../deployer/src/loaders/contract-resolve.ts | 34 +-- packages/deployer/src/providers/build.test.ts | 39 ++- packages/deployer/src/providers/build.ts | 25 +- .../src/providers/private-state-password.ts | 16 +- packages/deployer/src/runDeploy.test.ts | 103 +++++++- packages/deployer/src/runDeploy.ts | 143 +++-------- packages/deployer/src/wallet/handler.test.ts | 22 ++ packages/deployer/src/wallet/handler.ts | 9 +- packages/deployer/src/wallet/keystore.test.ts | 58 +++++ packages/deployer/src/wallet/keystore.ts | 71 +++++- yarn.lock | 224 ++++++++---------- 29 files changed, 1078 insertions(+), 557 deletions(-) delete mode 100644 packages/deployer/src/index.ts create mode 100644 packages/deployer/src/loaders/argv.test.ts create mode 100644 packages/deployer/src/loaders/argv.ts create mode 100644 packages/deployer/src/loaders/artifact-path.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index b3fad84..d751b5a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -55,7 +55,7 @@ documentation, programmatic API, and behavioural details. ## Requirements -- Node.js >= 20 +- Node.js >= 24 — the deployer uses explicit resource management (`await using` / `AsyncDisposableStack`), global only from Node 24 - Midnight Compact toolchain installed and available in `PATH` ```bash diff --git a/packages/cli/src/prompt.ts b/packages/cli/src/prompt.ts index 4ecc486..1bfd301 100644 --- a/packages/cli/src/prompt.ts +++ b/packages/cli/src/prompt.ts @@ -53,7 +53,9 @@ function readMaskedLine(): Promise { return; } if (code === 0x7f || code === 0x08) { - buffer = buffer.slice(0, -1); + // Split by code point: `slice(-1)` on a string would strip half a + // surrogate pair, leaving a lone surrogate in the passphrase. + buffer = [...buffer].slice(0, -1).join(''); continue; } buffer += ch; diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index 7609aef..f0696fa 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -6,7 +6,13 @@ * shim is required: midnight-js's indexer client uses the browser WebSocket * interface, which Node only ships natively from v22. */ -import { DeployError, Deployer } from '@openzeppelin/compact-deployer'; +import { createRequire } from 'node:module'; +import { Deployer } from '@openzeppelin/compact-deployer/deployer'; +import { DeployError } from '@openzeppelin/compact-deployer/errors'; +import { + type ParsedDeployArgv, + parseDeployArgv, +} from '@openzeppelin/compact-deployer/loaders/argv'; import chalk from 'chalk'; import ora from 'ora'; import { WebSocket } from 'ws'; @@ -15,121 +21,16 @@ import { promptPassphrase } from './prompt.ts'; (globalThis as { WebSocket?: unknown }).WebSocket = WebSocket; -interface ParsedArgs { +/** Shared deploy flags plus the CLI-only contract positional. */ +interface ParsedArgs extends ParsedDeployArgv { contract?: string; - network?: string; - configPath?: string; - seedFile?: string; - proofServer?: string; - syncTimeoutSec?: number; - syncBatchSize?: number; - seedCacheFromDust?: string; - seedCacheFromShielded?: string; - noCache: boolean; - dryRun: boolean; - json: boolean; - verbose: boolean; - help: boolean; - version: boolean; - positional: string[]; } function parseArgs(argv: string[]): ParsedArgs { - const out: ParsedArgs = { - noCache: false, - dryRun: false, - json: false, - verbose: false, - help: false, - version: false, - positional: [], - }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] as string; - switch (arg) { - case '-h': - case '--help': - out.help = true; - break; - case '--version': - out.version = true; - break; - case '-v': - case '--verbose': - out.verbose = true; - break; - case '--json': - out.json = true; - break; - case '--dry-run': - out.dryRun = true; - break; - case '--no-cache': - out.noCache = true; - break; - case '--seed-cache-from-dust': - out.seedCacheFromDust = expectValue( - argv, - ++i, - '--seed-cache-from-dust', - ); - break; - case '--seed-cache-from-shielded': - out.seedCacheFromShielded = expectValue( - argv, - ++i, - '--seed-cache-from-shielded', - ); - break; - case '--network': - out.network = expectValue(argv, ++i, '--network'); - break; - case '--config': - out.configPath = expectValue(argv, ++i, '--config'); - break; - case '--seed-file': - out.seedFile = expectValue(argv, ++i, '--seed-file'); - break; - case '--proof-server': - out.proofServer = expectValue(argv, ++i, '--proof-server'); - break; - case '--sync-timeout': { - const raw = expectValue(argv, ++i, '--sync-timeout'); - const seconds = Number.parseInt(raw, 10); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error( - `--sync-timeout requires a positive integer (seconds); got "${raw}"`, - ); - } - out.syncTimeoutSec = seconds; - break; - } - case '--sync-batch-size': { - const raw = expectValue(argv, ++i, '--sync-batch-size'); - const size = Number.parseInt(raw, 10); - if (!Number.isFinite(size) || size <= 0) { - throw new Error( - `--sync-batch-size requires a positive integer; got "${raw}"`, - ); - } - out.syncBatchSize = size; - break; - } - default: - if (arg.startsWith('--')) throw new Error(`Unknown flag: ${arg}`); - out.positional.push(arg); - } - } - out.contract = out.positional[0]; - return out; -} - -function expectValue(argv: string[], i: number, flag: string): string { - const v = argv[i]; - if (v === undefined || v.startsWith('-')) { - throw new Error(`${flag} requires a value`); - } - return v; + // Unlike the library entrypoint, the CLI is the whole program: an + // unrecognised flag is a user typo, not a host app's extra argv. + const parsed = parseDeployArgv(argv, { rejectUnknownFlags: true }); + return { ...parsed, contract: parsed.positional[0] }; } async function main(): Promise { @@ -334,8 +235,19 @@ function showUsage(): void { ); } +/** + * The CLI's own version. `../package.json` resolves the same from `src/` and + * from the compiled `dist/`, both being one level under the package root. + */ function packageVersion(): string { - return process.env.npm_package_version ?? 'dev'; + try { + const require = createRequire(import.meta.url); + return ( + (require('../package.json') as { version?: string }).version ?? 'dev' + ); + } catch { + return 'dev'; + } } main(); diff --git a/packages/cli/test/prompt.test.ts b/packages/cli/test/prompt.test.ts index 42bde24..441a6c6 100644 --- a/packages/cli/test/prompt.test.ts +++ b/packages/cli/test/prompt.test.ts @@ -121,6 +121,15 @@ describe('promptPassphrase', () => { expect(pp).toBe('xa'); }); + it('should delete a whole astral character on backspace, not half a surrogate pair', async () => { + const promise = promptPassphrase('label'); + mockStdin.emit('data', Buffer.from('pw🔒')); + mockStdin.emit('data', Buffer.from([0x7f])); + mockStdin.emit('data', Buffer.from('x\n')); + const pp = await promise; + expect(pp).toBe('pwx'); + }); + it('should drop a trailing backspace that empties the buffer', async () => { const promise = promptPassphrase('label'); mockStdin.emit('data', Buffer.from([0x7f])); diff --git a/packages/cli/test/runDeploy.test.ts b/packages/cli/test/runDeploy.test.ts index 865977a..f5f3438 100644 --- a/packages/cli/test/runDeploy.test.ts +++ b/packages/cli/test/runDeploy.test.ts @@ -1,23 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { Deployer } from '@openzeppelin/compact-deployer/deployer'; import { ArtifactNotFoundError, DeployError, - Deployer, -} from '@openzeppelin/compact-deployer'; +} from '@openzeppelin/compact-deployer/errors'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const cliPackageVersion: string = JSON.parse( + readFileSync( + fileURLToPath(new URL('../package.json', import.meta.url)), + 'utf8', + ), +).version; + // --- Mocks -------------------------------------------------------------- -vi.mock('@openzeppelin/compact-deployer', async () => { - const actual = await vi.importActual< - typeof import('@openzeppelin/compact-deployer') - >('@openzeppelin/compact-deployer'); - return { - ...actual, - Deployer: { - prepare: vi.fn(), - }, - }; -}); +// Only the deploy entrypoint is stubbed; the error classes stay real so the +// CLI's `instanceof DeployError` exit-code branch is exercised, not faked. +vi.mock('@openzeppelin/compact-deployer/deployer', () => ({ + Deployer: { + prepare: vi.fn(), + }, +})); + +// Registered as a mock resolving to the real module: the mock registry +// survives the `vi.resetModules()` in `runMain`, so the CLI re-import keeps +// the same error classes this file holds. Without it every reset hands the +// CLI a second copy and `instanceof DeployError` is false against ours. +vi.mock('@openzeppelin/compact-deployer/errors', async () => + vi.importActual('@openzeppelin/compact-deployer/errors'), +); vi.mock('chalk', () => ({ default: { @@ -185,29 +198,23 @@ describe('runDeploy CLI', () => { expect(mockPrepare).not.toHaveBeenCalled(); }); - it('should print the package version on --version', async () => { + it("should print the CLI's own package.json version on --version", async () => { + // Read from the manifest, not `npm_package_version`: that env var is + // set by whichever package's script is running, so under `npx` or a + // consumer's `yarn deploy` it reported the wrong project's version. const prev = process.env.npm_package_version; process.env.npm_package_version = '9.9.9'; try { await runMain(['--version']); - expect(mockConsoleLog).toHaveBeenCalledWith('9.9.9'); + expect(mockConsoleLog).toHaveBeenCalledWith(cliPackageVersion); + expect(mockConsoleLog).not.toHaveBeenCalledWith('9.9.9'); + expect(cliPackageVersion).toMatch(/^\d+\.\d+\.\d+/); } finally { if (prev === undefined) delete process.env.npm_package_version; else process.env.npm_package_version = prev; } expect(mockPrepare).not.toHaveBeenCalled(); }); - - it('should fall back to "dev" when npm_package_version is unset', async () => { - const prev = process.env.npm_package_version; - delete process.env.npm_package_version; - try { - await runMain(['--version']); - expect(mockConsoleLog).toHaveBeenCalledWith('dev'); - } finally { - if (prev !== undefined) process.env.npm_package_version = prev; - } - }); }); // ------------------------------------------------------------------ // diff --git a/packages/deployer/README.md b/packages/deployer/README.md index 132aa5e..da6f373 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -6,6 +6,10 @@ compact-deploy Token --network local > **Status: developer-preview, testnet only.** Verified on local devnet + preview. Preprod blocked ([Known issues](#known-issues-may-2026)). Mainnet unsupported: unaudited, no hardware signer, no multisig, no tx retry, no upgrade tooling. See [Roadmap](#roadmap--todo). +## Requirements + +- Node.js >= 24 — the deployer uses explicit resource management (`await using` / `AsyncDisposableStack`), global only from Node 24. + ## Quick start 1. Compile your contract with `compact-compiler` so artifacts land under `src/artifacts//`. @@ -98,7 +102,7 @@ Precedence, first non-null wins: 1. `--seed-file ` 2. `MN_DEPLOYER_SEED` env var (hex or BIP39 mnemonic) 3. `[wallet].keystore` (encrypted JSON, passphrase prompted) -4. `--network local` only: built-in prefunded standalone seed at `[networks.local].wallet.index` (0..3) +4. `--network local` only: built-in prefunded standalone seed at `[networks.local].wallet.index` (0..4) ## Sample config @@ -176,13 +180,46 @@ signing_key_file = "./deploy/Vault.signingkey" ## Programmatic API +The package has no barrel entrypoint: each module is its own subpath export, so an import names the module it comes from and pulls in only that module. + +| Subpath | Exports | +|---|---| +| `/run-deploy` | `runDeploy`, `constructorArgs`, `ConstructorArgsOf`, `RunDeployOptions` | +| `/deployer` | `Deployer`, `DeployerOptions`, `DeployResult` | +| `/deployments` | `Deployments`, `DeploymentRecord`, `DeploymentsFile`, `DeploymentsHistory` | +| `/errors` | `DeployError` and every typed subclass | +| `/config/compact-config` | `CompactConfig` | +| `/config/schema` | `ContractConfig`, `NetworkConfig`, `Profile`, `WalletConfig` | +| `/loaders/args` · `/loaders/argv` · `/loaders/artifact` · `/loaders/init-state` · `/loaders/signing-key` | `ConstructorArgs`, `parseDeployArgv`, `Artifact`, `InitialPrivateState`, `SigningKey` | +| `/providers/proof-server` | `ProofServer` | +| `/wallet/handler` · `/wallet/keystore` · `/wallet/seeds` | `WalletHandler`, `Keystore`, `classifySeed`, `localPrefundedSeed` | + +Curried form — pass the compiled `Contract` class and the constructor args are typed function parameters: + ```ts -import { deploy } from "@openzeppelin/compact-deployer"; +import { runDeploy } from "@openzeppelin/compact-deployer/run-deploy"; +import { Contract } from "./src/artifacts/Token/contract/index.js"; + +const result = await runDeploy(Contract, { network: "local" })( + "OpenZeppelin Token", // _name: string + "OZE", // _symbol: string + 18n, // _decimals: bigint +); +console.log(result.address); +``` + +Options-object form — name the contract as it appears in `compact.toml`, with args from the TOML or passed inline: -const result = await deploy({ +```ts +import { runDeploy } from "@openzeppelin/compact-deployer/run-deploy"; + +const result = await runDeploy({ contract: "Token", network: "local", configPath: "./compact.toml", + args: ["OpenZeppelin Token", "OZE", 18n], }); console.log(result.address); ``` + +Every option has a `process.argv` default (`--network`, `--config`, `--dry-run`, …), so the same script works with flags at the call site. On failure `runDeploy` sets `process.exitCode` and rethrows the original error; it never calls `process.exit`, so catch it if you want that exit code to reach the shell. diff --git a/packages/deployer/package.json b/packages/deployer/package.json index 579962d..29f8cb6 100644 --- a/packages/deployer/package.json +++ b/packages/deployer/package.json @@ -10,13 +10,68 @@ "author": "OpenZeppelin Community ", "license": "MIT", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } + "./config/compact-config": { + "types": "./dist/config/compact-config.d.ts", + "import": "./dist/config/compact-config.js" + }, + "./config/schema": { + "types": "./dist/config/schema.d.ts", + "import": "./dist/config/schema.js" + }, + "./deployer": { + "types": "./dist/deployer.d.ts", + "import": "./dist/deployer.js" + }, + "./deployments": { + "types": "./dist/deployments.d.ts", + "import": "./dist/deployments.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "import": "./dist/errors.js" + }, + "./loaders/args": { + "types": "./dist/loaders/args.d.ts", + "import": "./dist/loaders/args.js" + }, + "./loaders/argv": { + "types": "./dist/loaders/argv.d.ts", + "import": "./dist/loaders/argv.js" + }, + "./loaders/artifact": { + "types": "./dist/loaders/artifact.d.ts", + "import": "./dist/loaders/artifact.js" + }, + "./loaders/init-state": { + "types": "./dist/loaders/init-state.d.ts", + "import": "./dist/loaders/init-state.js" + }, + "./loaders/signing-key": { + "types": "./dist/loaders/signing-key.d.ts", + "import": "./dist/loaders/signing-key.js" + }, + "./providers/proof-server": { + "types": "./dist/providers/proof-server.d.ts", + "import": "./dist/providers/proof-server.js" + }, + "./run-deploy": { + "types": "./dist/runDeploy.d.ts", + "import": "./dist/runDeploy.js" + }, + "./wallet/handler": { + "types": "./dist/wallet/handler.d.ts", + "import": "./dist/wallet/handler.js" + }, + "./wallet/keystore": { + "types": "./dist/wallet/keystore.d.ts", + "import": "./dist/wallet/keystore.js" + }, + "./wallet/seeds": { + "types": "./dist/wallet/seeds.d.ts", + "import": "./dist/wallet/seeds.js" + }, + "./package.json": "./package.json" }, "files": [ "dist", @@ -35,8 +90,8 @@ }, "devDependencies": { "@tsconfig/node24": "^24.0.3", - "@types/node": "24.10.1", - "typescript": "^5.9.3", + "@types/node": "25.9.1", + "typescript": "^6.0.3", "vitest": "^4.1.6" }, "dependencies": { @@ -50,12 +105,10 @@ "@midnight-ntwrk/midnight-js-network-id": "4.1.0", "@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.0", "@midnight-ntwrk/midnight-js-types": "4.1.0", - "@midnight-ntwrk/midnight-js-utils": "4.1.0", "@midnight-ntwrk/testkit-js": "4.1.0", "@midnight-ntwrk/wallet-sdk-address-format": "3.1.1", "@midnight-ntwrk/wallet-sdk-dust-wallet": "4.0.0", "@midnight-ntwrk/wallet-sdk-facade": "4.0.0", - "@midnight-ntwrk/wallet-sdk-hd": "3.0.2", "@midnight-ntwrk/wallet-sdk-shielded": "3.0.0", "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "3.0.0", "@scure/bip39": "^1.2.1", diff --git a/packages/deployer/src/config/schema.test.ts b/packages/deployer/src/config/schema.test.ts index 86d086d..1a05910 100644 --- a/packages/deployer/src/config/schema.test.ts +++ b/packages/deployer/src/config/schema.test.ts @@ -76,14 +76,27 @@ describe('configSchema — networks', () => { ).toThrow(); }); - it('should clamp wallet.index to 0..3 only', () => { + it('should accept the highest prefunded wallet.index', () => { + const parsed = configSchema.parse({ + ...baseConfig, + networks: { + testnet: { + ...validNetwork, + wallet: { source: 'local', index: 4 }, + }, + }, + }); + expect(parsed.networks.testnet?.wallet?.index).toBe(4); + }); + + it('should clamp wallet.index to 0..4 only', () => { expect(() => configSchema.parse({ ...baseConfig, networks: { testnet: { ...validNetwork, - wallet: { source: 'local', index: 4 }, + wallet: { source: 'local', index: 5 }, }, }, }), diff --git a/packages/deployer/src/config/schema.ts b/packages/deployer/src/config/schema.ts index 495280d..e591e8b 100644 --- a/packages/deployer/src/config/schema.ts +++ b/packages/deployer/src/config/schema.ts @@ -18,7 +18,8 @@ const profileSchema = z const localWalletSchema = z.object({ source: z.literal('local'), - index: z.number().int().min(0).max(3).default(0), + // Upper bound matches LOCAL_PREFUNDED_SEEDS' five slots. + index: z.number().int().min(0).max(4).default(0), }); const networkSchema = z.object({ diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts index ff1e52c..2710c77 100644 --- a/packages/deployer/src/deployer.test.ts +++ b/packages/deployer/src/deployer.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; @@ -377,6 +377,28 @@ describe('Deployer', () => { expect(result.deploymentsFile).toContain('deployments'); }); + it('should keep the signing key out of the result and the written ledger', async () => { + const injected = fakeProvider('0xDEPLOYER'); + await using d = await Deployer.prepare({ + contract: 'Counter', + network: 'local', + configPath: fx.configPath, + logger: silentLogger, + walletProvider: asInjected(injected), + }); + const result = await d.deploy(); + + // The fixture's signing-key.hex; midnight-js persists it via + // privateStateProvider.setSigningKey, so nothing here should carry it. + const signingKeyHex = 'aa'.repeat(32); + expect(Object.keys(result)).not.toContain('signingKey'); + expect(JSON.stringify(result)).not.toContain(signingKeyHex); + + const ledger = readFileSync(result.deploymentsFile, 'utf8'); + expect(ledger).not.toContain('signingKey'); + expect(ledger).not.toContain(signingKeyHex); + }); + it('should adopt an injected walletProvider and not call WalletHandler.build', async () => { const injected = fakeProvider(); await using d = await Deployer.prepare({ diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index 30ee36b..fbd9203 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { shieldedToken, unshieldedToken } from '@midnight-ntwrk/ledger-v8'; import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; @@ -119,7 +120,6 @@ export interface DeployResult { txHash: string; txId: string; blockHeight: number; - signingKey: string; deployer: string; artifact: string; deploymentsFile: string; @@ -136,6 +136,12 @@ interface PreparedState { network: NetworkConfig; contract: ContractConfig; signingKey: SigningKey; + /** + * SHA-256 of the resolved seed: the secret material the default LevelDB + * private-state password derives from. `undefined` when the wallet was + * injected, in which case the caller must supply `privateStateProvider`. + */ + privateStateSecret: string | undefined; artifact: Artifact; args: ConstructorArgs; initialPrivateState: InitialPrivateState | undefined; @@ -201,6 +207,11 @@ export class Deployer implements AsyncDisposable { if (seedResolution) { logger.debug(`Resolved deployer seed from: ${seedResolution.origin}`); } + // Hashed here because `prepare` is the only scope holding the seed, and + // the private-state password must derive from secret material. + const privateStateSecret = seedResolution + ? createHash('sha256').update(seedResolution.seed.value).digest('hex') + : undefined; // Stack owns every resource acquired below. On any throw before // the final `stack.move()`, `await using` disposes them in reverse @@ -313,6 +324,7 @@ export class Deployer implements AsyncDisposable { network, contract, signingKey, + privateStateSecret, artifact, args, initialPrivateState, @@ -333,6 +345,7 @@ export class Deployer implements AsyncDisposable { contract: s.contract, zkConfigPath: s.artifact.zkConfigPath, privateStateProvider: s.opts.privateStateProvider, + privateStateSecret: s.privateStateSecret, }); const txResult = await executeDeploy({ providers, @@ -346,7 +359,6 @@ export class Deployer implements AsyncDisposable { const record = toDeploymentRecord({ deployTxData: txResult.deployTxData, - signingKey: s.signingKey.hex, deployer: s.deployer, artifact: s.contract.artifact, }); @@ -365,7 +377,6 @@ export class Deployer implements AsyncDisposable { txHash: record.txHash, txId: record.txId, blockHeight: record.blockHeight, - signingKey: record.signingKey, deployer: record.deployer, artifact: record.artifact, deploymentsFile: persisted.head, @@ -395,7 +406,6 @@ export class Deployer implements AsyncDisposable { txHash: '', txId: '', blockHeight: 0, - signingKey: s.signingKey.hex, deployer: s.deployer, artifact: s.contract.artifact, deploymentsFile: '', @@ -723,12 +733,10 @@ function buildExplorerUrl(base: string | undefined, address: string): string { function toDeploymentRecord({ deployTxData, - signingKey, deployer, artifact, }: { deployTxData: ContractDeployResult['deployTxData']; - signingKey: string; deployer: string; artifact: string; }): DeploymentRecord { @@ -737,7 +745,6 @@ function toDeploymentRecord({ txHash: deployTxData.public.txHash, txId: deployTxData.public.txId, blockHeight: deployTxData.public.blockHeight, - signingKey, deployer, artifact, timestamp: new Date().toISOString(), diff --git a/packages/deployer/src/deployments.test.ts b/packages/deployer/src/deployments.test.ts index 1ee9dd5..caeb80f 100644 --- a/packages/deployer/src/deployments.test.ts +++ b/packages/deployer/src/deployments.test.ts @@ -4,13 +4,15 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { type DeploymentRecord, Deployments } from './deployments.ts'; +/** Never persisted; the pinning test asserts the ledger file omits it. */ +const SIGNING_KEY_HEX = 'aa'.repeat(32); + function rec(address: string): DeploymentRecord { return { address, txHash: '0xhash', txId: '0xtx', blockHeight: 42, - signingKey: 'aa'.repeat(32), deployer: '0xdep', artifact: 'src/artifacts/Token/Token', timestamp: new Date('2026-05-15T00:00:00Z').toISOString(), @@ -57,6 +59,36 @@ describe('Deployments', () => { expect(headJson.Vault.address).toBe('0xV1'); }); + it('should never persist a signing key, in the head or in history', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const d = make(root); + await d.record('Token', rec('0xT1')); + const { head, history } = await d.record('Token', rec('0xT2')); + + for (const raw of [ + readFileSync(head, 'utf8'), + readFileSync(history, 'utf8'), + ]) { + expect(raw).not.toContain(SIGNING_KEY_HEX); + expect(raw).not.toContain('signingKey'); + } + }); + + it('should keep both records when two contracts are written concurrently', async () => { + const root = mkdtempSync(join(tmpdir(), 'persist-test-')); + const d = make(root); + // Same head file, interleaved read-modify-write: without the lock the + // later write clobbers the earlier contract's record. + const [{ head }] = await Promise.all([ + d.record('Token', rec('0xT1')), + d.record('Vault', rec('0xV1')), + ]); + + const headJson = JSON.parse(readFileSync(head, 'utf8')); + expect(headJson.Token.address).toBe('0xT1'); + expect(headJson.Vault.address).toBe('0xV1'); + }); + it('should honour an absolute deploymentsDir and expose paths', async () => { const absDir = mkdtempSync(join(tmpdir(), 'persist-abs-')); const d = new Deployments({ diff --git a/packages/deployer/src/deployments.ts b/packages/deployer/src/deployments.ts index e3db135..5a4383a 100644 --- a/packages/deployer/src/deployments.ts +++ b/packages/deployer/src/deployments.ts @@ -1,6 +1,13 @@ import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { + mkdir, + readFile, + rename, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; /** @@ -10,13 +17,17 @@ import { dirname, isAbsolute, resolve } from 'node:path'; * Each deploy rotates the prior head into history. */ -/** A single confirmed deploy. Persisted under the contract name in the head map. */ +/** + * A single confirmed deploy. Persisted under the contract name in the head + * map. Never carries the contract-maintenance signing key: midnight-js + * already persists it via `privateStateProvider.setSigningKey`, and this + * file is world-readable and routinely committed. + */ export interface DeploymentRecord { address: string; txHash: string; txId: string; blockHeight: number; - signingKey: string; deployer: string; artifact: string; timestamp: string; @@ -55,26 +66,37 @@ export class Deployments { return { head: this.#headPath, history: this.#historyPath }; } - /** Rotate the prior head for `contractName` into history; write `record` as new head. */ + /** + * Rotate the prior head for `contractName` into history; write `record` as + * new head. Serialised across processes by `.json.lock`: the whole + * body is a read-modify-write of two shared files, so concurrent deploys + * would otherwise drop each other's records. + */ async record( contractName: string, record: DeploymentRecord, ): Promise<{ head: string; history: string }> { await mkdir(dirname(this.#headPath), { recursive: true }); - const head = await this.#readHead(); - const previous = head[contractName]; - if (previous) { - const history = await this.#readHistory(); - const bucket = history[contractName] ?? []; - bucket.unshift(previous); - history[contractName] = bucket; - await writeJson(this.#historyPath, history); + const lockPath = `${this.#headPath}.lock`; + await acquireLock(lockPath); + try { + const head = await this.#readHead(); + const previous = head[contractName]; + if (previous) { + const history = await this.#readHistory(); + const bucket = history[contractName] ?? []; + bucket.unshift(previous); + history[contractName] = bucket; + await writeJson(this.#historyPath, history); + } + + head[contractName] = record; + await writeJson(this.#headPath, head); + } finally { + await releaseLock(lockPath); } - head[contractName] = record; - await writeJson(this.#headPath, head); - return { head: this.#headPath, history: this.#historyPath }; } @@ -102,6 +124,61 @@ export class Deployments { } } +/** A lock older than this is treated as abandoned by a crashed process. */ +const LOCK_STALE_MS = 30_000; +const LOCK_RETRY_MS = 25; +const LOCK_MAX_WAIT_MS = 10_000; + +/** + * Take `lockPath` exclusively via `open(O_CREAT|O_EXCL)`. Retries on EEXIST; + * unlinks and retries once the holder's mtime passes {@link LOCK_STALE_MS} + * so a killed deploy can't wedge the ledger permanently. + */ +async function acquireLock(lockPath: string): Promise { + const deadline = Date.now() + LOCK_MAX_WAIT_MS; + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { flag: 'wx' }); + return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; + if (await breakIfStale(lockPath)) continue; + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for the deployments lock at ${lockPath}. Remove it if no deploy is running.`, + ); + } + await delay(LOCK_RETRY_MS); + } + } +} + +/** Unlink `lockPath` if its mtime is older than {@link LOCK_STALE_MS}. Reports whether it did. */ +async function breakIfStale(lockPath: string): Promise { + try { + const { mtimeMs } = await stat(lockPath); + if (Date.now() - mtimeMs < LOCK_STALE_MS) return false; + await unlink(lockPath); + return true; + } catch { + // Holder released between our EEXIST and the stat/unlink; the next + // acquire attempt will win the race normally. + return false; + } +} + +async function releaseLock(lockPath: string): Promise { + try { + await unlink(lockPath); + } catch { + // Already gone (stale-broken by another waiter). Nothing to release. + } +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + async function readJson(path: string, fallback: T): Promise { if (!existsSync(path)) return fallback; const raw = await readFile(path, 'utf8'); diff --git a/packages/deployer/src/index.ts b/packages/deployer/src/index.ts deleted file mode 100644 index c590e34..0000000 --- a/packages/deployer/src/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Programmatic API for `@openzeppelin/compact-deployer`; `compact-deploy` is an opinionated shell over this. */ -// biome-ignore-all lint/performance/noBarrelFile: this file is the programmatic API surface for consumers of @openzeppelin/compact-deployer -export { CompactConfig } from './config/compact-config.ts'; -export type { - ContractConfig, - NetworkConfig, - Profile, - WalletConfig, -} from './config/schema.ts'; -export type { DeployerOptions, DeployResult } from './deployer.ts'; -export { Deployer } from './deployer.ts'; -export type { - DeploymentRecord, - DeploymentsFile, - DeploymentsHistory, -} from './deployments.ts'; -export { Deployments } from './deployments.ts'; -export { - ArtifactNotFoundError, - ConfigError, - DeployError, - DeployTxFailedError, - IndexerUnreachableError, - ProofServerUnreachableError, - UnfundedWalletError, - WalletError, -} from './errors.ts'; -export type { ArgsSource } from './loaders/args.ts'; -export { ConstructorArgs } from './loaders/args.ts'; -export type { LoadArtifactOptions } from './loaders/artifact.ts'; -export { Artifact } from './loaders/artifact.ts'; -export { InitialPrivateState } from './loaders/init-state.ts'; -export { SigningKey } from './loaders/signing-key.ts'; -export { ProofServer } from './providers/proof-server.ts'; -export type { - CompactContractClass, - ConstructorArgsOf, - RunDeployOptions, -} from './runDeploy.ts'; -export { constructorArgs, runDeploy } from './runDeploy.ts'; -export { WalletHandler } from './wallet/handler.ts'; -export type { MidnightKeystore } from './wallet/keystore.ts'; -export { Keystore } from './wallet/keystore.ts'; -export type { WalletSeed } from './wallet/seeds.ts'; -export { - classifySeed, - LOCAL_PREFUNDED_SEEDS, - localPrefundedSeed, -} from './wallet/seeds.ts'; diff --git a/packages/deployer/src/loaders/argv.test.ts b/packages/deployer/src/loaders/argv.test.ts new file mode 100644 index 0000000..07508ca --- /dev/null +++ b/packages/deployer/src/loaders/argv.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { parseDeployArgv } from './argv.ts'; + +describe('parseDeployArgv', () => { + it('should default every flag to off', () => { + expect(parseDeployArgv([])).toEqual({ + noCache: false, + dryRun: false, + json: false, + verbose: false, + help: false, + version: false, + positional: [], + }); + }); + + it('should parse every value flag', () => { + const p = parseDeployArgv([ + '--network', + 'preview', + '--config', + './compact.toml', + '--seed-file', + './seed.hex', + '--proof-server', + 'http://proof:6300', + '--sync-timeout', + '900', + '--sync-batch-size', + '2500', + '--seed-cache-from-dust', + './dust.gz', + '--seed-cache-from-shielded', + './shielded.gz', + ]); + expect(p.network).toBe('preview'); + expect(p.configPath).toBe('./compact.toml'); + expect(p.seedFile).toBe('./seed.hex'); + expect(p.proofServer).toBe('http://proof:6300'); + expect(p.syncTimeoutSec).toBe(900); + expect(p.syncBatchSize).toBe(2500); + expect(p.seedCacheFromDust).toBe('./dust.gz'); + expect(p.seedCacheFromShielded).toBe('./shielded.gz'); + }); + + it('should parse every boolean flag and its shorthand', () => { + const p = parseDeployArgv([ + '--json', + '--dry-run', + '--no-cache', + '-v', + '-h', + '--version', + ]); + expect(p).toMatchObject({ + json: true, + dryRun: true, + noCache: true, + verbose: true, + help: true, + version: true, + }); + }); + + it('should collect non-flag arguments as positionals in order', () => { + expect(parseDeployArgv(['Token', 'extra']).positional).toEqual([ + 'Token', + 'extra', + ]); + }); + + it('should treat an unrecognised single-dash arg as positional', () => { + expect(parseDeployArgv(['-x'], { rejectUnknownFlags: true })).toMatchObject( + { positional: ['-x'] }, + ); + }); + + it('should reject an unknown --flag only under rejectUnknownFlags', () => { + // The CLI is the whole program, so a typo is an error. The library + // coexists with extra argv injected by a caller's wrapper script. + expect(() => + parseDeployArgv(['--nope'], { rejectUnknownFlags: true }), + ).toThrow(/Unknown flag: --nope/); + expect(parseDeployArgv(['--nope']).positional).toEqual([]); + }); + + it('should reject a value flag whose value is missing or looks like a flag', () => { + expect(() => parseDeployArgv(['--network'])).toThrow( + /--network requires a value/, + ); + expect(() => parseDeployArgv(['--network', '--json'])).toThrow( + /--network requires a value/, + ); + }); + + it('should reject a non-positive --sync-timeout and --sync-batch-size', () => { + expect(() => parseDeployArgv(['--sync-timeout', 'nope'])).toThrow( + /--sync-timeout requires a positive integer \(seconds\); got "nope"/, + ); + expect(() => parseDeployArgv(['--sync-batch-size', '0'])).toThrow( + /--sync-batch-size requires a positive integer; got "0"/, + ); + }); +}); diff --git a/packages/deployer/src/loaders/argv.ts b/packages/deployer/src/loaders/argv.ts new file mode 100644 index 0000000..098c8c9 --- /dev/null +++ b/packages/deployer/src/loaders/argv.ts @@ -0,0 +1,134 @@ +/** + * Deploy-flag parser shared by the library entrypoint (`runDeploy`) and the + * `compact-deploy` CLI, so the two can't drift on flag names or validation. + */ + +/** Flags recognised by both front-ends. */ +export interface ParsedDeployArgv { + network?: string; + configPath?: string; + seedFile?: string; + proofServer?: string; + syncTimeoutSec?: number; + syncBatchSize?: number; + seedCacheFromDust?: string; + seedCacheFromShielded?: string; + noCache: boolean; + dryRun: boolean; + json: boolean; + verbose: boolean; + help: boolean; + version: boolean; + /** Non-flag arguments, in order. The CLI reads the contract name from slot 0. */ + positional: string[]; +} + +export interface ParseDeployArgvOptions { + /** + * Throw on an unrecognised `--flag`. The CLI sets this; the library leaves + * it off so it coexists with extra argv a caller's wrapper script injects. + * Unrecognised single-dash args are positional under either setting. + */ + rejectUnknownFlags?: boolean; +} + +export function parseDeployArgv( + argv: string[], + opts: ParseDeployArgvOptions = {}, +): ParsedDeployArgv { + const out: ParsedDeployArgv = { + noCache: false, + dryRun: false, + json: false, + verbose: false, + help: false, + version: false, + positional: [], + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case '-h': + case '--help': + out.help = true; + break; + case '--version': + out.version = true; + break; + case '-v': + case '--verbose': + out.verbose = true; + break; + case '--json': + out.json = true; + break; + case '--dry-run': + out.dryRun = true; + break; + case '--no-cache': + out.noCache = true; + break; + case '--seed-cache-from-dust': + out.seedCacheFromDust = expectValue( + argv, + ++i, + '--seed-cache-from-dust', + ); + break; + case '--seed-cache-from-shielded': + out.seedCacheFromShielded = expectValue( + argv, + ++i, + '--seed-cache-from-shielded', + ); + break; + case '--network': + out.network = expectValue(argv, ++i, '--network'); + break; + case '--config': + out.configPath = expectValue(argv, ++i, '--config'); + break; + case '--seed-file': + out.seedFile = expectValue(argv, ++i, '--seed-file'); + break; + case '--proof-server': + out.proofServer = expectValue(argv, ++i, '--proof-server'); + break; + case '--sync-timeout': + out.syncTimeoutSec = expectPositiveInt( + expectValue(argv, ++i, '--sync-timeout'), + '--sync-timeout', + ' (seconds)', + ); + break; + case '--sync-batch-size': + out.syncBatchSize = expectPositiveInt( + expectValue(argv, ++i, '--sync-batch-size'), + '--sync-batch-size', + ); + break; + default: + if (opts.rejectUnknownFlags === true && arg.startsWith('--')) { + throw new Error(`Unknown flag: ${arg}`); + } + if (!arg.startsWith('--')) out.positional.push(arg); + } + } + return out; +} + +function expectValue(argv: string[], i: number, flag: string): string { + const v = argv[i]; + if (v === undefined || v.startsWith('-')) { + throw new Error(`${flag} requires a value`); + } + return v; +} + +function expectPositiveInt(raw: string, flag: string, unit = ''): number { + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`${flag} requires a positive integer${unit}; got "${raw}"`); + } + return n; +} diff --git a/packages/deployer/src/loaders/artifact-path.ts b/packages/deployer/src/loaders/artifact-path.ts new file mode 100644 index 0000000..09ca189 --- /dev/null +++ b/packages/deployer/src/loaders/artifact-path.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; + +/** + * Artifact-directory resolution shared by the artifact loader and the + * `Contract`-class resolver, so the two agree on which directory a TOML + * `artifact` string names and which file is its entry point. + */ + +/** + * Resolve a TOML `artifact` value to a directory. Relative values are tried + * against `rootDir` first so a path that already includes the artifacts + * directory works, then under `artifactsDir`. + */ +export function resolveUnderRoot( + rootDir: string, + artifact: string, + artifactsDir: string, +): string { + if (isAbsolute(artifact)) return artifact; + const direct = resolve(rootDir, artifact); + if (existsSync(direct)) return direct; + return resolve(rootDir, artifactsDir, artifact); +} + +/** + * First existing `index.{cjs,js}` under `/contract` then + * ``. `.cjs` wins because compactc emits CommonJS. + */ +export function findArtifactEntry(artifactDir: string): string | undefined { + const contractDir = resolve(artifactDir, 'contract'); + const candidates = [ + resolve(contractDir, 'index.cjs'), + resolve(contractDir, 'index.js'), + resolve(artifactDir, 'index.cjs'), + resolve(artifactDir, 'index.js'), + ]; + return candidates.find(existsSync); +} diff --git a/packages/deployer/src/loaders/artifact.ts b/packages/deployer/src/loaders/artifact.ts index 43206c8..0d25959 100644 --- a/packages/deployer/src/loaders/artifact.ts +++ b/packages/deployer/src/loaders/artifact.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from 'node:fs'; -import { dirname, isAbsolute, resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { CompiledContract, type Contract } from '@midnight-ntwrk/compact-js'; import type { Types } from 'effect'; import { @@ -8,6 +8,7 @@ import { isModuleRef, } from '../config/schema.ts'; import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; +import { findArtifactEntry, resolveUnderRoot } from './artifact-path.ts'; import { LoaderContext } from './context.ts'; /** @@ -62,14 +63,14 @@ export class Artifact { throw new ArtifactNotFoundError(artifactPath); } - const entry = findEntry(resolve(artifactPath, 'contract'), artifactPath); + const entry = findArtifactEntry(artifactPath); if (!entry) { throw new ArtifactNotFoundError( `${artifactPath} (no contract/index.{cjs,js} or index.{cjs,js} found)`, ); } // Bind compiled assets to the directory the entry actually lives in: - // `findEntry` may resolve a top-level `index.{cjs,js}` rather than the + // `findArtifactEntry` may resolve a top-level `index.{cjs,js}` rather than the // `contract/` subdir, in which case the hardcoded path would be wrong. const contractDir = dirname(entry); @@ -162,30 +163,6 @@ interface ArtifactModule { default?: { Contract?: Types.Ctor }; } -function resolveUnderRoot( - rootDir: string, - artifact: string, - artifactsDir: string, -): string { - if (isAbsolute(artifact)) return artifact; - const direct = resolve(rootDir, artifact); - if (existsSync(direct)) return direct; - return resolve(rootDir, artifactsDir, artifact); -} - -function findEntry( - contractDir: string, - artifactDir: string, -): string | undefined { - const candidates = [ - resolve(contractDir, 'index.cjs'), - resolve(contractDir, 'index.js'), - resolve(artifactDir, 'index.cjs'), - resolve(artifactDir, 'index.js'), - ]; - return candidates.find(existsSync); -} - function collectCircuitNames(zkirDir: string): string[] { return readdirSync(zkirDir) .filter((f) => f.endsWith('.bzkir')) diff --git a/packages/deployer/src/loaders/contract-resolve.ts b/packages/deployer/src/loaders/contract-resolve.ts index 66c0af4..c84425e 100644 --- a/packages/deployer/src/loaders/contract-resolve.ts +++ b/packages/deployer/src/loaders/contract-resolve.ts @@ -1,7 +1,6 @@ -import { existsSync } from 'node:fs'; -import { isAbsolute, resolve } from 'node:path'; import type { CompactConfig } from '../config/compact-config.ts'; import { ConfigError } from '../errors.ts'; +import { findArtifactEntry, resolveUnderRoot } from './artifact-path.ts'; import { LoaderContext } from './context.ts'; /** @@ -28,7 +27,9 @@ export async function resolveContractName( for (const name of config.listContracts()) { const cfg = config.contract(name); - const entry = findContractEntry(rootDir, config.artifactsDir, cfg.artifact); + const entry = findArtifactEntry( + resolveUnderRoot(rootDir, cfg.artifact, config.artifactsDir), + ); if (!entry) { tried.push({ name, @@ -63,30 +64,3 @@ export async function resolveContractName( `Contract class did not match any [contracts.X] entry in compact.toml. Make sure the import path resolves to the same artifact directory referenced by the TOML.${tail}`, ); } - -function findContractEntry( - rootDir: string, - artifactsDir: string, - artifact: string, -): string | undefined { - const artifactPath = resolveUnderRoot(rootDir, artifact, artifactsDir); - const contractDir = resolve(artifactPath, 'contract'); - const candidates = [ - resolve(contractDir, 'index.cjs'), - resolve(contractDir, 'index.js'), - resolve(artifactPath, 'index.cjs'), - resolve(artifactPath, 'index.js'), - ]; - return candidates.find(existsSync); -} - -function resolveUnderRoot( - rootDir: string, - artifact: string, - artifactsDir: string, -): string { - if (isAbsolute(artifact)) return artifact; - const direct = resolve(rootDir, artifact); - if (existsSync(direct)) return direct; - return resolve(rootDir, artifactsDir, artifact); -} diff --git a/packages/deployer/src/providers/build.test.ts b/packages/deployer/src/providers/build.test.ts index eaa6a4c..0da4796 100644 --- a/packages/deployer/src/providers/build.test.ts +++ b/packages/deployer/src/providers/build.test.ts @@ -4,6 +4,8 @@ import type { } from '@midnight-ntwrk/testkit-js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ContractConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; +import { derivePrivateStatePassword } from './private-state-password.ts'; vi.mock('@midnight-ntwrk/midnight-js-http-client-proof-provider', () => ({ httpClientProofProvider: vi.fn((url: string) => ({ kind: 'proof', url })), @@ -66,6 +68,9 @@ const wallet = { getCoinPublicKey: vi.fn(() => 'coin-pubkey-def'), } as unknown as MidnightWalletProvider; +/** Stand-in for SHA-256 of a resolved seed; only its secrecy matters. */ +const SECRET = 'a1'.repeat(32); + const baseContract: ContractConfig = { artifact: 'src/artifacts/Counter', signing_key_file: 'keys/counter.signing', @@ -83,6 +88,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; @@ -96,6 +102,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: { ...baseContract, private_state_store_name: 'custom-store' }, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; @@ -109,22 +116,44 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0]; expect(opts?.accountId).toBe('coin-pubkey-def'); }); - it('should derive the private-state password from the wallet encryption pubkey', () => { + it('should derive the private-state password from the secret, never the wallet encryption pubkey', async () => { buildProviders({ env, wallet, contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); - expect(wallet.getEncryptionPublicKey).toHaveBeenCalledOnce(); + expect(wallet.getEncryptionPublicKey).not.toHaveBeenCalled(); + + const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0] as { + privateStoragePasswordProvider: () => string | Promise; + }; + expect(await opts.privateStoragePasswordProvider()).toBe( + derivePrivateStatePassword(SECRET), + ); + }); + + it('should reject an injected wallet with neither a secret nor a privateStateProvider', () => { + expect(() => + buildProviders({ + env, + wallet, + contractName: 'Counter', + contract: baseContract, + zkConfigPath: '/artifacts/Counter', + }), + ).toThrow(ConfigError); + expect(levelPrivateStateProvider).not.toHaveBeenCalled(); }); it('should expose a privateStoragePasswordProvider that returns the derived password', async () => { @@ -134,6 +163,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); const opts = vi.mocked(levelPrivateStateProvider).mock.calls[0]?.[0] as { @@ -151,6 +181,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); expect(NodeZkConfigProvider).toHaveBeenCalledWith('/artifacts/Counter'); @@ -163,6 +194,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); expect(indexerPublicDataProvider).toHaveBeenCalledWith( @@ -178,6 +210,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); const firstArg = vi.mocked(httpClientProofProvider).mock.calls[0]?.[0]; @@ -191,6 +224,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); expect(providers.walletProvider).toBe(wallet); @@ -225,6 +259,7 @@ describe('buildProviders', () => { contractName: 'Counter', contract: baseContract, zkConfigPath: '/artifacts/Counter', + privateStateSecret: SECRET, }); expect(Object.keys(providers).sort()).toEqual( diff --git a/packages/deployer/src/providers/build.ts b/packages/deployer/src/providers/build.ts index 372f0f7..d793442 100644 --- a/packages/deployer/src/providers/build.ts +++ b/packages/deployer/src/providers/build.ts @@ -11,6 +11,7 @@ import type { MidnightWalletProvider, } from '@midnight-ntwrk/testkit-js'; import type { ContractConfig } from '../config/schema.ts'; +import { ConfigError } from '../errors.ts'; import { derivePrivateStatePassword } from './private-state-password.ts'; export interface BuildProvidersOptions { @@ -21,6 +22,11 @@ export interface BuildProvidersOptions { zkConfigPath: string; /** Inject `inMemoryPrivateStateProvider` in tests to avoid LevelDB file-lock contention. */ privateStateProvider?: PrivateStateProvider; + /** + * Secret material for the default LevelDB password (SHA-256 of the wallet + * seed). Required unless {@link privateStateProvider} is supplied. + */ + privateStateSecret?: string; } export function buildProviders({ @@ -30,12 +36,18 @@ export function buildProviders({ contract, zkConfigPath, privateStateProvider, + privateStateSecret, }: BuildProvidersOptions): MidnightProviders { const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath); const resolvedPrivateStateProvider: PrivateStateProvider = privateStateProvider ?? - defaultLevelPrivateStateProvider(wallet, contract, contractName); + defaultLevelPrivateStateProvider( + wallet, + contract, + contractName, + privateStateSecret, + ); return { privateStateProvider: resolvedPrivateStateProvider, @@ -51,8 +63,17 @@ function defaultLevelPrivateStateProvider( wallet: MidnightWalletProvider, contract: ContractConfig, contractName: string, + privateStateSecret: string | undefined, ): PrivateStateProvider { - const password = derivePrivateStatePassword(wallet.getEncryptionPublicKey()); + if (!privateStateSecret) { + // Reached only with an injected wallet and no injected provider. There is + // no seed to derive from, and falling back to wallet public-key material + // would leave the private-state DB readable by anyone holding the address. + throw new ConfigError( + 'Cannot derive a private-state DB password without a deployer seed. Pass `privateStateProvider` alongside `walletProvider`.', + ); + } + const password = derivePrivateStatePassword(privateStateSecret); return levelPrivateStateProvider({ privateStateStoreName: contract.private_state_store_name ?? `${contractName}-private-state`, diff --git a/packages/deployer/src/providers/private-state-password.ts b/packages/deployer/src/providers/private-state-password.ts index 98a890e..868fa98 100644 --- a/packages/deployer/src/providers/private-state-password.ts +++ b/packages/deployer/src/providers/private-state-password.ts @@ -1,18 +1,20 @@ import { createHash } from 'node:crypto'; /** - * Derive a leveldb-compatible password from the wallet's encryption key. - * level-private-state-provider rejects passwords with 4+ identical chars - * in a row, which structured seeds (TEST_MNEMONIC, `0x…0001`) routinely + * Derive a leveldb-compatible password from secret key material. `secret` + * must be secret-derived (the deployer passes SHA-256 of the wallet seed); + * a public key would make the private-state DB decryptable by anyone who + * can read the wallet's address. + * + * level-private-state-provider rejects passwords with 4+ identical chars in + * a row, which structured seeds (TEST_MNEMONIC, `0x…0001`) routinely * produce. We SHA-256 + base64url + strip + rehash-on-collision until clean, * then append `A1!` for guaranteed character-class diversity. */ -export function derivePrivateStatePassword( - encryptionPublicKey: string, -): string { +export function derivePrivateStatePassword(secret: string): string { for (let counter = 0; counter < 1024; counter++) { const body = createHash('sha256') - .update(`${encryptionPublicKey}:${counter}`) + .update(`${secret}:${counter}`) .digest('base64url') .replace(/[^A-Za-z0-9]/g, ''); if (!/(.)\1{3,}/.test(body)) { diff --git a/packages/deployer/src/runDeploy.test.ts b/packages/deployer/src/runDeploy.test.ts index 8fa37ba..c1847fa 100644 --- a/packages/deployer/src/runDeploy.test.ts +++ b/packages/deployer/src/runDeploy.test.ts @@ -5,6 +5,9 @@ import { DeployError } from './errors.ts'; import * as contractResolveModule from './loaders/contract-resolve.ts'; import { constructorArgs, runDeploy } from './runDeploy.ts'; +/** Never appears in a DeployResult; the pinning test asserts its absence. */ +const SIGNING_KEY_HEX = 'ab'.repeat(32); + function fakeDeployResult(overrides: Record = {}) { return { contractName: 'X', @@ -13,7 +16,6 @@ function fakeDeployResult(overrides: Record = {}) { txHash: '0xtx', txId: 'tx-id', blockHeight: 42, - signingKey: '0xsk', deployer: '0xdep', artifact: 'X', deploymentsFile: '/tmp/local.json', @@ -37,11 +39,13 @@ function fakeDeployer( } let originalArgv: string[]; +let originalExitCode: typeof process.exitCode; let exitSpy: ReturnType; let writeSpy: ReturnType; beforeEach(() => { originalArgv = process.argv; + originalExitCode = process.exitCode; exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`process.exit(${code ?? 0})`); }) as never); @@ -50,6 +54,9 @@ beforeEach(() => { afterEach(() => { process.argv = originalArgv; + // runDeploy sets process.exitCode on failure; left set, it would fail the + // whole vitest run even with every test green. + process.exitCode = originalExitCode; vi.restoreAllMocks(); }); @@ -98,6 +105,41 @@ describe('runDeploy', () => { ).toBe(120_000); }); + it('should thread --sync-batch-size to Deployer.prepare', async () => { + process.argv = ['node', 'script.ts', '--sync-batch-size', '2500']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X' }); + + expect( + (deployerModule.Deployer.prepare as any).mock.calls[0][0].syncBatchSize, + ).toBe(2500); + }); + + it('should let an explicit syncBatchSize opt beat the argv value', async () => { + process.argv = ['node', 'script.ts', '--sync-batch-size', '2500']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + await runDeploy({ contract: 'X', syncBatchSize: 9000 }); + + expect( + (deployerModule.Deployer.prepare as any).mock.calls[0][0].syncBatchSize, + ).toBe(9000); + }); + + it('should reject a non-positive --sync-batch-size', async () => { + process.argv = ['node', 'script.ts', '--sync-batch-size', '0']; + await expect(runDeploy({ contract: 'X' })).rejects.toThrow( + /--sync-batch-size requires a positive integer/, + ); + }); + it('should reject a non-positive --sync-timeout', async () => { process.argv = ['node', 'script.ts', '--sync-timeout', 'nope']; await expect(runDeploy({ contract: 'X' })).rejects.toThrow( @@ -167,15 +209,43 @@ describe('runDeploy', () => { expect(parsed.address).toBe('0xaddr'); }); - it('should emit JSON error + exit with DeployError.exitCode in --json mode', async () => { + it('should keep the contract signing key out of the --json stdout object', async () => { process.argv = ['node', 'script.ts', '--json']; - vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( - new DeployError('boom', 3), + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, ); - await expect(runDeploy({ contract: 'X' })).rejects.toThrow( - 'process.exit(3)', + await runDeploy({ contract: 'X' }); + + const written = writeSpy.mock.calls[0]?.[0] as string; + expect(written).not.toContain(SIGNING_KEY_HEX); + expect(Object.keys(JSON.parse(written))).not.toContain('signingKey'); + }); + + it('should write nothing but the result object to stdout with the default logger', async () => { + process.argv = ['node', 'script.ts', '--json', '--verbose']; + const fakeDep = fakeDeployer(); + vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( + fakeDep as never, + ); + + // No `logger` opt: exercises buildLogger, which must target stderr. + await runDeploy({ contract: 'X' }); + + expect(writeSpy.mock.calls).toHaveLength(1); + expect(JSON.parse(writeSpy.mock.calls[0]?.[0] as string).contractName).toBe( + 'X', ); + }); + + it('should emit JSON error + set exitCode from DeployError in --json mode', async () => { + process.argv = ['node', 'script.ts', '--json']; + const err = new DeployError('boom', 3); + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue(err); + + await expect(runDeploy({ contract: 'X' })).rejects.toBe(err); + expect(process.exitCode).toBe(3); const written = writeSpy.mock.calls[0]?.[0] as string; const parsed = JSON.parse(written); @@ -184,16 +254,23 @@ describe('runDeploy', () => { expect(parsed.exitCode).toBe(3); }); - it('should exit with code 1 on non-DeployError', async () => { + it('should set exitCode 1 and rethrow on a non-DeployError', async () => { + process.argv = ['node', 'script.ts']; + const err = new Error('generic'); + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue(err); + + await expect(runDeploy({ contract: 'X' })).rejects.toBe(err); + expect(process.exitCode).toBe(1); + }); + + it('should never call process.exit — an embedding app keeps control', async () => { process.argv = ['node', 'script.ts']; vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( - new Error('generic'), + new DeployError('boom', 4), ); - await expect(runDeploy({ contract: 'X' })).rejects.toThrow( - 'process.exit(1)', - ); - expect(exitSpy).toHaveBeenCalledWith(1); + await expect(runDeploy({ contract: 'X' })).rejects.toThrow('boom'); + expect(exitSpy).not.toHaveBeenCalled(); }); it('should pass constructorArgs(Contract, ...) tuple through to Deployer.prepare', async () => { @@ -352,7 +429,7 @@ describe('runDeploy', () => { await expect( runDeploy({ contract: 'X', logger: logger as never }), - ).rejects.toThrow('process.exit(1)'); + ).rejects.toThrow('boom'); expect(logger.debug).toHaveBeenCalledWith('Error: boom\n at trace'); }); diff --git a/packages/deployer/src/runDeploy.ts b/packages/deployer/src/runDeploy.ts index da4210c..1650fd4 100644 --- a/packages/deployer/src/runDeploy.ts +++ b/packages/deployer/src/runDeploy.ts @@ -1,7 +1,8 @@ -import { type Logger, pino } from 'pino'; +import pino, { type Logger } from 'pino'; import { CompactConfig } from './config/compact-config.ts'; import { Deployer, type DeployResult } from './deployer.ts'; import { DeployError } from './errors.ts'; +import { parseDeployArgv } from './loaders/argv.ts'; import { resolveContractName } from './loaders/contract-resolve.ts'; /** @@ -25,7 +26,7 @@ export type CompactContractClass = { * @example * ```ts * import type { Contract } from '../artifacts/Token/contract/index.js'; - * import { runDeploy, type ConstructorArgsOf } from '@openzeppelin/compact-deployer'; + * import { runDeploy, type ConstructorArgsOf } from '@openzeppelin/compact-deployer/run-deploy'; * * await runDeploy>({ * contract: 'Token', @@ -52,7 +53,7 @@ export type ConstructorArgsOf = * * @example * ```ts - * import { runDeploy, constructorArgs } from '@openzeppelin/compact-deployer'; + * import { runDeploy, constructorArgs } from '@openzeppelin/compact-deployer/run-deploy'; * import { Contract } from '../artifacts/Token/contract/index.js'; * * await runDeploy({ @@ -111,6 +112,8 @@ export interface RunDeployOptions< proofServer?: string; /** Wallet-sync timeout in seconds. Argv: `--sync-timeout`. */ syncTimeoutSec?: number; + /** Dust/shielded sync batch size. Argv: `--sync-batch-size`. Default 5000. */ + syncBatchSize?: number; /** Skip the on-disk wallet-state cache. Argv: `--no-cache`. */ skipWalletCache?: boolean; /** @@ -157,17 +160,18 @@ export interface RunDeployOptions< * await runDeploy({ contract: 'TokenExample', args: [...] }); * ``` * - * Parses `--network`, `--dry-run`, `--sync-timeout`, `--no-cache`, - * `--seed-cache-from-dust`, `--seed-cache-from-shielded`, + * Parses `--network`, `--dry-run`, `--sync-timeout`, `--sync-batch-size`, + * `--no-cache`, `--seed-cache-from-dust`, `--seed-cache-from-shielded`, * `--seed-file`, `--proof-server`, `--config`, `--json`, `-v` / * `--verbose` from `process.argv` as defaults. Explicit options win - * over argv. Wires a pino logger, runs Deployer.prepare + deploy or - * dryRun, prints the result, and exits with the typed exit code on - * error. + * over argv. Wires a pino logger (stderr), runs Deployer.prepare + + * deploy or dryRun, and prints the result. * * Returns the result on success so callers can chain post-deploy work - * (e.g. seeding state via callTx). On error: logs and calls - * `process.exit` — never returns to the caller. + * (e.g. seeding state via callTx). On error: logs, sets `process.exitCode` + * to the typed exit code, and rethrows the original error. Never calls + * `process.exit`, so an embedding app keeps control; catch the rethrow to + * let that exit code reach the shell. */ export function runDeploy( Contract: C, @@ -209,7 +213,7 @@ async function runDeployImpl( opts: RunDeployOptions, Contract?: CompactContractClass, ): Promise { - const argv = parseArgv(process.argv.slice(2)); + const argv = parseDeployArgv(process.argv.slice(2)); const json = opts.json ?? argv.json; const verbose = opts.verbose ?? argv.verbose; const dryRun = opts.dryRun ?? argv.dryRun; @@ -238,6 +242,7 @@ async function runDeployImpl( args: opts.args, syncTimeoutMs: syncTimeoutSec !== undefined ? syncTimeoutSec * 1000 : undefined, + syncBatchSize: opts.syncBatchSize ?? argv.syncBatchSize, skipWalletCache: opts.skipWalletCache ?? argv.noCache, seedCacheDust: opts.seedCacheFromDust ?? argv.seedCacheFromDust, seedCacheShielded: @@ -251,7 +256,6 @@ async function runDeployImpl( return result; } catch (e) { handleError(e, { json, verbose, logger }); - throw e; // unreachable — handleError calls process.exit } } @@ -259,103 +263,15 @@ async function runDeployImpl( // Helpers // --------------------------------------------------------------------------- -interface ParsedArgv { - network?: string; - configPath?: string; - seedFile?: string; - proofServer?: string; - syncTimeoutSec?: number; - seedCacheFromDust?: string; - seedCacheFromShielded?: string; - noCache: boolean; - dryRun: boolean; - json: boolean; - verbose: boolean; -} - -function parseArgv(argv: string[]): ParsedArgv { - const out: ParsedArgv = { - noCache: false, - dryRun: false, - json: false, - verbose: false, - }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i] as string; - switch (arg) { - case '-v': - case '--verbose': - out.verbose = true; - break; - case '--json': - out.json = true; - break; - case '--dry-run': - out.dryRun = true; - break; - case '--no-cache': - out.noCache = true; - break; - case '--seed-cache-from-dust': - out.seedCacheFromDust = expectValue( - argv, - ++i, - '--seed-cache-from-dust', - ); - break; - case '--seed-cache-from-shielded': - out.seedCacheFromShielded = expectValue( - argv, - ++i, - '--seed-cache-from-shielded', - ); - break; - case '--network': - out.network = expectValue(argv, ++i, '--network'); - break; - case '--config': - out.configPath = expectValue(argv, ++i, '--config'); - break; - case '--seed-file': - out.seedFile = expectValue(argv, ++i, '--seed-file'); - break; - case '--proof-server': - out.proofServer = expectValue(argv, ++i, '--proof-server'); - break; - case '--sync-timeout': { - const raw = expectValue(argv, ++i, '--sync-timeout'); - const seconds = Number.parseInt(raw, 10); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error( - `--sync-timeout requires a positive integer (seconds); got "${raw}"`, - ); - } - out.syncTimeoutSec = seconds; - break; - } - default: - // Unknown flags are ignored so the helper coexists with extra - // argv the caller's wrapper script may inject. - break; - } - } - return out; -} - -function expectValue(argv: string[], i: number, flag: string): string { - const v = argv[i]; - if (v === undefined || v.startsWith('-')) { - throw new Error(`${flag} requires a value`); - } - return v; -} - function buildLogger(opts: { json: boolean; verbose: boolean }): Logger { - // JSON mode keeps stdout machine-parseable, so logger writes to stderr. - // Default mode goes to stdout with the standard pino-pretty fallback. - return pino({ - level: opts.verbose ? 'debug' : 'info', - }); + // Always stderr: `--json` reserves stdout for the single result object, + // and a deploy script's log lines must not corrupt a caller's pipe. + return pino( + { + level: opts.verbose ? 'debug' : 'info', + }, + pino.destination(2), + ); } function printResult( @@ -384,6 +300,14 @@ function printResult( } } +/** + * Report the failure and set the process exit code, then rethrow the + * original error. Deliberately does not call `process.exit`: `runDeploy` is + * a library entrypoint, and a script embedding it must keep its own cleanup + * and error handling. `code` reaches the shell only for a caller that + * catches and lets the process end normally; an uncaught rethrow takes + * Node's unhandled-rejection path and exits 1. + */ function handleError( e: unknown, opts: { json: boolean; verbose: boolean; logger: Logger }, @@ -401,5 +325,6 @@ function handleError( opts.logger.debug(e.stack); } } - process.exit(code); + process.exitCode = code; + throw e; } diff --git a/packages/deployer/src/wallet/handler.test.ts b/packages/deployer/src/wallet/handler.test.ts index 33bed77..bdb4a65 100644 --- a/packages/deployer/src/wallet/handler.test.ts +++ b/packages/deployer/src/wallet/handler.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, copyFileSync, existsSync, mkdirSync, @@ -42,6 +43,7 @@ vi.mock('node:fs', async (importOriginal) => { renameSync: vi.fn(), copyFileSync: vi.fn(), mkdirSync: vi.fn(), + chmodSync: vi.fn(), }; }); @@ -412,6 +414,9 @@ describe('WalletHandler', () => { }); await handler.saveCache(); expect(save).toHaveBeenCalledWith(provider.wallet.shielded); + // WalletSaveStateProvider writes at the umask; the snapshot must end + // up owner-only. + expect(chmodSync).toHaveBeenCalledWith(expect.any(String), 0o600); }); }); @@ -456,6 +461,23 @@ describe('WalletHandler', () => { expect(payload).toEqual(gzipped); }); + it('should write the imported cache with mode 0o600', async () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + wireTestkitChain(fakeProvider()); + await WalletHandler.build( + logger, + fakeEnv(), + { kind: 'hex', value: 'aa'.repeat(32) }, + { seedCacheDust: '/state.json' }, + ); + // Owner-only from creation: the snapshot exposes the full UTXO set. + expect(writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + { mode: 0o600 }, + ); + }); + it('should ensure the .states/ directory exists before writing', async () => { vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); wireTestkitChain(fakeProvider()); diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts index 3d9867c..ed1b491 100644 --- a/packages/deployer/src/wallet/handler.ts +++ b/packages/deployer/src/wallet/handler.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { + chmodSync, copyFileSync, existsSync, mkdirSync, @@ -291,6 +292,9 @@ export class WalletHandler implements AsyncDisposable { filename, ); await saver.save(subWallet as Parameters[0]); + // WalletSaveStateProvider writes at the process umask and offers no + // mode control, so tighten the snapshot after the fact. + chmodSync(filePath, 0o600); } catch (e) { this.#logger.warn( { err: (e as Error).message, label, filePath }, @@ -364,7 +368,10 @@ function importSeedCache( if (backedUp) { copyFileSync(targetPath, backupPath); } - writeFileSync(tempPath, payload); + // 0o600: a wallet state snapshot exposes the full UTXO set and every + // derived address. Set at create time so the bytes are never group- or + // world-readable, not even between write and rename. + writeFileSync(tempPath, payload, { mode: 0o600 }); renameSync(tempPath, targetPath); logger.info( `Imported ${kind} cache: ${absoluteSrc} → ${targetPath}${ diff --git a/packages/deployer/src/wallet/keystore.test.ts b/packages/deployer/src/wallet/keystore.test.ts index 29b19d0..4e8b9d1 100644 --- a/packages/deployer/src/wallet/keystore.test.ts +++ b/packages/deployer/src/wallet/keystore.test.ts @@ -158,5 +158,63 @@ describe('Keystore', () => { /missing crypto section/, ); }); + + it('should reject a keystore missing kdfparams with WalletError', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const { kdfparams: _dropped, ...crypto } = ks.crypto; + const tampered = { ...ks, crypto } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(WalletError); + expect(() => Keystore.fromJSON(tampered)).toThrow(/kdfparams/); + }); + + it('should reject an absurd scrypt n that would exhaust memory', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { + ...ks.crypto, + kdfparams: { ...ks.crypto.kdfparams, n: 2 ** 30 }, + }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(WalletError); + }); + + it('should reject a scrypt n that is not a power of two', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { + ...ks.crypto, + kdfparams: { ...ks.crypto.kdfparams, n: 3000 }, + }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(/power of two/); + }); + + it('should reject a non-hex ciphertext', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { ...ks.crypto, ciphertext: 'zzzz' }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(/hex/); + }); + + it('should reject a dklen other than 32', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); + const tampered = { + ...ks, + crypto: { + ...ks.crypto, + kdfparams: { ...ks.crypto.kdfparams, dklen: 16 }, + }, + } as unknown as MidnightKeystore; + expect(() => Keystore.fromJSON(tampered)).toThrow(WalletError); + }); + + it('should round-trip a valid keystore through fromJSON', () => { + const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS); + expect(Keystore.fromJSON(ks.toJSON()).decrypt('pw')).toBe(SEED); + }); }); }); diff --git a/packages/deployer/src/wallet/keystore.ts b/packages/deployer/src/wallet/keystore.ts index deb71f0..f8fe66f 100644 --- a/packages/deployer/src/wallet/keystore.ts +++ b/packages/deployer/src/wallet/keystore.ts @@ -11,8 +11,10 @@ import { randomBytes, randomUUID, scryptSync, + timingSafeEqual, } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; +import { z } from 'zod'; import { WalletError } from '../errors.ts'; const VERSION = 'midnight-1'; @@ -45,6 +47,50 @@ const DEFAULTS: Required = { dklen: 32, }; +const hex = (bytes?: number) => + z + .string() + .refine( + (s) => + /^[0-9a-fA-F]*$/.test(s) && + s.length % 2 === 0 && + (bytes === undefined || s.length === bytes * 2), + bytes === undefined + ? 'expected a hex string' + : `expected ${bytes * 2} hex chars`, + ); + +/** + * Full on-disk shape. The scrypt bounds cap the work a hostile keystore can + * force: `n` above 2^20 at r=8 needs gigabytes and minutes per attempt, so an + * unbounded value is a denial of service on anyone who opens the file. `dklen` + * is fixed at 32 because {@link Keystore.decrypt} splits it 16/16 into the AES + * key and the MAC key. + */ +const keystoreSchema = z.object({ + version: z.literal(VERSION), + id: z.string().min(1), + crypto: z.object({ + cipher: z.literal('aes-128-ctr'), + ciphertext: hex(), + cipherparams: z.object({ iv: hex(16) }), + kdf: z.literal('scrypt'), + kdfparams: z.object({ + dklen: z.literal(32), + n: z + .number() + .int() + .min(1024) + .max(2 ** 20) + .refine((v) => (v & (v - 1)) === 0, 'expected a power of two'), + p: z.number().int().min(1).max(16), + r: z.number().int().min(1).max(32), + salt: hex(), + }), + mac: hex(32), + }), +}); + /** Encrypted wallet-seed wrapper; invariants enforced at construction. */ export class Keystore { readonly #data: MidnightKeystore; @@ -121,7 +167,11 @@ export class Keystore { return Keystore.fromJSON(parsed); } - /** Wrap parsed keystore JSON; validates shape + version/cipher/KDF eagerly. */ + /** + * Wrap parsed keystore JSON. Validates the complete shape, so `decrypt` + * can index into `crypto` without guards and never surfaces a raw + * `TypeError` from a hand-edited or truncated file. + */ static fromJSON(data: unknown): Keystore { if (!data || typeof data !== 'object') { throw new WalletError('Invalid keystore: expected an object'); @@ -130,7 +180,7 @@ export class Keystore { if (!d.crypto || typeof d.crypto !== 'object') { throw new WalletError('Invalid keystore: missing crypto section'); } - const crypto = d.crypto as MidnightKeystore['crypto']; + const crypto = d.crypto as Partial; if (d.version !== VERSION) { throw new WalletError( `Unsupported keystore version: ${String(d.version)} (expected ${VERSION})`, @@ -146,7 +196,15 @@ export class Keystore { `Unsupported cipher: ${String(crypto.cipher)} (expected aes-128-ctr)`, ); } - return new Keystore(d as MidnightKeystore); + const parsed = keystoreSchema.safeParse(data); + if (!parsed.success) { + throw new WalletError( + `Invalid keystore: ${parsed.error.issues + .map((i) => `${i.path.join('.') || ''}: ${i.message}`) + .join('; ')}`, + ); + } + return new Keystore(parsed.data as MidnightKeystore); } /** Recover the hex-encoded seed. Throws {@link WalletError} on MAC mismatch. */ @@ -169,8 +227,11 @@ export class Keystore { const cipherBytes = Buffer.from(ciphertext, 'hex'); const expectedMac = createHash('sha256') .update(Buffer.concat([macKey, cipherBytes])) - .digest('hex'); - if (expectedMac !== mac) { + .digest(); + // Constant-time: a length-dependent early exit would leak how much of a + // guessed passphrase's MAC matched. Lengths are equal by construction, + // `mac` being pinned to 32 bytes by the schema. + if (!timingSafeEqual(expectedMac, Buffer.from(mac, 'hex'))) { throw new WalletError( 'Keystore MAC mismatch (wrong passphrase or corrupted file)', ); diff --git a/yarn.lock b/yarn.lock index dc2eab8..336d4cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -249,12 +249,12 @@ __metadata: linkType: hard "@grpc/grpc-js@npm:^1.11.1": - version: 1.14.3 - resolution: "@grpc/grpc-js@npm:1.14.3" + version: 1.14.4 + resolution: "@grpc/grpc-js@npm:1.14.4" dependencies: "@grpc/proto-loader": "npm:^0.8.0" "@js-sdsl/ordered-map": "npm:^4.4.2" - checksum: 10/bb9bfe2f749179ae5ac7774d30486dfa2e0b004518c28de158b248e0f6f65f40138f01635c48266fa540670220f850216726e3724e1eb29d078817581c96e4db + checksum: 10/f9cdbd81e7388dc784c57274fcf6f4f4484da8968dd0975b97a14708d3fb117ae4a7bc2848e1bd1cc8b8ed9ee7a80ff131bfe728c85260da90a4e0b170e31ca9 languageName: node linkType: hard @@ -620,7 +620,7 @@ __metadata: languageName: node linkType: hard -"@midnight-ntwrk/wallet-sdk-hd@npm:3.0.2, @midnight-ntwrk/wallet-sdk-hd@npm:^3.0.2": +"@midnight-ntwrk/wallet-sdk-hd@npm:^3.0.2": version: 3.0.2 resolution: "@midnight-ntwrk/wallet-sdk-hd@npm:3.0.2" dependencies: @@ -922,23 +922,21 @@ __metadata: "@midnight-ntwrk/midnight-js-network-id": "npm:4.1.0" "@midnight-ntwrk/midnight-js-node-zk-config-provider": "npm:4.1.0" "@midnight-ntwrk/midnight-js-types": "npm:4.1.0" - "@midnight-ntwrk/midnight-js-utils": "npm:4.1.0" "@midnight-ntwrk/testkit-js": "npm:4.1.0" "@midnight-ntwrk/wallet-sdk-address-format": "npm:3.1.1" "@midnight-ntwrk/wallet-sdk-dust-wallet": "npm:4.0.0" "@midnight-ntwrk/wallet-sdk-facade": "npm:4.0.0" - "@midnight-ntwrk/wallet-sdk-hd": "npm:3.0.2" "@midnight-ntwrk/wallet-sdk-shielded": "npm:3.0.0" "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "npm:3.0.0" "@scure/bip39": "npm:^1.2.1" "@tsconfig/node24": "npm:^24.0.3" - "@types/node": "npm:24.10.1" + "@types/node": "npm:25.9.1" axios: "npm:^1.12.0" pino: "npm:^9.7.0" rxjs: "npm:^7.8.1" smol-toml: "npm:^1.3.4" testcontainers: "npm:^10.28.0" - typescript: "npm:^5.9.3" + typescript: "npm:^6.0.3" vitest: "npm:^4.1.6" zod: "npm:^3.23.8" languageName: unknown @@ -1471,10 +1469,10 @@ __metadata: languageName: node linkType: hard -"@protobufjs/eventemitter@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/eventemitter@npm:1.1.0" - checksum: 10/03af3e99f17ad421283d054c88a06a30a615922a817741b43ca1b13e7c6b37820a37f6eba9980fb5150c54dba6e26cb6f7b64a6f7d8afa83596fafb3afa218c3 +"@protobufjs/eventemitter@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/eventemitter@npm:1.1.1" + checksum: 10/a54dc1aff4475ffad4fdf3235c71a553f5e40e3b4cf6a2e217151895a61cb4eb0be20d63791db22441ca25e594671f1021977133f9939540750231ff7d8e9dd6 languageName: node linkType: hard @@ -1494,13 +1492,6 @@ __metadata: languageName: node linkType: hard -"@protobufjs/inquire@npm:^1.1.2": - version: 1.1.2 - resolution: "@protobufjs/inquire@npm:1.1.2" - checksum: 10/259756489c75a751552df60d18f82503d2534855646397b96b91cf15807fa852e99bd9eb73dabb64da37aec7913844032ecb031a4326d82aae622f5e4c2f8a17 - languageName: node - linkType: hard - "@protobufjs/path@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/path@npm:1.1.2" @@ -1516,9 +1507,9 @@ __metadata: linkType: hard "@protobufjs/utf8@npm:^1.1.1": - version: 1.1.1 - resolution: "@protobufjs/utf8@npm:1.1.1" - checksum: 10/ed0c3f9ff1afd602a0aed54c4c03a0b8f641686a5587d8949e088dcac653fb2019d15691ed92eef23dfdf9f4293249532d0508ecd15cef810acf026917719a19 + version: 1.1.2 + resolution: "@protobufjs/utf8@npm:1.1.2" + checksum: 10/ff759348d60e8f65137d3a7a16e00cf69abd9a6dede75e50ec377c6aebb4ac400a4a70af2e77eb2bf75e2accf30abbea81803e9403004b1922ea70776bfdc3aa languageName: node linkType: hard @@ -1923,7 +1914,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0": +"@types/node@npm:*": version: 25.9.0 resolution: "@types/node@npm:25.9.0" dependencies: @@ -1932,15 +1923,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:24.10.1": - version: 24.10.1 - resolution: "@types/node@npm:24.10.1" - dependencies: - undici-types: "npm:~7.16.0" - checksum: 10/ddac8c97be5f7401e31ea0e9316c6e864993c6cd06689b7f9874ecfb576ef8349f2d14298248a08b94a6dd029570a46a285cddc4d50e524817f1a3730b29a86e - languageName: node - linkType: hard - "@types/node@npm:25.9.1": version: 25.9.1 resolution: "@types/node@npm:25.9.1" @@ -1950,6 +1932,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=13.7.0": + version: 26.4.0 + resolution: "@types/node@npm:26.4.0" + dependencies: + undici-types: "npm:~8.3.0" + checksum: 10/90e2a82f8c17200c74515753ed48f6e13987b657d4c93e9b9922860a0abef0627b66037eb77d2a315d0255e9cd0115d71e791d13446ec02d93f85d8ab02dec69 + languageName: node + linkType: hard + "@types/node@npm:^18.11.18": version: 18.19.130 resolution: "@types/node@npm:18.19.130" @@ -2346,18 +2337,18 @@ __metadata: linkType: hard "axios@npm:^1.12.0": - version: 1.16.1 - resolution: "axios@npm:1.16.1" + version: 1.20.0 + resolution: "axios@npm:1.20.0" dependencies: follow-redirects: "npm:^1.16.0" - form-data: "npm:^4.0.5" + form-data: "npm:^4.0.6" https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10/9b6218cf96321cfbbf8f160658d695367114bcf4fb62492bdc1ccd647f184b5c71ae400e5ecaaf41079bc561de2ecbaf1fec63f398b3ec53389beff7694df64c + checksum: 10/0376b6178baecbe0f5d36088f5b5f6c21362ce301e0731f77172f9c89be740bdee20d8ae6ab186340d7cb752020c6a4f1381290e5a5cd10f6f9003941615ac77 languageName: node linkType: hard -"b4a@npm:^1.6.4": +"b4a@npm:^1.6.4, b4a@npm:^1.8.1": version: 1.8.1 resolution: "b4a@npm:1.8.1" peerDependencies: @@ -2384,20 +2375,20 @@ __metadata: linkType: hard "bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": - version: 2.8.3 - resolution: "bare-events@npm:2.8.3" + version: 2.9.2 + resolution: "bare-events@npm:2.9.2" peerDependencies: bare-abort-controller: "*" peerDependenciesMeta: bare-abort-controller: optional: true - checksum: 10/704252793362d4a422959f3b5d134a3f893f020b515cccf55965c8076941d6e7fd8c23268560693f2300270378a00384156237e4390edda2d4ca0e641bfe774e + checksum: 10/28b1571b49bce4fb6ea7701f201aa364134607a5840d79285c2fa4a17d9248781bc14a6da16b4004b3bc6e87fd0de6eadf4f5f30346c1617845ef8d4b91fd3b9 languageName: node linkType: hard "bare-fs@npm:^4.0.1, bare-fs@npm:^4.5.5": - version: 4.7.1 - resolution: "bare-fs@npm:4.7.1" + version: 4.8.1 + resolution: "bare-fs@npm:4.8.1" dependencies: bare-events: "npm:^2.5.4" bare-path: "npm:^3.0.0" @@ -2409,30 +2400,22 @@ __metadata: peerDependenciesMeta: bare-buffer: optional: true - checksum: 10/bb873bf8d22c45fd14444b0f9731315a77b696c9387b09cc0df9975b998d1b5db9f4c88aa4b264ce59edeade573689ba9e0ba172003cc8900b2c2ad803f9275b - languageName: node - linkType: hard - -"bare-os@npm:^3.0.1": - version: 3.9.1 - resolution: "bare-os@npm:3.9.1" - checksum: 10/2a106aca9eeb1cf41e30403410c9fa81a9e13c25818debc21444f2485158e01e65f10daff37acab0cbf9460c00e64e6bcaedef07b25a9171ec1e45485213ff50 + checksum: 10/e6797ff2ed0581f8270b8da4998a3ddf77a65ffa8bac49b315c38c39c6c17df1a9a0db5f5e33b320f0c72cfa1cfc41a3d2ebe0d9b7f6528202ea7e74cd5326b1 languageName: node linkType: hard "bare-path@npm:^3.0.0": - version: 3.0.0 - resolution: "bare-path@npm:3.0.0" - dependencies: - bare-os: "npm:^3.0.1" - checksum: 10/712d90e9cd8c3263cc11b0e0d386d1531a452706d7840c081ee586b34b00d72544e65df7a40013d47c1b177277495225deeede65cb2984db88a979cb65aaa2ff + version: 3.1.1 + resolution: "bare-path@npm:3.1.1" + checksum: 10/a8700e81203a48bf4de260240ca383d37d02aa287af7ec0da3483e875a534f3c54446243b0f88a77cfcea5adb142584f64c17268d014ce5e978d8a7236ba76e6 languageName: node linkType: hard "bare-stream@npm:^2.6.4": - version: 2.13.1 - resolution: "bare-stream@npm:2.13.1" + version: 2.13.4 + resolution: "bare-stream@npm:2.13.4" dependencies: + b4a: "npm:^1.8.1" streamx: "npm:^2.25.0" teex: "npm:^1.0.1" peerDependencies: @@ -2446,16 +2429,16 @@ __metadata: optional: true bare-events: optional: true - checksum: 10/50aa90a7005d71c1af8fafcc84f378bd4d7c2dd293a581ffe3899bee39b0d2eb07c47e1092f581fa5b199a63c0ad2618b150c0ab716658727e3fcc7fd7d1e401 + checksum: 10/9b7b03d134072697f13b3e28ce7feb0a743b9cd8640659ff502d0e7d222f033459c723d57682288eb09b61ff6e0652e0c7efb87d539696f64a8203622949c29b languageName: node linkType: hard "bare-url@npm:^2.2.2": - version: 2.4.3 - resolution: "bare-url@npm:2.4.3" + version: 2.5.2 + resolution: "bare-url@npm:2.5.2" dependencies: bare-path: "npm:^3.0.0" - checksum: 10/e2c16dd57e0c4b974813d9acd626b96e83a8894e19b0bf780de4bef40a7000c697984a47c398c8f612aa7991974bfb97f1c3c3fd410085a55fa5db15d1ba6309 + checksum: 10/4a3eb8ad515e86d13c25d25796db36eb48968d9af69aff9c5e311355592d315dbec3f3f1ff2986e076ef1a46246df0740d711cebab45e5ce51e83f9378e1929e languageName: node linkType: hard @@ -2494,11 +2477,11 @@ __metadata: linkType: hard "brace-expansion@npm:^2.0.1": - version: 2.1.0 - resolution: "brace-expansion@npm:2.1.0" + version: 2.1.4 + resolution: "brace-expansion@npm:2.1.4" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 + checksum: 10/11e18dc39706037331abedbb742af1da733267042f94c0f08487ef1a63cee068c1859f8d5f95523869725191133eb1a69753de457ed4b76b7c187d9ea0646737 languageName: node linkType: hard @@ -2965,11 +2948,11 @@ __metadata: linkType: hard "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": - version: 1.1.1 - resolution: "es-object-atoms@npm:1.1.1" + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" dependencies: es-errors: "npm:^1.3.0" - checksum: 10/54fe77de288451dae51c37bfbfe3ec86732dc3778f98f3eb3bdb4bf48063b2c0b8f9c93542656986149d08aa5be3204286e2276053d19582b76753f1a2728867 + checksum: 10/70041de72ab8996df74c17775cdedb8a0c36eb09a4111921d974f7d018af963023bb035a328b5772c2851daa40fb49f52313be0418763a975cb42cb6fe723255 languageName: node linkType: hard @@ -3140,16 +3123,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.5": - version: 4.0.5 - resolution: "form-data@npm:4.0.5" +"form-data@npm:^4.0.6": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.12" - checksum: 10/52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10/de6614c8537c92fa5fa3ee7e827758f98f5a9c033f348b7de81855ef36e5cb867e75d9f405d9483ab8d724a4a20d4e79926a299fa8dbba38f530eb659f0884e4 languageName: node linkType: hard @@ -3362,12 +3345,12 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.2": - version: 2.0.3 - resolution: "hasown@npm:2.0.3" +"hasown@npm:^2.0.2, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" dependencies: function-bind: "npm:^1.1.2" - checksum: 10/619526379cda755409d856cbf3c65b82ea342151719a0a550920cf7d6a7f58f7cf079e5a78f3acd162324fc784a3d3d6f6f61aff613b47a0163c16fbe09ea89f + checksum: 10/13823863ae48161068b4c51606a3128451c66f14545a5169d667fe9fca168dcd38c27570c7a299e32ef844b8da3d55def7fe88602f8970d4311fb543ee88001a languageName: node linkType: hard @@ -3867,7 +3850,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12": +"mime-types@npm:^2.1.35": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -4072,11 +4055,11 @@ __metadata: linkType: hard "nan@npm:^2.19.0, nan@npm:^2.23.0": - version: 2.27.0 - resolution: "nan@npm:2.27.0" + version: 2.28.0 + resolution: "nan@npm:2.28.0" dependencies: node-gyp: "npm:latest" - checksum: 10/bdce0630e417740501394c412bd9f0ed1c287825e3b8f9b7efb95cc3acd3ef69de60479b5f00a2d039b79321e5ce29b672b0b263cfe0e4d8f47c8f810a24a5ee + checksum: 10/299ffb18f43cc53b0728cb16ed15e868960168b84d4140b54c8e057c54e6714d0291eee63c02c3cae4bcf5d0d06b6a93fc8717d8d9ae72ad6d4e8eba5ddacc66 languageName: node linkType: hard @@ -4474,22 +4457,21 @@ __metadata: linkType: hard "protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.2, protobufjs@npm:^7.5.5": - version: 7.6.0 - resolution: "protobufjs@npm:7.6.0" + version: 7.6.6 + resolution: "protobufjs@npm:7.6.6" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" "@protobufjs/codegen": "npm:^2.0.5" - "@protobufjs/eventemitter": "npm:^1.1.0" + "@protobufjs/eventemitter": "npm:^1.1.1" "@protobufjs/fetch": "npm:^1.1.1" "@protobufjs/float": "npm:^1.0.2" - "@protobufjs/inquire": "npm:^1.1.2" "@protobufjs/path": "npm:^1.1.2" "@protobufjs/pool": "npm:^1.1.0" "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" long: "npm:^5.3.2" - checksum: 10/2becdf429fa148b2f3c9ee5e52c7b8249d2b775d158ce9e5bcf82d2f9d979bf95667818f5c70487636f775e5712aecf20775ac6e86a019e146fb95ed4063dfdc + checksum: 10/a894f38c793a0d741f77a7fbf8595141618e8c1cb3433637d2d0309ea9baca59bd9c44ec95f46ee839d063de5d5ae1bc78ef8d474359866af52ee4ceef7fec4f languageName: node linkType: hard @@ -4913,13 +4895,13 @@ __metadata: linkType: hard "streamx@npm:^2.12.5, streamx@npm:^2.15.0, streamx@npm:^2.25.0": - version: 2.25.0 - resolution: "streamx@npm:2.25.0" + version: 2.28.1 + resolution: "streamx@npm:2.28.1" dependencies: events-universal: "npm:^1.0.0" fast-fifo: "npm:^1.3.2" text-decoder: "npm:^1.1.0" - checksum: 10/d00dd38a1b73e4dac5225344aee421eb12ba9dded3f0ee3427d358d663677af185bc2310f46cb85ff3da31e032a50514d6f66348ba756154fe8a89b845273a3c + checksum: 10/1bbfad45ee015af1e8ccf73315e1a03bb3c650865c06b64c28b328c20d9a8ff1aa7993d79a124b26ac17720b4f4825fe225bc4ab78a1b2c36a7d04343d312e51 languageName: node linkType: hard @@ -5006,20 +4988,20 @@ __metadata: linkType: hard "tar-fs@npm:^2.1.4": - version: 2.1.4 - resolution: "tar-fs@npm:2.1.4" + version: 2.1.5 + resolution: "tar-fs@npm:2.1.5" dependencies: chownr: "npm:^1.1.1" mkdirp-classic: "npm:^0.5.2" pump: "npm:^3.0.0" tar-stream: "npm:^2.1.4" - checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be + checksum: 10/9dfbde66d223e3164a8018521c7c1eb205696917cbfb116d15cde286ccc7f2445964bb3d4772f369eb003aad67c2573e4f1390c74266fee32fa16cb846b6d144 languageName: node linkType: hard "tar-fs@npm:^3.0.7": - version: 3.1.2 - resolution: "tar-fs@npm:3.1.2" + version: 3.1.3 + resolution: "tar-fs@npm:3.1.3" dependencies: bare-fs: "npm:^4.0.1" bare-path: "npm:^3.0.0" @@ -5030,7 +5012,7 @@ __metadata: optional: true bare-path: optional: true - checksum: 10/b358fb7061eebb42bfa6f122cf62d1bdd40dc619117863f3b59eeaa4f880dc03707014905bdb592e77176703d9045956d1ba27adda4458805f9f7cbf62015cbd + checksum: 10/858176af2e7c41a302026c6f21940160d50045f567e4276950c921c501e8ddb42edbf5b5437298092fe60d42c7b999a599279d94c3f369b9adb1cfc5b57486fe languageName: node linkType: hard @@ -5048,14 +5030,14 @@ __metadata: linkType: hard "tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": - version: 3.2.0 - resolution: "tar-stream@npm:3.2.0" + version: 3.2.1 + resolution: "tar-stream@npm:3.2.1" dependencies: b4a: "npm:^1.6.4" bare-fs: "npm:^4.5.5" fast-fifo: "npm:^1.2.0" streamx: "npm:^2.15.0" - checksum: 10/ce57a81521de73ae7a3b7d55a08da50d6771427c249bfa89a208518e48faf5254c8fa7201a8f5419ab8bde9601a74e6dd512b31a13ec89774aec96178f99a8d3 + checksum: 10/1a09f857e0789489476d99fb716fa6d209e7d19aa73b7a4b23f40eeba46792d5007ca62fde4fca8f643ed71ace1c526a293c655718a0409275a45a8aa86b562a languageName: node linkType: hard @@ -5164,9 +5146,9 @@ __metadata: linkType: hard "tmp@npm:^0.2.3": - version: 0.2.5 - resolution: "tmp@npm:0.2.5" - checksum: 10/dd4b78b32385eab4899d3ae296007b34482b035b6d73e1201c4a9aede40860e90997a1452c65a2d21aee73d53e93cd167d741c3db4015d90e63b6d568a93d7ec + version: 0.2.7 + resolution: "tmp@npm:0.2.7" + checksum: 10/0a3bc90beb0c6275273c3475fb57e466eaab1c9c4a101d029ff62b18146ce136e7f75d09de34863d9f2c2a492751402508f9e028bc98eb34a1416195d4b15619 languageName: node linkType: hard @@ -5258,16 +5240,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.9.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/c089d9d3da2729fd4ac517f9b0e0485914c4b3c26f80dc0cffcb5de1719a17951e92425d55db59515c1a7ddab65808466debb864d0d56dcf43f27007d0709594 - languageName: node - linkType: hard - "typescript@npm:^6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" @@ -5278,16 +5250,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/696e1b017bc2635f4e0c94eb4435357701008e2f272f553d06e35b494b8ddc60aa221145e286c28ace0c89ee32827a28c2040e3a69bdc108b1a5dc8fb40b72e3 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" @@ -5312,17 +5274,17 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.16.0": - version: 7.16.0 - resolution: "undici-types@npm:7.16.0" - checksum: 10/db43439f69c2d94cc29f75cbfe9de86df87061d6b0c577ebe9bb3255f49b22c50162a7d7eb413b0458b6510b8ca299ac7cff38c3a29fbd31af9f504bcf7fbc0d +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10/6681d2837ac75a75ac1cc46090aa2b8ddc7c6b8ecc295a6cdb06838752a730da3d8afeecf05e5ab7903160eafad3a8b6ffa1927e5ded260590f4d4fef18646d5 languageName: node linkType: hard "undici@npm:^6.24.0": - version: 6.25.0 - resolution: "undici@npm:6.25.0" - checksum: 10/a475e45da3e1d1073283bb70531666f09a432eabff2b857bd7063d469a1ee1486192ff61dc0dadbb526673ce1120fee14d66a59b6b17d1e0bd3a4d5f0a52d0a6 + version: 6.28.0 + resolution: "undici@npm:6.28.0" + checksum: 10/672a7a53bd1f6c80edb73b357ab36e98ef9e474024c442aab2b8ea2bc32f1103cab05525c78661ae724f3e1340e23d03efb9730fba28e76218ab0ec52e15d75a languageName: node linkType: hard @@ -5643,8 +5605,8 @@ __metadata: linkType: hard "yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" + version: 17.7.3 + resolution: "yargs@npm:17.7.3" dependencies: cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" @@ -5653,7 +5615,7 @@ __metadata: string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" yargs-parser: "npm:^21.1.1" - checksum: 10/abb3e37678d6e38ea85485ed86ebe0d1e3464c640d7d9069805ea0da12f69d5a32df8e5625e370f9c96dd1c2dc088ab2d0a4dd32af18222ef3c4224a19471576 + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 languageName: node linkType: hard From 25ede5d0efc794603733e6728c1e7280a987928b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 28 Aug 2026 10:58:43 +0200 Subject: [PATCH 8/9] refactor(deployer): split services out and cache the unshielded wallet Extracts five single-responsibility modules under src/services/: file-lock, atomic-json, wallet-sync, wallet-cache, and deploy-tx. They stay internal, with no package.json subpath, so the public surface is unchanged. deployments.ts, deployer.ts, and wallet/handler.ts shed the locking, syncing, persistence, and tx-submission code they had absorbed. Caches the unshielded sub-wallet alongside shielded and dust, with a matching --seed-cache-from-unshielded flag. The sync gate already waited on unshielded progress, so an uncached one re-synced from genesis and dominated every warm boot. Narrows two swallowed errors in file-lock. A stale-break fault that is not ENOENT now surfaces instead of retrying into a misleading lock timeout, and a failed release warns rather than vanishing. Release still never throws: it runs in the caller's finally, where throwing would mask a real error or fail an already-successful deploy. Coverage thresholds move to 100 in all four metrics, which the package now meets. --- packages/cli/src/runDeploy.ts | 8 + packages/cli/test/runDeploy.test.ts | 3 + packages/deployer/README.md | 7 +- .../src/config/compact-config.test.ts | 15 + .../deployer/src/config/compact-config.ts | 8 +- packages/deployer/src/deployer.test.ts | 425 +-------------- packages/deployer/src/deployer.ts | 405 ++------------ packages/deployer/src/deployments.ts | 86 +-- packages/deployer/src/loaders/argv.test.ts | 9 + packages/deployer/src/loaders/argv.ts | 8 + .../deployer/src/loaders/artifact.test.ts | 36 ++ .../src/loaders/constructor-meta.test.ts | 50 +- .../src/loaders/contract-resolve.test.ts | 50 +- packages/deployer/src/runDeploy.test.ts | 21 +- packages/deployer/src/runDeploy.ts | 8 +- .../deployer/src/services/atomic-json.test.ts | 52 ++ packages/deployer/src/services/atomic-json.ts | 22 + .../deployer/src/services/deploy-tx.test.ts | 158 ++++++ packages/deployer/src/services/deploy-tx.ts | 85 +++ .../deployer/src/services/file-lock.test.ts | 189 +++++++ packages/deployer/src/services/file-lock.ts | 85 +++ .../src/services/wallet-cache.test.ts | 502 ++++++++++++++++++ .../deployer/src/services/wallet-cache.ts | 370 +++++++++++++ .../deployer/src/services/wallet-sync.test.ts | 365 +++++++++++++ packages/deployer/src/services/wallet-sync.ts | 265 +++++++++ packages/deployer/src/wallet/handler.test.ts | 243 ++------- packages/deployer/src/wallet/handler.ts | 348 ++---------- packages/deployer/src/wallet/keystore.test.ts | 12 + packages/deployer/src/wallet/seeds.test.ts | 13 +- packages/deployer/vitest.config.ts | 6 +- 30 files changed, 2469 insertions(+), 1385 deletions(-) create mode 100644 packages/deployer/src/services/atomic-json.test.ts create mode 100644 packages/deployer/src/services/atomic-json.ts create mode 100644 packages/deployer/src/services/deploy-tx.test.ts create mode 100644 packages/deployer/src/services/deploy-tx.ts create mode 100644 packages/deployer/src/services/file-lock.test.ts create mode 100644 packages/deployer/src/services/file-lock.ts create mode 100644 packages/deployer/src/services/wallet-cache.test.ts create mode 100644 packages/deployer/src/services/wallet-cache.ts create mode 100644 packages/deployer/src/services/wallet-sync.test.ts create mode 100644 packages/deployer/src/services/wallet-sync.ts diff --git a/packages/cli/src/runDeploy.ts b/packages/cli/src/runDeploy.ts index f0696fa..35ff3f1 100644 --- a/packages/cli/src/runDeploy.ts +++ b/packages/cli/src/runDeploy.ts @@ -90,6 +90,7 @@ async function main(): Promise { skipWalletCache: args.noCache, seedCacheDust: args.seedCacheFromDust, seedCacheShielded: args.seedCacheFromShielded, + seedCacheUnshielded: args.seedCacheFromUnshielded, syncBatchSize: args.syncBatchSize, logger, promptPassphrase: async (path) => { @@ -197,6 +198,11 @@ function showUsage(): void { ' --seed-cache-from-shielded Import a pre-warmed shielded state file into .states/', ), ); + console.log( + chalk.yellow( + ' --seed-cache-from-unshielded Import a pre-warmed unshielded state file into .states/', + ), + ); console.log( chalk.yellow(' --dry-run Load+validate, do NOT submit a tx'), ); @@ -246,6 +252,8 @@ function packageVersion(): string { (require('../package.json') as { version?: string }).version ?? 'dev' ); } catch { + // A missing or malformed package.json means a broken install, and + // `--version` has no better answer than a placeholder. return 'dev'; } } diff --git a/packages/cli/test/runDeploy.test.ts b/packages/cli/test/runDeploy.test.ts index f5f3438..13e3e69 100644 --- a/packages/cli/test/runDeploy.test.ts +++ b/packages/cli/test/runDeploy.test.ts @@ -243,6 +243,8 @@ describe('runDeploy CLI', () => { '/dust.json', '--seed-cache-from-shielded', '/shielded.gz', + '--seed-cache-from-unshielded', + '/unshielded.gz', ]); expect(mockPrepare).toHaveBeenCalledTimes(1); @@ -257,6 +259,7 @@ describe('runDeploy CLI', () => { expect(opts.skipWalletCache).toBe(true); expect(opts.seedCacheDust).toBe('/dust.json'); expect(opts.seedCacheShielded).toBe('/shielded.gz'); + expect(opts.seedCacheUnshielded).toBe('/unshielded.gz'); }); it('should reject --seed-cache-from-dust with no follow-up value', async () => { diff --git a/packages/deployer/README.md b/packages/deployer/README.md index da6f373..c7a1a12 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -48,6 +48,7 @@ compact-deploy --no-cache ignore on-disk wallet-state cache; force fresh sync --seed-cache-from-dust import a pre-warmed dust state file into .states/ --seed-cache-from-shielded import a pre-warmed shielded state file into .states/ + --seed-cache-from-unshielded import a pre-warmed unshielded state file into .states/ --dry-run load, validate, build providers, log plan, DO NOT submit --json single JSON object on stdout (machine-readable) -v, --verbose pino debug logs to .compact/logs/.log @@ -70,7 +71,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea ## Wallet cache -After each successful sync the deployer writes `/.states/--.gz` (one file per shielded / dust sub-wallet). Next run restores from it instead of re-syncing from genesis. +After each successful sync the deployer writes `/.states/--.gz` (one file per shielded / dust / unshielded sub-wallet). Next run restores from it instead of re-syncing from genesis. - Contents: gzipped sub-wallet state (UTXOs, checkpoint). No private keys (re-derived from seed each run). - Keyed by SHA-256(seed) + network ID, so `local` vs `preprod` keep separate caches. @@ -85,9 +86,11 @@ If cold sync OOMs on preprod (the known upstream bug) and you already have a `wa ``` compact-deploy --network preprod \ --seed-cache-from-dust /path/to/state.json \ - --seed-cache-from-shielded /path/to/shielded.json # optional + --seed-cache-from-shielded /path/to/shielded.json \ + --seed-cache-from-unshielded /path/to/unshielded.json ``` +- The dust file is the one that matters on preprod; the shielded and unshielded imports are optional. - Accepts either raw JSON (the direct `serializeState()` output) or its gzipped copy. Gzip is detected by magic bytes. - The file is renamed to the seed-derived cache name and dropped into `.states/`. - **The previous cache (if any) is preserved at `.gz.bak`** — never deleted, never overwritten by the import. To roll back from a bad import, `mv .states/.gz.bak .states/.gz`. diff --git a/packages/deployer/src/config/compact-config.test.ts b/packages/deployer/src/config/compact-config.test.ts index 367a887..9ec4c4a 100644 --- a/packages/deployer/src/config/compact-config.test.ts +++ b/packages/deployer/src/config/compact-config.test.ts @@ -113,6 +113,21 @@ signing_key_file = "./deploy/Vault.signingkey" ); }); + it('should resolve a relative --config path against cwd', async () => { + const dir = tmpRepo(MIN_VALID); + const config = await CompactConfig.load('compact.toml', dir); + expect(config.configPath).toBe(join(dir, 'compact.toml')); + }); + + it('should throw ConfigError when the --config path exists but cannot be read', async () => { + // A directory clears the existence check, then `readFile` fails EISDIR. + const dir = mkdtempSync(join(tmpdir(), 'compact-toml-unreadable-')); + await expect(CompactConfig.load(dir)).rejects.toThrow(ConfigError); + await expect(CompactConfig.load(dir)).rejects.toThrow( + /Failed to read .*: EISDIR/, + ); + }); + it('should throw ConfigError when no compact.toml exists upward from cwd', async () => { const dir = mkdtempSync(join(tmpdir(), 'no-compact-toml-')); await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( diff --git a/packages/deployer/src/config/compact-config.ts b/packages/deployer/src/config/compact-config.ts index c93fa1c..9fdddd3 100644 --- a/packages/deployer/src/config/compact-config.ts +++ b/packages/deployer/src/config/compact-config.ts @@ -62,7 +62,13 @@ export class CompactConfig { const result = configSchema.safeParse(parsed); if (!result.success) { const issues = result.error.issues - .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`) + .map((i) => { + // A TOML document always parses to a table, so an issue can + // never land at the empty root path. + /* v8 ignore next */ + const at = i.path.join('.') || '(root)'; + return ` - ${at}: ${i.message}`; + }) .join('\n'); throw new ConfigError(`compact.toml validation failed:\n${issues}`); } diff --git a/packages/deployer/src/deployer.test.ts b/packages/deployer/src/deployer.test.ts index 2710c77..fed4aa6 100644 --- a/packages/deployer/src/deployer.test.ts +++ b/packages/deployer/src/deployer.test.ts @@ -15,7 +15,7 @@ import { vi, } from 'vitest'; import { Deployer } from './deployer.ts'; -import { DeployTxFailedError, UnfundedWalletError } from './errors.ts'; +import { DeployTxFailedError } from './errors.ts'; import { buildProviders } from './providers/build.ts'; import { WalletHandler } from './wallet/handler.ts'; @@ -217,41 +217,6 @@ function fakeProviderWithState( }; } -/** Build a single FacadeState with caller-supplied shielded/unshielded balance maps. */ -function syncedState( - shielded: Record, - unshielded: Record, -): unknown { - return { - isSynced: true, - shielded: { - balances: shielded, - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - }, - unshielded: { - balances: unshielded, - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - dust: { - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - balance: () => 0n, - }, - }; -} - type DeployTxResult = Awaited>; function fakeDeployTxResult(address = '0xCONTRACT'): DeployTxResult { return { @@ -475,189 +440,6 @@ describe('Deployer', () => { await expect(d.deploy()).rejects.toBeInstanceOf(DeployTxFailedError); }); - describe('syncAndVerifyFunds (owned-wallet branch)', () => { - it('should reject with a timeout error when the wallet never reaches chain tip', async () => { - const built = fakeOwnedFromProvider( - fakeProviderWithState(Rx.NEVER, '0xSTUCK'), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await expect( - Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 50, - }), - ).rejects.toThrow(/Wallet sync timeout after 50ms/); - }); - - it('should complete sync when sub-wallets are within the gap but not strictly complete', async () => { - // Live-chain shape (issue #115): the global dust stream keeps - // advancing, so dust is never strictly complete (gap 0) but settles - // within the tolerated gap. The old strict `isSynced` gate would hang - // here forever; the tolerant `isCompleteWithin` gate must proceed. - const anyBal = new Proxy({} as Record, { - get: () => 1n, - }); - const liveTipState = { - isSynced: false, - shielded: { - balances: anyBal, - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - }, - unshielded: { - balances: anyBal, - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - dust: { - state: { - progress: { - isStrictlyComplete: () => false, - isCompleteWithin: () => true, - }, - }, - balance: () => 1n, - }, - }; - const built = fakeOwnedFromProvider( - fakeProviderWithState(Rx.of(liveTipState as unknown), '0xLIVE-TIP'), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 1000, - }); - expect(d.deployer).toBe('0xLIVE-TIP'); - }); - - it('should NOT complete sync while any sub-wallet is outside the gap', async () => { - // Dust still outside the tolerated gap: the gate must keep waiting and - // ultimately time out rather than deploy against a half-synced wallet. - const anyBal = new Proxy({} as Record, { - get: () => 1n, - }); - const laggingState = { - isSynced: false, - shielded: { - balances: anyBal, - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - }, - unshielded: { - balances: anyBal, - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - dust: { - state: { - progress: { - isStrictlyComplete: () => false, - isCompleteWithin: () => false, - }, - }, - balance: () => 1n, - }, - }; - // Emit the lagging state, then hang: the gate filters it out and must - // keep waiting (not complete the sequence) so the timeout can fire. - const built = fakeOwnedFromProvider( - fakeProviderWithState( - Rx.concat(Rx.of(laggingState as unknown), Rx.NEVER), - '0xLAGGING', - ), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await expect( - Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 50, - }), - ).rejects.toThrow(/Wallet sync timeout after 50ms/); - }); - - it('should throw UnfundedWalletError when shielded and unshielded balances are both empty', async () => { - const built = fakeOwnedFromProvider( - fakeProviderWithState(Rx.of(syncedState({}, {})), '0xEMPTY'), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await expect( - Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 1000, - }), - ).rejects.toBeInstanceOf(UnfundedWalletError); - }); - - it('should NOT throw when only the unshielded side has a positive balance', async () => { - // Use a Proxy so any token key returns the expected balance. The - // ledger token raw key is opaque from this file. - const unshieldedAny = new Proxy({} as Record, { - get: () => 5n, - }); - const built = fakeOwnedFromProvider( - fakeProviderWithState( - Rx.of(syncedState({}, unshieldedAny)), - '0xUNSHIELDED-ONLY', - ), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 1000, - }); - expect(d.deployer).toBe('0xUNSHIELDED-ONLY'); - expect(built.saveCache.mock.calls.length).toBeGreaterThanOrEqual(1); - }); - - it('should NOT throw when only the shielded side has a positive balance', async () => { - const shieldedAny = new Proxy({} as Record, { - get: () => 3n, - }); - const built = fakeOwnedFromProvider( - fakeProviderWithState( - Rx.of(syncedState(shieldedAny, {})), - '0xSHIELDED-ONLY', - ), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 1000, - }); - expect(d.deployer).toBe('0xSHIELDED-ONLY'); - }); - }); - describe('wallet build options', () => { it('should forward syncBatchSize to WalletHandler.build', async () => { const built = fakeOwnedWallet('0xBATCH'); @@ -751,93 +533,8 @@ describe('Deployer', () => { }); describe('explorer URL', () => { - it('should return an empty explorerUrl when no explorer is configured', async () => { - const injected = fakeProvider('0xDEP'); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - walletProvider: asInjected(injected), - }); - const result = await d.deploy(); - expect(result.explorerUrl).toBe(''); - }); - - it('should return an empty explorerUrl when the address is empty', async () => { - const customFx = writeFixture({ explorer: 'https://explorer.example' }); - try { - vi.mocked(deployContract).mockResolvedValueOnce({ - deployTxData: { - public: { - contractAddress: '', - txHash: '0xH', - txId: '0xT', - blockHeight: 1, - }, - }, - } as unknown as DeployTxResult); - const injected = fakeProvider('0xDEP'); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: customFx.configPath, - logger: silentLogger, - walletProvider: asInjected(injected), - }); - const result = await d.deploy(); - expect(result.explorerUrl).toBe(''); - } finally { - customFx.cleanup(); - } - }); - - it('should NOT double-prefix when the address already starts with 0x', async () => { + it('should surface the explorer URL built from the deployed address', async () => { const customFx = writeFixture({ explorer: 'https://explorer.example' }); - try { - // fakeDeployTxResult default address already includes the 0x prefix. - const injected = fakeProvider('0xDEP'); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: customFx.configPath, - logger: silentLogger, - walletProvider: asInjected(injected), - }); - const result = await d.deploy(); - expect(result.explorerUrl).toBe( - 'https://explorer.example/contracts/0xCONTRACT', - ); - } finally { - customFx.cleanup(); - } - }); - - it('should add the 0x prefix when the address lacks one', async () => { - const customFx = writeFixture({ explorer: 'https://explorer.example' }); - try { - vi.mocked(deployContract).mockResolvedValueOnce( - fakeDeployTxResult('BARE'), - ); - const injected = fakeProvider('0xDEP'); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: customFx.configPath, - logger: silentLogger, - walletProvider: asInjected(injected), - }); - const result = await d.deploy(); - expect(result.explorerUrl).toBe( - 'https://explorer.example/contracts/0xBARE', - ); - } finally { - customFx.cleanup(); - } - }); - - it('should strip a trailing slash from the explorer base', async () => { - const customFx = writeFixture({ explorer: 'https://explorer.example/' }); try { const injected = fakeProvider('0xDEP'); await using d = await Deployer.prepare({ @@ -913,122 +610,4 @@ describe('Deployer', () => { ); }); }); - - describe('logWalletAddresses', () => { - it('should log the three bech32 addresses on the owned-wallet happy path', async () => { - const built = fakeOwnedWallet('0xADDR'); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - - const info = vi.fn(); - const logger = pino({ level: 'silent' }); - (logger as any).info = info; - - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger, - syncTimeoutMs: 1000, - }); - expect(d.deployer).toBe('0xADDR'); - expect(info).toHaveBeenCalledWith( - 'Wallet addresses (verify these match your seed):', - ); - expect(info).toHaveBeenCalledWith( - expect.stringMatching(/shielded:.*addr1stub/), - ); - }); - }); - - describe('describeProgress branches', () => { - it('should render the progress percentage when highest > 0', async () => { - // Mid-sync state (still short of the tip) that drives the progress - // subscription's "else" branch (highest > 0). Then a follow-up - // tip-reached state lets the `isCompleteWithin` gate resolve so the - // prepare call terminates. - const midState = { - isSynced: false, - shielded: { - balances: {} as Record, - state: { - progress: { - isStrictlyComplete: () => false, - isCompleteWithin: () => false, - appliedIndex: 10n, - highestIndex: 100n, - isConnected: true, - }, - }, - }, - unshielded: { - balances: {} as Record, - progress: { - isStrictlyComplete: () => false, - isCompleteWithin: () => false, - appliedId: 5n, - highestTransactionId: 50n, - isConnected: true, - }, - }, - dust: { - state: { - progress: { - isStrictlyComplete: () => false, - isCompleteWithin: () => false, - appliedIndex: 1n, - highestIndex: 10n, - isConnected: true, - }, - }, - balance: () => 0n, - }, - }; - const anyKeyHasBalance = new Proxy({} as Record, { - get: () => 1n, - }); - const syncedState = { - isSynced: true, - shielded: { - balances: anyKeyHasBalance, - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - }, - unshielded: { - balances: anyKeyHasBalance, - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - dust: { - state: { - progress: { - isStrictlyComplete: () => true, - isCompleteWithin: () => true, - }, - }, - balance: () => 1n, - }, - }; - const built = fakeOwnedFromProvider( - fakeProviderWithState( - Rx.of(midState as unknown, syncedState as unknown), - '0xPROGRESS', - ), - ); - vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned); - await using d = await Deployer.prepare({ - contract: 'Counter', - network: 'local', - configPath: fx.configPath, - logger: silentLogger, - syncTimeoutMs: 1000, - }); - expect(d.deployer).toBe('0xPROGRESS'); - }); - }); }); diff --git a/packages/deployer/src/deployer.ts b/packages/deployer/src/deployer.ts index fbd9203..f9c1b52 100644 --- a/packages/deployer/src/deployer.ts +++ b/packages/deployer/src/deployer.ts @@ -1,48 +1,14 @@ import { createHash } from 'node:crypto'; -import { shieldedToken, unshieldedToken } from '@midnight-ntwrk/ledger-v8'; -import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; -import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types'; import type { EnvironmentConfiguration, MidnightWalletProvider, } from '@midnight-ntwrk/testkit-js'; -import { - DustAddress, - ShieldedAddress, - UnshieldedAddress, -} from '@midnight-ntwrk/wallet-sdk-address-format'; -import type { FacadeState } from '@midnight-ntwrk/wallet-sdk-facade'; import type { Logger } from 'pino'; -import * as Rx from 'rxjs'; import { CompactConfig } from './config/compact-config.ts'; import type { ContractConfig, NetworkConfig } from './config/schema.ts'; -import { type DeploymentRecord, Deployments } from './deployments.ts'; -import { - ConfigError, - DeployTxFailedError, - UnfundedWalletError, -} from './errors.ts'; - -/** - * Default sync ceiling (10 min). Overrides testkit-js's hardcoded 90 s - * `waitForFunds` timeout, which is too short for real networks. - */ -const DEFAULT_SYNC_TIMEOUT_MS = 10 * 60 * 1000; - -/** - * Tolerated sync gap, in events, for the chain-tip gate. - * `FacadeState.isSynced` requires every sub-wallet to be *strictly* - * complete (gap 0). On a live network the global dust stream advances - * continuously, so the dust wallet sits a few events behind the tip - * indefinitely and strict `isSynced` never flips. The gate would then time - * out on a wallet that is in fact fully usable. `isCompleteWithin(50)` is the - * SDK default (and what the unshielded wallet uses internally): it treats - * "within 50 events of tip" as synced, which a live wallet reaches and - * holds. See OpenZeppelin/compact-tools#115. - */ -const SYNC_MAX_GAP = 50n; - +import { Deployments } from './deployments.ts'; +import { ConfigError } from './errors.ts'; import { ConstructorArgs } from './loaders/args.ts'; import { Artifact } from './loaders/artifact.ts'; import { InitialPrivateState } from './loaders/init-state.ts'; @@ -50,6 +16,16 @@ import { SigningKey } from './loaders/signing-key.ts'; import { buildProviders } from './providers/build.ts'; import { applyNetwork } from './providers/network.ts'; import { ProofServer } from './providers/proof-server.ts'; +import { + buildExplorerUrl, + executeDeploy, + toDeploymentRecord, +} from './services/deploy-tx.ts'; +import { + DEFAULT_SYNC_TIMEOUT_MS, + logWalletAddresses, + syncAndVerifyFunds, +} from './services/wallet-sync.ts'; import { WalletHandler } from './wallet/handler.ts'; import { resolveSeed } from './wallet/seeds.ts'; @@ -102,6 +78,8 @@ export interface DeployerOptions { seedCacheDust?: string; /** Like {@link seedCacheDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ seedCacheShielded?: string; + /** Like {@link seedCacheDust} but for the unshielded sub-wallet. Argv: `--seed-cache-from-unshielded`. */ + seedCacheUnshielded?: string; /** * Sync batch size for the shielded + dust sub-wallets. Precedence: this * value > `[networks.X].sync_batch_size` (TOML) > 5000. Raise it to replay @@ -195,23 +173,35 @@ export class Deployer implements AsyncDisposable { contract.signing_key_file, ); - const seedResolution = opts.walletProvider - ? undefined - : await resolveSeed({ - config, - networkName, - network, - seedFile: opts.seedFile, - promptPassphrase: opts.promptPassphrase, - }); - if (seedResolution) { - logger.debug(`Resolved deployer seed from: ${seedResolution.origin}`); + // One discriminated value instead of two parallel ones: every later + // use of the seed is reachable only through the `owned` arm, so the + // "injected wallet has no seed" invariant is checked by the compiler + // rather than re-asserted at runtime. + const walletSource = opts.walletProvider + ? ({ kind: 'injected', provider: opts.walletProvider } as const) + : ({ + kind: 'owned', + resolution: await resolveSeed({ + config, + networkName, + network, + seedFile: opts.seedFile, + promptPassphrase: opts.promptPassphrase, + }), + } as const); + if (walletSource.kind === 'owned') { + logger.debug( + `Resolved deployer seed from: ${walletSource.resolution.origin}`, + ); } // Hashed here because `prepare` is the only scope holding the seed, and // the private-state password must derive from secret material. - const privateStateSecret = seedResolution - ? createHash('sha256').update(seedResolution.seed.value).digest('hex') - : undefined; + const privateStateSecret = + walletSource.kind === 'owned' + ? createHash('sha256') + .update(walletSource.resolution.seed.value) + .digest('hex') + : undefined; // Stack owns every resource acquired below. On any throw before // the final `stack.move()`, `await using` disposes them in reverse @@ -243,12 +233,9 @@ export class Deployer implements AsyncDisposable { ); let wallet: MidnightWalletProvider; - if (opts.walletProvider) { - wallet = opts.walletProvider; + if (walletSource.kind === 'injected') { + wallet = walletSource.provider; } else { - if (!seedResolution) { - throw new Error('internal: seedResolution missing for owned wallet'); - } // Sync tuning precedence: CLI/programmatic option > [networks.X] TOML // value > built-in default. `sync_batch_size` falls through to // WalletHandler's 5000 default when neither is set. @@ -261,11 +248,12 @@ export class Deployer implements AsyncDisposable { const owned = await WalletHandler.build( logger, env, - seedResolution.seed, + walletSource.resolution.seed, { skipWalletCache: opts.skipWalletCache, seedCacheDust: opts.seedCacheDust, seedCacheShielded: opts.seedCacheShielded, + seedCacheUnshielded: opts.seedCacheUnshielded, syncBatchSize, }, ); @@ -445,308 +433,3 @@ function resolveTargets( contract: config.contract(opts.contract), }; } - -/** - * Print the wallet's three bech32m addresses so the user can verify - * the seed before a long sync. Best-effort: warn-and-continue on - * failure. - */ -async function logWalletAddresses( - wallet: MidnightWalletProvider, - logger: Logger, -): Promise { - try { - const networkId = getNetworkId(); - const [shieldedState, unshieldedState, dustState] = await Promise.all([ - Rx.firstValueFrom(wallet.wallet.shielded.state), - Rx.firstValueFrom(wallet.wallet.unshielded.state), - Rx.firstValueFrom(wallet.wallet.dust.state), - ]); - const shielded = ShieldedAddress.codec - .encode(networkId, shieldedState.address) - .toString(); - const unshielded = UnshieldedAddress.codec - .encode(networkId, unshieldedState.address) - .toString(); - const dust = DustAddress.codec - .encode(networkId, dustState.address) - .toString(); - logger.info('Wallet addresses (verify these match your seed):'); - logger.info(` shielded: ${shielded}`); - logger.info(` unshielded: ${unshielded}`); - logger.info(` dust: ${dust}`); - } catch (e) { - logger.warn( - { err: (e as Error).message }, - 'Could not derive wallet addresses for display; continuing', - ); - } -} - -/** - * One-liner progress string for "Still syncing". Accepts both progress - * shapes (shielded/dust use `appliedIndex`/`highestIndex`; unshielded - * uses `appliedId`/`highestTransactionId`). - */ -function describeProgress(p: { isStrictlyComplete: () => boolean }): string { - const complete = p.isStrictlyComplete(); - const fields = p as unknown as { - appliedIndex?: bigint; - highestIndex?: bigint; - highestRelevantIndex?: bigint; - appliedId?: bigint; - highestTransactionId?: bigint; - isConnected?: boolean; - }; - const applied = fields.appliedIndex ?? fields.appliedId ?? 0n; - const highest = fields.highestIndex ?? fields.highestTransactionId ?? 0n; - const connected = fields.isConnected ?? false; - // Once the indexer has told the wallet its max event id, we can - // render a real progress percentage. Until then surface "applied, - // highest unknown" and the subscription's connection state so the - // user can tell "still connecting" from "connected but no events yet" - // from "events flowing". - if (highest === 0n) { - return `applied=${applied} highest=? connected=${connected} complete=${complete}`; - } - const pct = Number((applied * 100n) / highest); - return `${applied}/${highest} (${pct}%) connected=${connected} complete=${complete}`; -} - -/** - * Drive the wallet to chain tip and assert spendable funds. Gates on each - * sub-wallet being within {@link SYNC_MAX_GAP} events of the tip rather - * than strictly complete: on a live network the global dust stream never - * settles to gap 0, so strict `FacadeState.isSynced` never fires and the - * gate times out on a perfectly usable wallet (#115). The gate still waits - * on all three sub-wallets; dropping the dust/shielded wait entirely - * regressed on local with `Invalid Transaction (custom error 170)`. - * Throttles progress logs to once per 30 s. Throws - * {@link UnfundedWalletError} on empty wallet. - */ -async function syncAndVerifyFunds(args: { - wallet: MidnightWalletProvider; - timeoutMs: number; - logger: Logger; - /** Periodic checkpoint so a Ctrl+C mid-sync survives. Owned-wallet branch only. */ - onCheckpoint?: () => Promise; -}): Promise { - const { wallet, timeoutMs, logger, onCheckpoint } = args; - logger.info( - `Syncing wallet to chain tip (timeout ${Math.round(timeoutMs / 1000)}s)…`, - ); - const start = Date.now(); - - // Two subscriptions to the same observable: one logs throttled - // progress lines for UX, the other waits for completion. The progress - // tap deliberately runs through `Rx.throttleTime(30_000)` so the - // shielded-sync flood doesn't drown the terminal; the completion gate - // doesn't throttle, so the deploy proceeds the instant sync flips. - const state$ = wallet.wallet.state(); - const progressSub = state$ - .pipe( - Rx.throttleTime(30_000, undefined, { leading: false, trailing: true }), - ) - .subscribe((s) => { - const elapsedSec = Math.round((Date.now() - start) / 1000); - const elapsedHms = - elapsedSec < 60 - ? `${elapsedSec}s` - : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`; - // Pull running balance projections each tick so the user can - // see funds materialise mid-sync (NIGHT becomes visible the - // moment unshielded completes; dust accumulates as the wallet - // processes events even before its sync is strictly complete). - const shieldedBal = s.shielded.balances[shieldedToken().raw] ?? 0n; - const unshieldedBal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; - const dustBal = s.dust.balance(new Date()); - logger.info( - `Still syncing (${elapsedHms} elapsed). ` + - `shielded ${describeProgress(s.shielded.state.progress)} balance=${shieldedBal}; ` + - `unshielded ${describeProgress(s.unshielded.progress)} balance=${unshieldedBal}; ` + - `dust ${describeProgress(s.dust.state.progress)} balance=${dustBal}`, - ); - }); - - // Periodic checkpoint: snapshot the wallet caches every 5 min so a - // user who Ctrl+C's a long preprod first-run can resume from the - // latest snapshot instead of starting at id=0 again. Best-effort: - // a failed save logs a warning and the sync keeps going. Skipped - // when `onCheckpoint` is not provided (i.e. injected-wallet callers - // where the deployer doesn't own persistence). - let checkpointInFlight = false; - const checkpointSub = onCheckpoint - ? state$ - .pipe( - Rx.throttleTime(5 * 60 * 1000, undefined, { - leading: false, - trailing: true, - }), - ) - .subscribe(() => { - if (checkpointInFlight) return; - checkpointInFlight = true; - onCheckpoint().finally(() => { - checkpointInFlight = false; - }); - }) - : undefined; - - // Per-sub-wallet edge-trigger: the first time each sub-wallet flips - // to `complete=true`, log its current balance immediately. This lets - // a user with NIGHT+dust (the typical preprod-faucet wallet shape) - // see their unshielded balance after ~30 s instead of waiting for - // the full shielded sync (30 – 60 min) to surface anything. - const seenComplete = { shielded: false, unshielded: false, dust: false }; - const balanceSub = state$.subscribe((s) => { - if ( - !seenComplete.unshielded && - s.unshielded.progress.isStrictlyComplete() - ) { - seenComplete.unshielded = true; - const bal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; - logger.info(`Unshielded sync complete — NIGHT balance: ${bal}`); - } - if (!seenComplete.dust && s.dust.state.progress.isStrictlyComplete()) { - seenComplete.dust = true; - const bal = s.dust.balance(new Date()); - logger.info(`Dust sync complete — dust balance: ${bal}`); - } - if ( - !seenComplete.shielded && - s.shielded.state.progress.isStrictlyComplete() - ) { - seenComplete.shielded = true; - const bal = s.shielded.balances[shieldedToken().raw] ?? 0n; - logger.info(`Shielded sync complete — shielded balance: ${bal}`); - } - }); - - let synced: FacadeState; - try { - synced = await Rx.firstValueFrom( - state$.pipe( - // Tolerant tip gate: all three sub-wallets within SYNC_MAX_GAP - // events of the tip. Strict `s.isSynced` never fires on a live - // chain because the global dust stream keeps advancing (#115). - Rx.filter( - (s: FacadeState) => - s.shielded.state.progress.isCompleteWithin(SYNC_MAX_GAP) && - s.dust.state.progress.isCompleteWithin(SYNC_MAX_GAP) && - s.unshielded.progress.isCompleteWithin(SYNC_MAX_GAP), - ), - Rx.timeout({ - each: timeoutMs, - with: () => - Rx.throwError( - () => new Error(`Wallet sync timeout after ${timeoutMs}ms`), - ), - }), - ), - ); - } finally { - progressSub.unsubscribe(); - balanceSub.unsubscribe(); - checkpointSub?.unsubscribe(); - } - - const totalSec = Math.round((Date.now() - start) / 1000); - const totalHms = - totalSec < 60 - ? `${totalSec}s` - : `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`; - logger.info(`Sync complete after ${totalHms}`); - - // Accept funds in either shielded or unshielded. Preprod faucets - // hand out unshielded NIGHT, while a freshly bridged wallet may sit - // entirely in the shielded layer. Both are deployable: dust for - // fees auto-generates from either NIGHT or shielded holdings. - // Mirrors midnight-apps's `waitForUnshieldedFunds` semantics. - const shieldedBal = synced.shielded.balances[shieldedToken().raw]; - const unshieldedBal = synced.unshielded.balances[unshieldedToken().raw]; - const hasShielded = shieldedBal !== undefined && shieldedBal > 0n; - const hasUnshielded = unshieldedBal !== undefined && unshieldedBal > 0n; - if (!hasShielded && !hasUnshielded) { - throw new UnfundedWalletError(wallet.getCoinPublicKey()); - } - logger.info( - `Wallet balance: shielded=${shieldedBal ?? 0n}, unshielded=${unshieldedBal ?? 0n}`, - ); -} - -interface ExecuteDeployArgs { - providers: Parameters[0]; - contractName: string; - contract: ContractConfig; - artifact: Artifact; - signingKey: string; - args: readonly unknown[]; - initialPrivateState: unknown; -} - -/** Submit the deploy tx; wrap failures in {@link DeployTxFailedError}. */ -async function executeDeploy({ - providers, - contractName, - contract, - artifact, - signingKey, - args, - initialPrivateState, -}: ExecuteDeployArgs): Promise>> { - const compiled = artifact.compiledContract as Parameters< - typeof deployContract - >[1]['compiledContract']; - const base = { - compiledContract: compiled, - signingKey, - args, - } as Parameters[1]; - const deployOptions = - contract.private_state_id !== undefined - ? { - ...base, - privateStateId: contract.private_state_id, - initialPrivateState, - } - : base; - - try { - return await deployContract(providers, deployOptions); - } catch (e) { - throw new DeployTxFailedError( - `Deploy of "${contractName}" failed: ${(e as Error).message}`, - { cause: e }, - ); - } -} - -type ContractDeployResult = Awaited>; - -/** Build `/contracts/0x
`, or `''` when no explorer / no address. */ -function buildExplorerUrl(base: string | undefined, address: string): string { - if (!base || !address) return ''; - const trimmed = base.endsWith('/') ? base.slice(0, -1) : base; - const hex = address.startsWith('0x') ? address : `0x${address}`; - return `${trimmed}/contracts/${hex}`; -} - -function toDeploymentRecord({ - deployTxData, - deployer, - artifact, -}: { - deployTxData: ContractDeployResult['deployTxData']; - deployer: string; - artifact: string; -}): DeploymentRecord { - return { - address: deployTxData.public.contractAddress, - txHash: deployTxData.public.txHash, - txId: deployTxData.public.txId, - blockHeight: deployTxData.public.blockHeight, - deployer, - artifact, - timestamp: new Date().toISOString(), - }; -} diff --git a/packages/deployer/src/deployments.ts b/packages/deployer/src/deployments.ts index 5a4383a..73d21db 100644 --- a/packages/deployer/src/deployments.ts +++ b/packages/deployer/src/deployments.ts @@ -1,14 +1,7 @@ -import { randomUUID } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { - mkdir, - readFile, - rename, - stat, - unlink, - writeFile, -} from 'node:fs/promises'; +import { mkdir } from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; +import { readJson, writeJson } from './services/atomic-json.ts'; +import { acquireLock, releaseLock } from './services/file-lock.ts'; /** * Two-file per-network deployment ledger: @@ -123,76 +116,3 @@ export class Deployments { return readJson(this.#historyPath, {}); } } - -/** A lock older than this is treated as abandoned by a crashed process. */ -const LOCK_STALE_MS = 30_000; -const LOCK_RETRY_MS = 25; -const LOCK_MAX_WAIT_MS = 10_000; - -/** - * Take `lockPath` exclusively via `open(O_CREAT|O_EXCL)`. Retries on EEXIST; - * unlinks and retries once the holder's mtime passes {@link LOCK_STALE_MS} - * so a killed deploy can't wedge the ledger permanently. - */ -async function acquireLock(lockPath: string): Promise { - const deadline = Date.now() + LOCK_MAX_WAIT_MS; - for (;;) { - try { - await writeFile(lockPath, `${process.pid}\n`, { flag: 'wx' }); - return; - } catch (e) { - if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; - if (await breakIfStale(lockPath)) continue; - if (Date.now() > deadline) { - throw new Error( - `Timed out waiting for the deployments lock at ${lockPath}. Remove it if no deploy is running.`, - ); - } - await delay(LOCK_RETRY_MS); - } - } -} - -/** Unlink `lockPath` if its mtime is older than {@link LOCK_STALE_MS}. Reports whether it did. */ -async function breakIfStale(lockPath: string): Promise { - try { - const { mtimeMs } = await stat(lockPath); - if (Date.now() - mtimeMs < LOCK_STALE_MS) return false; - await unlink(lockPath); - return true; - } catch { - // Holder released between our EEXIST and the stat/unlink; the next - // acquire attempt will win the race normally. - return false; - } -} - -async function releaseLock(lockPath: string): Promise { - try { - await unlink(lockPath); - } catch { - // Already gone (stale-broken by another waiter). Nothing to release. - } -} - -function delay(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -async function readJson(path: string, fallback: T): Promise { - if (!existsSync(path)) return fallback; - const raw = await readFile(path, 'utf8'); - if (!raw.trim()) return fallback; - return JSON.parse(raw) as T; -} - -// Write atomically: a crash mid-write would otherwise leave a truncated -// `*.json`, breaking subsequent reads and losing durable deploy state. -// Write to a sibling temp file, then rename it into place (atomic on the -// same filesystem). -async function writeJson(path: string, value: unknown): Promise { - await mkdir(dirname(path), { recursive: true }); - const tmp = `${path}.${randomUUID()}.tmp`; - await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); - await rename(tmp, path); -} diff --git a/packages/deployer/src/loaders/argv.test.ts b/packages/deployer/src/loaders/argv.test.ts index 07508ca..919e46d 100644 --- a/packages/deployer/src/loaders/argv.test.ts +++ b/packages/deployer/src/loaders/argv.test.ts @@ -32,6 +32,8 @@ describe('parseDeployArgv', () => { './dust.gz', '--seed-cache-from-shielded', './shielded.gz', + '--seed-cache-from-unshielded', + './unshielded.gz', ]); expect(p.network).toBe('preview'); expect(p.configPath).toBe('./compact.toml'); @@ -41,6 +43,13 @@ describe('parseDeployArgv', () => { expect(p.syncBatchSize).toBe(2500); expect(p.seedCacheFromDust).toBe('./dust.gz'); expect(p.seedCacheFromShielded).toBe('./shielded.gz'); + expect(p.seedCacheFromUnshielded).toBe('./unshielded.gz'); + }); + + it('should reject --seed-cache-from-unshielded with no follow-up value', () => { + expect(() => parseDeployArgv(['--seed-cache-from-unshielded'])).toThrow( + /--seed-cache-from-unshielded requires a value/, + ); }); it('should parse every boolean flag and its shorthand', () => { diff --git a/packages/deployer/src/loaders/argv.ts b/packages/deployer/src/loaders/argv.ts index 098c8c9..7346ace 100644 --- a/packages/deployer/src/loaders/argv.ts +++ b/packages/deployer/src/loaders/argv.ts @@ -13,6 +13,7 @@ export interface ParsedDeployArgv { syncBatchSize?: number; seedCacheFromDust?: string; seedCacheFromShielded?: string; + seedCacheFromUnshielded?: string; noCache: boolean; dryRun: boolean; json: boolean; @@ -82,6 +83,13 @@ export function parseDeployArgv( '--seed-cache-from-shielded', ); break; + case '--seed-cache-from-unshielded': + out.seedCacheFromUnshielded = expectValue( + argv, + ++i, + '--seed-cache-from-unshielded', + ); + break; case '--network': out.network = expectValue(argv, ++i, '--network'); break; diff --git a/packages/deployer/src/loaders/artifact.test.ts b/packages/deployer/src/loaders/artifact.test.ts index dbf3969..38deb15 100644 --- a/packages/deployer/src/loaders/artifact.test.ts +++ b/packages/deployer/src/loaders/artifact.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FileOrModuleRef } from '../config/schema.ts'; import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; vi.mock('@midnight-ntwrk/compact-js', () => ({ @@ -196,6 +197,21 @@ describe('Artifact.load — error paths', () => { ); }); + it('should throw ConfigError when witnesses ref carries neither file nor module', async () => { + makeArtifactDir(root, 'WitnessShapeless'); + await expect( + Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'WitnessShapeless', + contractName: 'WitnessShapeless', + // Programmatic callers bypass the TOML schema, so the shape is + // re-checked here rather than trusted. + witnesses: { export: 'witnesses' } as unknown as FileOrModuleRef, + }), + ).rejects.toThrow(/witnesses must be \{ module, export \}/); + }); + it('should throw ConfigError when witnesses module export does not resolve to an object', async () => { makeArtifactDir(root, 'WitnessNonObject'); writeFileSync( @@ -220,6 +236,26 @@ describe('Artifact.load — witnesses module ref', () => { root = mkdtempSync(join(tmpdir(), 'artifact-wit-')); }); + it('should call a function-valued witnesses export and use its result', async () => { + makeArtifactDir(root, 'WitnessFactory'); + writeFileSync( + join(root, 'w.mjs'), + 'export const witnesses = () => ({ add: () => 1 });', + ); + const art = await Artifact.load({ + rootDir: root, + artifactsDir: 'src/artifacts', + artifact: 'WitnessFactory', + contractName: 'WitnessFactory', + witnesses: { module: 'w.mjs', export: 'witnesses' }, + }); + // The factory's return value reaches the compiled contract; the + // factory itself never does. + expect(art.compiledContract).toMatchObject({ + w: { add: expect.any(Function) }, + }); + }); + it('should accept a { module, export } witnesses ref that resolves to an object', async () => { makeArtifactDir(root, 'WithWitnesses'); writeFileSync( diff --git a/packages/deployer/src/loaders/constructor-meta.test.ts b/packages/deployer/src/loaders/constructor-meta.test.ts index 773c7a1..6a0c042 100644 --- a/packages/deployer/src/loaders/constructor-meta.test.ts +++ b/packages/deployer/src/loaders/constructor-meta.test.ts @@ -1,6 +1,10 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { ConfigError } from '../errors.ts'; +import { ArtifactNotFoundError, ConfigError } from '../errors.ts'; import { + loadConstructorParamNames, parseConstructorParamNames, reorderNamedArgs, } from './constructor-meta.ts'; @@ -41,6 +45,14 @@ describe('parseConstructorParamNames', () => { ).toEqual(['salt', 'commitments', 'thresh']); }); + it('handles parentheses nested inside a param type', () => { + expect( + parseConstructorParamNames( + dts('owner_0: (Uint8Array | ContractAddress), thresh_0: bigint'), + ), + ).toEqual(['owner', 'thresh']); + }); + it('keeps the SSA suffix when stripping would cause a name collision', () => { expect( parseConstructorParamNames(dts('foo_0: bigint, foo_1: bigint')), @@ -54,6 +66,42 @@ describe('parseConstructorParamNames', () => { it('returns [] when initialState is not present', () => { expect(parseConstructorParamNames('// nothing here')).toEqual([]); }); + + it('returns [] when the initialState parameter list is never closed', () => { + // Truncated `.d.ts`: the paren scan runs off the end without + // balancing, which must not be read as a no-arg constructor's `()`. + expect( + parseConstructorParamNames( + 'initialState(context: Ctx, name_0: string', + ), + ).toEqual([]); + }); + + it('rejects a constructor param with no type annotation', () => { + expect(() => parseConstructorParamNames(dts('name_0'))).toThrow( + /Cannot parse constructor param: "name_0"/, + ); + }); +}); + +describe('loadConstructorParamNames', () => { + it('reads the ordered param names from the artifact d.ts', () => { + const dir = mkdtempSync(join(tmpdir(), 'ctor-meta-')); + mkdirSync(join(dir, 'contract')); + writeFileSync( + join(dir, 'contract', 'index.d.ts'), + dts('_name_0: string, _supply_0: bigint'), + ); + expect(loadConstructorParamNames(dir)).toEqual(['_name', '_supply']); + }); + + it('throws ArtifactNotFoundError when the artifact has no contract/index.d.ts', () => { + const dir = mkdtempSync(join(tmpdir(), 'ctor-meta-')); + expect(() => loadConstructorParamNames(dir)).toThrow(ArtifactNotFoundError); + expect(() => loadConstructorParamNames(dir)).toThrow( + /cannot reorder named args/, + ); + }); }); describe('reorderNamedArgs', () => { diff --git a/packages/deployer/src/loaders/contract-resolve.test.ts b/packages/deployer/src/loaders/contract-resolve.test.ts index 917dad5..5c90569 100644 --- a/packages/deployer/src/loaders/contract-resolve.test.ts +++ b/packages/deployer/src/loaders/contract-resolve.test.ts @@ -14,7 +14,12 @@ afterEach(() => { rootDirs.length = 0; }); -function makeProject(entries: Record): string { +/** How the generated artifact module exposes its `Contract` class, if at all. */ +type ExportStyle = 'named' | 'default' | 'none'; + +function makeProject( + entries: Record, +): string { const root = mkdtempSync(join(tmpdir(), 'contract-resolve-')); rootDirs.push(root); mkdirSync(join(root, 'artifacts')); @@ -45,7 +50,9 @@ proof_server = "http://localhost:6300" ${contractsToml} `, ); - for (const [name, { Contract }] of Object.entries(entries)) { + for (const [name, { Contract, exportStyle = 'named' }] of Object.entries( + entries, + )) { const contractDir = join(root, 'artifacts', name, 'contract'); mkdirSync(contractDir, { recursive: true }); // The exported class instance is shared via the module cache, so @@ -53,10 +60,12 @@ ${contractsToml} const g = globalThis as unknown as Record; const key = `__test_contract_${name}_${Date.now()}_${Math.random()}`; g[key] = Contract; - writeFileSync( - join(contractDir, 'index.js'), - `export const Contract = globalThis['${key}'];\n`, - ); + const body = { + named: `export const Contract = globalThis['${key}'];\n`, + default: `export default { Contract: globalThis['${key}'] };\n`, + none: 'export const notAContract = 1;\n', + }[exportStyle]; + writeFileSync(join(contractDir, 'index.js'), body); } return root; } @@ -79,6 +88,35 @@ describe('resolveContractName', () => { ); }); + it('matches a Contract reached through the module default export', async () => { + class TokenContract { + initialState() {} + } + const root = makeProject({ + TokenExample: { Contract: TokenContract, exportStyle: 'default' }, + }); + const config = await CompactConfig.load(join(root, 'compact.toml')); + expect(await resolveContractName(TokenContract, config, root)).toBe( + 'TokenExample', + ); + }); + + it('passes over an artifact module that exports no Contract at all', async () => { + class TokenContract { + initialState() {} + } + const root = makeProject({ + Bare: { Contract: TokenContract, exportStyle: 'none' }, + TokenExample: { Contract: TokenContract }, + }); + const config = await CompactConfig.load(join(root, 'compact.toml')); + // `Bare` holds the same class but never exposes it, so it must not + // register as a second match and make the lookup ambiguous. + expect(await resolveContractName(TokenContract, config, root)).toBe( + 'TokenExample', + ); + }); + it('throws when no entry matches the Contract class', async () => { class A { initialState() {} diff --git a/packages/deployer/src/runDeploy.test.ts b/packages/deployer/src/runDeploy.test.ts index c1847fa..5465872 100644 --- a/packages/deployer/src/runDeploy.test.ts +++ b/packages/deployer/src/runDeploy.test.ts @@ -147,7 +147,7 @@ describe('runDeploy', () => { ); }); - it('should thread --seed-cache-from-dust and --seed-cache-from-shielded to Deployer.prepare', async () => { + it('should thread every --seed-cache-from-* flag to Deployer.prepare', async () => { process.argv = [ 'node', 'script.ts', @@ -155,6 +155,8 @@ describe('runDeploy', () => { '/path/to/dust.json', '--seed-cache-from-shielded', '/path/to/shielded.gz', + '--seed-cache-from-unshielded', + '/path/to/unshielded.gz', ]; const fakeDep = fakeDeployer(); vi.spyOn(deployerModule.Deployer, 'prepare').mockResolvedValue( @@ -166,6 +168,7 @@ describe('runDeploy', () => { const callArgs = (deployerModule.Deployer.prepare as any).mock.calls[0][0]; expect(callArgs.seedCacheDust).toBe('/path/to/dust.json'); expect(callArgs.seedCacheShielded).toBe('/path/to/shielded.gz'); + expect(callArgs.seedCacheUnshielded).toBe('/path/to/unshielded.gz'); }); it('should let explicit seedCacheFromDust opt beat the argv value', async () => { @@ -263,6 +266,22 @@ describe('runDeploy', () => { expect(process.exitCode).toBe(1); }); + it('should stringify a non-Error rejection in --json mode', async () => { + process.argv = ['node', 'script.ts', '--json']; + vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( + 'proof server said no', + ); + + await expect(runDeploy({ contract: 'X' })).rejects.toBe( + 'proof server said no', + ); + expect(process.exitCode).toBe(1); + + const parsed = JSON.parse(writeSpy.mock.calls[0]?.[0] as string); + expect(parsed.error).toBe('Error'); + expect(parsed.message).toBe('proof server said no'); + }); + it('should never call process.exit — an embedding app keeps control', async () => { process.argv = ['node', 'script.ts']; vi.spyOn(deployerModule.Deployer, 'prepare').mockRejectedValue( diff --git a/packages/deployer/src/runDeploy.ts b/packages/deployer/src/runDeploy.ts index 1650fd4..dcc4114 100644 --- a/packages/deployer/src/runDeploy.ts +++ b/packages/deployer/src/runDeploy.ts @@ -125,6 +125,8 @@ export interface RunDeployOptions< seedCacheFromDust?: string; /** Like {@link seedCacheFromDust} but for the shielded sub-wallet. Argv: `--seed-cache-from-shielded`. */ seedCacheFromShielded?: string; + /** Like {@link seedCacheFromDust} but for the unshielded sub-wallet. Argv: `--seed-cache-from-unshielded`. */ + seedCacheFromUnshielded?: string; /** Dry-run mode (skip on-chain submission). Argv: `--dry-run`. */ dryRun?: boolean; /** Emit a machine-readable JSON object on stdout instead of pretty lines. Argv: `--json`. */ @@ -162,8 +164,8 @@ export interface RunDeployOptions< * * Parses `--network`, `--dry-run`, `--sync-timeout`, `--sync-batch-size`, * `--no-cache`, `--seed-cache-from-dust`, `--seed-cache-from-shielded`, - * `--seed-file`, `--proof-server`, `--config`, `--json`, `-v` / - * `--verbose` from `process.argv` as defaults. Explicit options win + * `--seed-cache-from-unshielded`, `--seed-file`, `--proof-server`, + * `--config`, `--json`, `-v` / `--verbose` from `process.argv` as defaults. Explicit options win * over argv. Wires a pino logger (stderr), runs Deployer.prepare + * deploy or dryRun, and prints the result. * @@ -247,6 +249,8 @@ async function runDeployImpl( seedCacheDust: opts.seedCacheFromDust ?? argv.seedCacheFromDust, seedCacheShielded: opts.seedCacheFromShielded ?? argv.seedCacheFromShielded, + seedCacheUnshielded: + opts.seedCacheFromUnshielded ?? argv.seedCacheFromUnshielded, promptPassphrase: opts.promptPassphrase, logger, }); diff --git a/packages/deployer/src/services/atomic-json.test.ts b/packages/deployer/src/services/atomic-json.test.ts new file mode 100644 index 0000000..3d44ce0 --- /dev/null +++ b/packages/deployer/src/services/atomic-json.test.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readJson, writeJson } from './atomic-json.ts'; + +function scratch(): string { + return mkdtempSync(join(tmpdir(), 'atomic-json-test-')); +} + +describe('readJson', () => { + it('should return the fallback when the file does not exist', async () => { + const path = join(scratch(), 'missing.json'); + expect(await readJson(path, { seeded: true })).toEqual({ seeded: true }); + }); + + it('should return the fallback when the file is empty', async () => { + const path = join(scratch(), 'empty.json'); + writeFileSync(path, ' \n'); + expect(await readJson(path, { seeded: true })).toEqual({ seeded: true }); + }); + + it('should parse an existing document', async () => { + const path = join(scratch(), 'head.json'); + writeFileSync(path, '{"Token":{"address":"0xT1"}}'); + expect(await readJson(path, {})).toEqual({ Token: { address: '0xT1' } }); + }); +}); + +describe('writeJson', () => { + it('should round-trip a value through readJson', async () => { + const path = join(scratch(), 'head.json'); + await writeJson(path, { Token: { address: '0xT1' } }); + expect(await readJson(path, {})).toEqual({ Token: { address: '0xT1' } }); + }); + + it('should create missing parent directories', async () => { + const path = join(scratch(), 'nested', 'deeper', 'head.json'); + await writeJson(path, { ok: 1 }); + expect(await readJson(path, {})).toEqual({ ok: 1 }); + }); + + it('should leave no temp sibling behind and end the file with a newline', async () => { + const dir = scratch(); + const path = join(dir, 'head.json'); + await writeJson(path, { ok: 1 }); + // The rename target is the only artefact; a leftover `.tmp` would mean + // the atomic write degraded into a plain write. + expect(readdirSync(dir)).toEqual(['head.json']); + expect(readFileSync(path, 'utf8')).toBe('{\n "ok": 1\n}\n'); + }); +}); diff --git a/packages/deployer/src/services/atomic-json.ts b/packages/deployer/src/services/atomic-json.ts new file mode 100644 index 0000000..2d5a5df --- /dev/null +++ b/packages/deployer/src/services/atomic-json.ts @@ -0,0 +1,22 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +export async function readJson(path: string, fallback: T): Promise { + if (!existsSync(path)) return fallback; + const raw = await readFile(path, 'utf8'); + if (!raw.trim()) return fallback; + return JSON.parse(raw) as T; +} + +// Write atomically: a crash mid-write would otherwise leave a truncated +// `*.json`, breaking subsequent reads and losing durable deploy state. +// Write to a sibling temp file, then rename it into place (atomic on the +// same filesystem). +export async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.${randomUUID()}.tmp`; + await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`); + await rename(tmp, path); +} diff --git a/packages/deployer/src/services/deploy-tx.test.ts b/packages/deployer/src/services/deploy-tx.test.ts new file mode 100644 index 0000000..c72dfaa --- /dev/null +++ b/packages/deployer/src/services/deploy-tx.test.ts @@ -0,0 +1,158 @@ +import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContractConfig } from '../config/schema.ts'; +import { DeployTxFailedError } from '../errors.ts'; +import type { Artifact } from '../loaders/artifact.ts'; +import { + buildExplorerUrl, + type ContractDeployResult, + executeDeploy, + toDeploymentRecord, +} from './deploy-tx.ts'; + +vi.mock('@midnight-ntwrk/midnight-js-contracts', () => ({ + deployContract: vi.fn(), +})); + +function fakeDeployTxResult(address = '0xCONTRACT'): ContractDeployResult { + return { + deployTxData: { + public: { + contractAddress: address, + txHash: '0xHASH', + txId: '0xTX', + blockHeight: 1234, + }, + }, + } as unknown as ContractDeployResult; +} + +function args( + contract: Partial = {}, +): Parameters[0] { + return { + providers: { tag: 'providers' } as never, + contractName: 'Counter', + contract: { artifact: 'Counter', ...contract } as ContractConfig, + artifact: { compiledContract: { fake: 'compiled' } } as unknown as Artifact, + signingKey: 'aa'.repeat(32), + args: [1, 2], + initialPrivateState: { seeded: true }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('executeDeploy', () => { + it('should submit the compiled contract, signing key and args', async () => { + vi.mocked(deployContract).mockResolvedValue(fakeDeployTxResult()); + const result = await executeDeploy(args()); + expect(result).toEqual(fakeDeployTxResult()); + expect(deployContract).toHaveBeenCalledWith( + { tag: 'providers' }, + { + compiledContract: { fake: 'compiled' }, + signingKey: 'aa'.repeat(32), + args: [1, 2], + }, + ); + }); + + it('should omit the private-state options when no private_state_id is configured', async () => { + vi.mocked(deployContract).mockResolvedValue(fakeDeployTxResult()); + await executeDeploy(args()); + const options = vi.mocked(deployContract).mock.calls[0]?.[1]; + expect(options).not.toHaveProperty('privateStateId'); + expect(options).not.toHaveProperty('initialPrivateState'); + }); + + it('should pass privateStateId and the initial state when private_state_id is configured', async () => { + vi.mocked(deployContract).mockResolvedValue(fakeDeployTxResult()); + await executeDeploy(args({ private_state_id: 'counter-state' })); + expect(deployContract).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + privateStateId: 'counter-state', + initialPrivateState: { seeded: true }, + }), + ); + }); + + it('should wrap a midnight-js failure in DeployTxFailedError, keeping the cause', async () => { + const cause = new Error('chain rejected'); + vi.mocked(deployContract).mockRejectedValue(cause); + const thrown = await executeDeploy(args()).catch((e: unknown) => e); + expect(thrown).toBeInstanceOf(DeployTxFailedError); + expect((thrown as DeployTxFailedError).message).toBe( + 'Deploy of "Counter" failed: chain rejected', + ); + // The original failure stays reachable for CLI stack traces. + expect((thrown as DeployTxFailedError).cause).toBe(cause); + }); +}); + +describe('buildExplorerUrl', () => { + it('should return an empty string when no explorer is configured', () => { + expect(buildExplorerUrl(undefined, '0xCONTRACT')).toBe(''); + }); + + it('should return an empty string when the address is empty', () => { + expect(buildExplorerUrl('https://explorer.example', '')).toBe(''); + }); + + it('should NOT double-prefix an address that already starts with 0x', () => { + expect(buildExplorerUrl('https://explorer.example', '0xCONTRACT')).toBe( + 'https://explorer.example/contracts/0xCONTRACT', + ); + }); + + it('should add the 0x prefix when the address lacks one', () => { + expect(buildExplorerUrl('https://explorer.example', 'BARE')).toBe( + 'https://explorer.example/contracts/0xBARE', + ); + }); + + it('should strip a trailing slash from the explorer base', () => { + expect(buildExplorerUrl('https://explorer.example/', '0xCONTRACT')).toBe( + 'https://explorer.example/contracts/0xCONTRACT', + ); + }); +}); + +describe('toDeploymentRecord', () => { + it('should map the public tx fields and stamp an ISO timestamp', () => { + const record = toDeploymentRecord({ + deployTxData: fakeDeployTxResult().deployTxData, + deployer: '0xDEPLOYER', + artifact: 'src/artifacts/Counter/Counter', + }); + expect(record).toMatchObject({ + address: '0xCONTRACT', + txHash: '0xHASH', + txId: '0xTX', + blockHeight: 1234, + deployer: '0xDEPLOYER', + artifact: 'src/artifacts/Counter/Counter', + }); + expect(record.timestamp).toBe(new Date(record.timestamp).toISOString()); + }); + + it('should never carry a signing key into the ledger record', () => { + const record = toDeploymentRecord({ + deployTxData: fakeDeployTxResult().deployTxData, + deployer: '0xDEPLOYER', + artifact: 'Counter', + }); + expect(Object.keys(record).sort()).toEqual([ + 'address', + 'artifact', + 'blockHeight', + 'deployer', + 'timestamp', + 'txHash', + 'txId', + ]); + }); +}); diff --git a/packages/deployer/src/services/deploy-tx.ts b/packages/deployer/src/services/deploy-tx.ts new file mode 100644 index 0000000..3f59c1d --- /dev/null +++ b/packages/deployer/src/services/deploy-tx.ts @@ -0,0 +1,85 @@ +import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import type { ContractConfig } from '../config/schema.ts'; +import type { DeploymentRecord } from '../deployments.ts'; +import { DeployTxFailedError } from '../errors.ts'; +import type { Artifact } from '../loaders/artifact.ts'; + +export type ContractDeployResult = Awaited>; + +export interface ExecuteDeployArgs { + providers: Parameters[0]; + contractName: string; + contract: ContractConfig; + artifact: Artifact; + signingKey: string; + args: readonly unknown[]; + initialPrivateState: unknown; +} + +/** Submit the deploy tx; wrap failures in {@link DeployTxFailedError}. */ +export async function executeDeploy({ + providers, + contractName, + contract, + artifact, + signingKey, + args, + initialPrivateState, +}: ExecuteDeployArgs): Promise { + const compiled = artifact.compiledContract as Parameters< + typeof deployContract + >[1]['compiledContract']; + const base = { + compiledContract: compiled, + signingKey, + args, + } as Parameters[1]; + const deployOptions = + contract.private_state_id !== undefined + ? { + ...base, + privateStateId: contract.private_state_id, + initialPrivateState, + } + : base; + + try { + return await deployContract(providers, deployOptions); + } catch (e) { + throw new DeployTxFailedError( + `Deploy of "${contractName}" failed: ${(e as Error).message}`, + { cause: e }, + ); + } +} + +/** Build `/contracts/0x
`, or `''` when no explorer / no address. */ +export function buildExplorerUrl( + base: string | undefined, + address: string, +): string { + if (!base || !address) return ''; + const trimmed = base.endsWith('/') ? base.slice(0, -1) : base; + const hex = address.startsWith('0x') ? address : `0x${address}`; + return `${trimmed}/contracts/${hex}`; +} + +export function toDeploymentRecord({ + deployTxData, + deployer, + artifact, +}: { + deployTxData: ContractDeployResult['deployTxData']; + deployer: string; + artifact: string; +}): DeploymentRecord { + return { + address: deployTxData.public.contractAddress, + txHash: deployTxData.public.txHash, + txId: deployTxData.public.txId, + blockHeight: deployTxData.public.blockHeight, + deployer, + artifact, + timestamp: new Date().toISOString(), + }; +} diff --git a/packages/deployer/src/services/file-lock.test.ts b/packages/deployer/src/services/file-lock.test.ts new file mode 100644 index 0000000..4395f3e --- /dev/null +++ b/packages/deployer/src/services/file-lock.test.ts @@ -0,0 +1,189 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { acquireLock, LOCK_STALE_MS, releaseLock } from './file-lock.ts'; + +/** + * Injects an `unlink` failure without touching directory permissions, so the + * EACCES paths are exercised even for a uid that chmod cannot constrain. + * Pass-through while `code` is unset. + */ +const unlinkFault = vi.hoisted(() => ({ + code: undefined as string | undefined, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + unlink: (path: Parameters[0]) => { + if (unlinkFault.code === undefined) return actual.unlink(path); + const err: NodeJS.ErrnoException = new Error( + `EACCES: permission denied, unlink '${String(path)}'`, + ); + err.code = unlinkFault.code; + return Promise.reject(err); + }, + }; +}); + +afterEach(() => { + unlinkFault.code = undefined; +}); + +function lockPath(): string { + return join(mkdtempSync(join(tmpdir(), 'file-lock-test-')), 'head.json.lock'); +} + +/** A held lock in a read-only directory: `stat` resolves, `unlink` fails EACCES. */ +function unremovableLock(): { dir: string; path: string } { + const dir = mkdtempSync(join(tmpdir(), 'file-lock-test-')); + const path = join(dir, 'head.json.lock'); + writeFileSync(path, '999999\n'); + return { dir, path }; +} + +// chmod does not constrain root, so the permission-fault cases cannot run there. +const isRoot = process.getuid?.() === 0; + +describe('acquireLock', () => { + it('should create the lock file and remove it on release', async () => { + const path = lockPath(); + await acquireLock(path); + expect(existsSync(path)).toBe(true); + await releaseLock(path); + expect(existsSync(path)).toBe(false); + }); + + it('should block a contending acquire until the holder releases', async () => { + const path = lockPath(); + await acquireLock(path, { retryMs: 1 }); + let acquired = false; + const contender = acquireLock(path, { retryMs: 1 }).then(() => { + acquired = true; + }); + await new Promise((r) => setTimeout(r, 30)); + expect(acquired).toBe(false); + await releaseLock(path); + await contender; + expect(acquired).toBe(true); + await releaseLock(path); + }); + + it('should break a lock whose mtime is older than the stale window', async () => { + const path = lockPath(); + writeFileSync(path, '999999\n'); + // Age the holder past the stale window so the next acquire unlinks it + // instead of waiting out the (much longer) timeout. + const aged = (Date.now() - LOCK_STALE_MS * 2) / 1000; + utimesSync(path, aged, aged); + await acquireLock(path, { retryMs: 1, maxWaitMs: 200 }); + expect(existsSync(path)).toBe(true); + await releaseLock(path); + }); + + it('should throw a removal hint when a fresh lock outlives maxWaitMs', async () => { + const path = lockPath(); + writeFileSync(path, '999999\n'); + await expect( + acquireLock(path, { retryMs: 1, maxWaitMs: 20 }), + ).rejects.toThrow(/Timed out waiting for the deployments lock/); + }); + + it('should rethrow a non-EEXIST failure instead of retrying', async () => { + // Parent directory does not exist: `open(O_CREAT|O_EXCL)` fails ENOENT, + // which is not contention and must surface immediately. + const path = join(lockPath(), 'nested', 'head.json.lock'); + await expect(acquireLock(path)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('should keep waiting when the lock vanishes before the stale check', async () => { + // A dangling symlink reproduces the release-between-EEXIST-and-stat + // race deterministically: `open(O_CREAT|O_EXCL)` still fails EEXIST, + // while `stat` follows the link and fails ENOENT. + const path = lockPath(); + symlinkSync(join(path, '..', 'gone'), path); + await expect( + acquireLock(path, { retryMs: 1, maxWaitMs: 30 }), + ).rejects.toThrow(/Timed out waiting for the deployments lock/); + }); + + it('should surface a stale-break unlink fault regardless of uid', async () => { + const { path } = unremovableLock(); + const aged = (Date.now() - LOCK_STALE_MS * 2) / 1000; + utimesSync(path, aged, aged); + unlinkFault.code = 'EACCES'; + await expect( + acquireLock(path, { retryMs: 1, maxWaitMs: 50 }), + ).rejects.toMatchObject({ code: 'EACCES' }); + }); + + it.skipIf(isRoot)( + 'should surface a stale-break permission fault instead of timing out', + async () => { + const { dir, path } = unremovableLock(); + const aged = (Date.now() - LOCK_STALE_MS * 2) / 1000; + utimesSync(path, aged, aged); + chmodSync(dir, 0o500); + try { + await expect( + acquireLock(path, { retryMs: 1, maxWaitMs: 50 }), + ).rejects.toMatchObject({ code: 'EACCES' }); + } finally { + chmodSync(dir, 0o700); + } + }, + ); +}); + +describe('releaseLock', () => { + it('should be a no-op when the lock file is already gone', async () => { + const path = lockPath(); + const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}); + await expect(releaseLock(path)).resolves.toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('should warn rather than throw on an unlink fault regardless of uid', async () => { + const { path } = unremovableLock(); + unlinkFault.code = 'EACCES'; + const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {}); + try { + await expect(releaseLock(path)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not release the deployments lock'), + ); + } finally { + warn.mockRestore(); + } + }); + + it.skipIf(isRoot)( + 'should warn rather than throw when the lock cannot be removed', + async () => { + const { dir, path } = unremovableLock(); + chmodSync(dir, 0o500); + const warn = vi + .spyOn(process, 'emitWarning') + .mockImplementation(() => {}); + try { + await expect(releaseLock(path)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not release the deployments lock'), + ); + } finally { + warn.mockRestore(); + chmodSync(dir, 0o700); + } + }, + ); +}); diff --git a/packages/deployer/src/services/file-lock.ts b/packages/deployer/src/services/file-lock.ts new file mode 100644 index 0000000..fd3396c --- /dev/null +++ b/packages/deployer/src/services/file-lock.ts @@ -0,0 +1,85 @@ +import { stat, unlink, writeFile } from 'node:fs/promises'; + +/** A lock older than this is treated as abandoned by a crashed process. */ +export const LOCK_STALE_MS = 30_000; +export const LOCK_RETRY_MS = 25; +export const LOCK_MAX_WAIT_MS = 10_000; + +/** Timing overrides. Production callers take the defaults; tests shrink them. */ +export interface LockOptions { + staleMs?: number; + retryMs?: number; + maxWaitMs?: number; +} + +/** + * Take `lockPath` exclusively via `open(O_CREAT|O_EXCL)`. Retries on EEXIST; + * unlinks and retries once the holder's mtime passes {@link LOCK_STALE_MS} + * so a killed deploy can't wedge the ledger permanently. + */ +export async function acquireLock( + lockPath: string, + opts: LockOptions = {}, +): Promise { + const staleMs = opts.staleMs ?? LOCK_STALE_MS; + const retryMs = opts.retryMs ?? LOCK_RETRY_MS; + const maxWaitMs = opts.maxWaitMs ?? LOCK_MAX_WAIT_MS; + const deadline = Date.now() + maxWaitMs; + for (;;) { + try { + await writeFile(lockPath, `${process.pid}\n`, { flag: 'wx' }); + return; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; + if (await breakIfStale(lockPath, staleMs)) continue; + if (Date.now() > deadline) { + throw new Error( + `Timed out waiting for the deployments lock at ${lockPath}. Remove it if no deploy is running.`, + ); + } + await delay(retryMs); + } + } +} + +/** Unlink `lockPath` if its mtime is older than `staleMs`. Reports whether it did. */ +async function breakIfStale( + lockPath: string, + staleMs: number, +): Promise { + try { + const { mtimeMs } = await stat(lockPath); + if (Date.now() - mtimeMs < staleMs) return false; + await unlink(lockPath); + return true; + } catch (e) { + // ENOENT: the holder released between our EEXIST and this stat/unlink, + // so the next acquire wins the race. Any other fault is not contention, + // and retrying it would surface as a misleading lock timeout. + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + return false; + } +} + +/** + * Release `lockPath`. Never throws: it runs in the caller's `finally`, where + * throwing would mask a failing deploy's real error and turn a successful one + * into a false failure. + */ +export async function releaseLock(lockPath: string): Promise { + try { + await unlink(lockPath); + } catch (e) { + // Already gone (stale-broken by another waiter). Nothing to release. + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return; + // Anything else leaves the lock on disk, blocking other deploys until it + // ages past `staleMs`. Warn on stderr to keep `--json` stdout parseable. + process.emitWarning( + `Could not release the deployments lock at ${lockPath}: ${(e as Error).message}`, + ); + } +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/packages/deployer/src/services/wallet-cache.test.ts b/packages/deployer/src/services/wallet-cache.test.ts new file mode 100644 index 0000000..6ffb6bb --- /dev/null +++ b/packages/deployer/src/services/wallet-cache.test.ts @@ -0,0 +1,502 @@ +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import type { EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js'; +import { + WalletFactory, + WalletSaveStateProvider, +} from '@midnight-ntwrk/testkit-js'; +import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet'; +import { UnshieldedWallet } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; +import type { Logger } from 'pino'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WalletCache } from './wallet-cache.ts'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => Buffer.alloc(0)), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + copyFileSync: vi.fn(), + mkdirSync: vi.fn(), + chmodSync: vi.fn(), + }; +}); + +vi.mock('@midnight-ntwrk/testkit-js', () => ({ + DEFAULT_WALLET_STATE_DIRECTORY: './.states', + WalletFactory: { + createShieldedWallet: vi.fn(() => ({ tag: 'shielded-fresh' })), + createDustWallet: vi.fn(() => ({ tag: 'dust-fresh' })), + createUnshieldedWallet: vi.fn(() => ({ tag: 'unshielded-fresh' })), + restoreShieldedWallet: vi.fn(async () => ({ tag: 'shielded-restored' })), + }, + WalletSaveStateProvider: vi.fn(), +})); + +vi.mock('@midnight-ntwrk/wallet-sdk-dust-wallet', () => ({ + DustWallet: vi.fn(() => ({ + restore: vi.fn(() => ({ tag: 'dust-restored' })), + })), +})); + +vi.mock('@midnight-ntwrk/wallet-sdk-unshielded-wallet', () => ({ + UnshieldedWallet: vi.fn(() => ({ + restore: vi.fn(() => ({ tag: 'unshielded-restored' })), + })), +})); + +/** Pino-shaped logger whose methods are spies, freshly built per test. */ +function spyLogger(): Logger { + const logger: Record = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + level: 'silent', + }; + logger.child = (): Logger => spyLogger(); + return logger as unknown as Logger; +} + +function fakeEnv( + walletNetworkId: EnvironmentConfiguration['walletNetworkId'] = 'testnet', +): EnvironmentConfiguration { + return { walletNetworkId } as unknown as EnvironmentConfiguration; +} + +const DUST_OPTIONS = { + additionalFeeOverhead: 1000n, + ledgerParams: { tag: 'ledger-params' }, + feeBlocksMargin: 7, +} as unknown as Parameters< + WalletCache['loadOrCreateDustWallet'] +>[0]['dustOptions']; + +/** Stub the save-state provider's `load`/`save` for one test. */ +function wireSaveStateProvider(impl: { load?: unknown; save?: unknown }): void { + vi.mocked(WalletSaveStateProvider).mockImplementation(function ( + this: object, + ) { + Object.assign(this, { + load: impl.load ?? vi.fn(async () => 'serialized-state'), + save: impl.save ?? vi.fn(async () => undefined), + }); + } as unknown as new ( + ...args: unknown[] + ) => InstanceType); +} + +describe('WalletCache', () => { + let logger: Logger; + + function makeCache(env = fakeEnv()): WalletCache { + return new WalletCache({ + logger, + env, + shieldedSeed: new Uint8Array(32).fill(0x11), + dustSeed: new Uint8Array(32).fill(0x33), + unshieldedSeed: new Uint8Array(32).fill(0x22), + }); + } + + beforeEach(() => { + logger = spyLogger(); + vi.mocked(existsSync).mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('cache file paths', () => { + it('should derive per-kind seed-hashed paths under .states/', () => { + const cache = makeCache(fakeEnv('preview')); + expect(cache.shieldedCacheFilePath).toMatch( + /\.states\/preview-[0-9a-f]{16}-shielded\.gz$/, + ); + expect(cache.dustCacheFilePath).toMatch( + /\.states\/preview-[0-9a-f]{16}-dust\.gz$/, + ); + expect(cache.unshieldedCacheFilePath).toMatch( + /\.states\/preview-[0-9a-f]{16}-unshielded\.gz$/, + ); + }); + + it('should give every sub-wallet a distinct filename for its distinct seed', () => { + const cache = makeCache(); + const paths = [ + cache.shieldedCacheFilePath, + cache.dustCacheFilePath, + cache.unshieldedCacheFilePath, + ]; + expect(new Set(paths).size).toBe(3); + }); + }); + + describe('importSeedCache', () => { + it('should gzip a raw-JSON source into the seed-derived path', () => { + vi.mocked(readFileSync).mockReturnValue( + Buffer.from('{"state":"raw-json"}', 'utf8'), + ); + makeCache(fakeEnv('preview')).importSeedCache( + '/path/to/state.json', + 'dust', + ); + // Atomic write: payload lands in `.tmp`, then rename to + // ``. Asserts both halves so a future regression that + // skips the rename (or writes directly to target) fails loudly. + expect(writeFileSync).toHaveBeenCalled(); + const [tempPath, payload] = vi.mocked(writeFileSync).mock.calls[0] ?? []; + expect(String(tempPath)).toMatch(/preview-[0-9a-f]{16}-dust\.gz\.tmp$/); + // Payload was raw → must be gzipped on the way in (magic bytes). + const payloadBuf = payload as Buffer; + expect(payloadBuf[0]).toBe(0x1f); + expect(payloadBuf[1]).toBe(0x8b); + // rename(2) is atomic on POSIX within the same filesystem. + const [renameFrom, renameTo] = vi.mocked(renameSync).mock.calls[0] ?? []; + expect(String(renameFrom)).toBe(String(tempPath)); + expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-dust\.gz$/); + }); + + it('should pass a gzipped source through unchanged (no double-gzip)', () => { + const gzipped = gzipSync(Buffer.from('{"state":"raw-json"}', 'utf8')); + vi.mocked(readFileSync).mockReturnValue(gzipped); + makeCache().importSeedCache('/path/to/state.gz', 'dust'); + expect(vi.mocked(writeFileSync).mock.calls[0]?.[1]).toEqual(gzipped); + }); + + it('should write the imported cache with mode 0o600', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + makeCache().importSeedCache('/state.json', 'dust'); + // Owner-only from creation: the snapshot exposes the full UTXO set. + expect(writeFileSync).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + { mode: 0o600 }, + ); + }); + + it('should ensure the .states/ directory exists before writing', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + makeCache().importSeedCache('/state.json', 'dust'); + expect(mkdirSync).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ recursive: true }), + ); + }); + + it('should throw WalletError when the source file is missing', () => { + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw new Error('ENOENT: no such file'); + }); + expect(() => + makeCache().importSeedCache('/missing.json', 'dust'), + ).toThrow(/--seed-cache-from-dust:.*missing\.json/); + }); + + it('should back up an existing target cache to .bak before overwriting', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + vi.mocked(existsSync).mockReturnValue(true); + makeCache().importSeedCache('/state.json', 'dust'); + // copyFileSync MUST be called with (target, target.bak) so the + // previous cache bytes are preserved forever. If this assertion + // breaks, the safety net we promised the user is gone. + expect(copyFileSync).toHaveBeenCalledTimes(1); + const [src, dest] = vi.mocked(copyFileSync).mock.calls[0] ?? []; + expect(String(src)).toMatch(/-dust\.gz$/); + expect(String(dest)).toBe(`${String(src)}.bak`); + const sawBackupLog = vi + .mocked(logger.info) + .mock.calls.some((c) => + String(c[0]).includes('previous cache backed up to'), + ); + expect(sawBackupLog).toBe(true); + }); + + it('should NOT create a .bak when the target cache does not already exist', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + vi.mocked(existsSync).mockReturnValue(false); + makeCache().importSeedCache('/state.json', 'dust'); + expect(copyFileSync).not.toHaveBeenCalled(); + }); + + it('should route a shielded import to the matching -shielded.gz path', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + makeCache(fakeEnv('preview')).importSeedCache('/state.json', 'shielded'); + const renameTo = vi.mocked(renameSync).mock.calls[0]?.[1]; + expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-shielded\.gz$/); + }); + + it('should route an unshielded import to the matching -unshielded.gz path', () => { + vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + makeCache(fakeEnv('preview')).importSeedCache( + '/state.json', + 'unshielded', + ); + const renameTo = vi.mocked(renameSync).mock.calls[0]?.[1]; + expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-unshielded\.gz$/); + }); + }); + + describe('loadOrCreateShieldedWallet', () => { + const args = { + config: { tag: 'config' }, + seed: new Uint8Array(32).fill(0x11), + skipCache: false, + }; + + it('should build fresh when no cache file exists', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const wallet = await makeCache().loadOrCreateShieldedWallet(args); + expect(wallet).toEqual({ tag: 'shielded-fresh' }); + expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); + }); + + it('should restore from cache when the file exists', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({}); + const wallet = await makeCache().loadOrCreateShieldedWallet(args); + expect(WalletFactory.restoreShieldedWallet).toHaveBeenCalledWith( + args.config, + 'serialized-state', + ); + expect(wallet).toEqual({ tag: 'shielded-restored' }); + expect(WalletFactory.createShieldedWallet).not.toHaveBeenCalled(); + }); + + it('should skip the cache entirely when skipCache is set', async () => { + vi.mocked(existsSync).mockReturnValue(true); + await makeCache().loadOrCreateShieldedWallet({ + ...args, + skipCache: true, + }); + expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + 'Wallet cache disabled (--no-cache); doing fresh sync', + ); + }); + + it('should fall back to a fresh build when restore throws', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({ + load: vi.fn(async () => { + throw new Error('corrupt'); + }), + }); + await makeCache().loadOrCreateShieldedWallet(args); + expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'corrupt' }), + expect.stringContaining('Wallet cache restore failed'), + ); + }); + }); + + describe('loadOrCreateDustWallet', () => { + const args = { + config: { tag: 'config' }, + seed: new Uint8Array(32).fill(0x33), + dustOptions: DUST_OPTIONS, + skipCache: false, + }; + + it('should build fresh when no cache file exists', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const wallet = await makeCache().loadOrCreateDustWallet(args); + expect(wallet).toEqual({ tag: 'dust-fresh' }); + expect(DustWallet).not.toHaveBeenCalled(); + }); + + it('should restore from cache and re-apply the dust cost parameters', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({}); + const wallet = await makeCache().loadOrCreateDustWallet(args); + expect(wallet).toEqual({ tag: 'dust-restored' }); + // `costParameters` is not baked into the snapshot, so the restored + // wallet only honours our `additionalFeeOverhead` if the config the + // DustWallet class is built from carries it. + expect(DustWallet).toHaveBeenCalledWith( + expect.objectContaining({ + tag: 'config', + costParameters: { + ledgerParams: DUST_OPTIONS.ledgerParams, + additionalFeeOverhead: DUST_OPTIONS.additionalFeeOverhead, + feeBlocksMargin: DUST_OPTIONS.feeBlocksMargin, + }, + }), + ); + expect(WalletFactory.createDustWallet).not.toHaveBeenCalled(); + }); + + it('should skip the cache entirely when skipCache is set', async () => { + vi.mocked(existsSync).mockReturnValue(true); + await makeCache().loadOrCreateDustWallet({ ...args, skipCache: true }); + expect(DustWallet).not.toHaveBeenCalled(); + expect(WalletFactory.createDustWallet).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + 'Dust wallet cache disabled (--no-cache); doing fresh sync', + ); + }); + + it('should fall back to a fresh build when restore throws', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({ + load: vi.fn(async () => { + throw new Error('corrupt-dust'); + }), + }); + await makeCache().loadOrCreateDustWallet(args); + expect(WalletFactory.createDustWallet).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'corrupt-dust' }), + expect.stringContaining('Dust wallet cache restore failed'), + ); + }); + }); + + describe('loadOrCreateUnshieldedWallet', () => { + const args = { + config: { tag: 'config' }, + keystore: { tag: 'keystore' } as unknown as Parameters< + WalletCache['loadOrCreateUnshieldedWallet'] + >[0]['keystore'], + skipCache: false, + }; + + it('should build fresh from the keystore when no cache file exists', async () => { + vi.mocked(existsSync).mockReturnValue(false); + const wallet = await makeCache().loadOrCreateUnshieldedWallet(args); + expect(wallet).toEqual({ tag: 'unshielded-fresh' }); + expect(WalletFactory.createUnshieldedWallet).toHaveBeenCalledWith( + args.config, + args.keystore, + ); + expect(UnshieldedWallet).not.toHaveBeenCalled(); + }); + + it('should restore from cache without consulting the keystore', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({}); + const wallet = await makeCache().loadOrCreateUnshieldedWallet(args); + expect(wallet).toEqual({ tag: 'unshielded-restored' }); + // The snapshot carries the public key, so the restored wallet is + // built from the config alone. + expect(UnshieldedWallet).toHaveBeenCalledWith(args.config); + expect(WalletFactory.createUnshieldedWallet).not.toHaveBeenCalled(); + }); + + it('should skip the cache entirely when skipCache is set', async () => { + vi.mocked(existsSync).mockReturnValue(true); + await makeCache().loadOrCreateUnshieldedWallet({ + ...args, + skipCache: true, + }); + expect(UnshieldedWallet).not.toHaveBeenCalled(); + expect(WalletFactory.createUnshieldedWallet).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + 'Unshielded wallet cache disabled (--no-cache); doing fresh sync', + ); + }); + + it('should fall back to a fresh build when restore throws', async () => { + vi.mocked(existsSync).mockReturnValue(true); + wireSaveStateProvider({ + load: vi.fn(async () => { + throw new Error('corrupt-unshielded'); + }), + }); + await makeCache().loadOrCreateUnshieldedWallet(args); + expect(WalletFactory.createUnshieldedWallet).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'corrupt-unshielded' }), + expect.stringContaining('Unshielded wallet cache restore failed'), + ); + }); + }); + + describe('save', () => { + it('should snapshot all three sub-wallets and tighten them to 0o600', async () => { + const save = vi.fn(async () => undefined); + wireSaveStateProvider({ save }); + const cache = makeCache(); + await cache.save({ + shielded: { tag: 'sub-shielded' }, + dust: 'sub-dust', + unshielded: 'sub-unshielded', + }); + expect(save).toHaveBeenCalledWith({ tag: 'sub-shielded' }); + expect(save).toHaveBeenCalledWith('sub-dust'); + expect(save).toHaveBeenCalledWith('sub-unshielded'); + // WalletSaveStateProvider writes at the umask; the snapshot must end + // up owner-only. + expect(chmodSync).toHaveBeenCalledWith( + cache.shieldedCacheFilePath, + 0o600, + ); + expect(chmodSync).toHaveBeenCalledWith(cache.dustCacheFilePath, 0o600); + expect(chmodSync).toHaveBeenCalledWith( + cache.unshieldedCacheFilePath, + 0o600, + ); + }); + + it('should swallow a per-sub-wallet save failure with a warn log', async () => { + wireSaveStateProvider({ + save: vi.fn(async () => { + throw new Error('disk-full'); + }), + }); + await expect( + makeCache().save({ shielded: {}, dust: {}, unshielded: {} }), + ).resolves.toBeUndefined(); + for (const label of ['shielded', 'dust', 'unshielded']) { + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'disk-full', label }), + 'Wallet sub-wallet cache save failed; continuing', + ); + } + }); + + it('should keep snapshotting the other sub-wallets when one fails', async () => { + const save = vi.fn(async (sub: unknown) => { + if (sub === 'sub-dust') throw new Error('disk-full'); + }); + wireSaveStateProvider({ save }); + const cache = makeCache(); + await cache.save({ + shielded: 'sub-shielded', + dust: 'sub-dust', + unshielded: 'sub-unshielded', + }); + // allSettled, not all: the dust rejection must not cancel the two + // siblings' snapshots. + expect(save).toHaveBeenCalledTimes(3); + expect(chmodSync).toHaveBeenCalledWith( + cache.shieldedCacheFilePath, + 0o600, + ); + expect(chmodSync).toHaveBeenCalledWith( + cache.unshieldedCacheFilePath, + 0o600, + ); + expect(chmodSync).not.toHaveBeenCalledWith( + cache.dustCacheFilePath, + 0o600, + ); + }); + }); +}); diff --git a/packages/deployer/src/services/wallet-cache.ts b/packages/deployer/src/services/wallet-cache.ts new file mode 100644 index 0000000..246a3fe --- /dev/null +++ b/packages/deployer/src/services/wallet-cache.ts @@ -0,0 +1,370 @@ +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { + DEFAULT_WALLET_STATE_DIRECTORY, + type DustWalletOptions, + type EnvironmentConfiguration, + WalletFactory, + WalletSaveStateProvider, +} from '@midnight-ntwrk/testkit-js'; +import { + DustWallet, + type DustWalletAPI, +} from '@midnight-ntwrk/wallet-sdk-dust-wallet'; +import type { ShieldedWalletAPI } from '@midnight-ntwrk/wallet-sdk-shielded'; +import { + type UnshieldedKeystore, + UnshieldedWallet, + type UnshieldedWalletAPI, +} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; +import type { Logger } from 'pino'; +import { WalletError } from '../errors.ts'; + +/** Opaque testkit-js `FluentWalletBuilder.config` (not exported by testkit). */ +export type ConfigShape = unknown; + +/** Sub-wallet a snapshot belongs to. Snapshots are not interchangeable across kinds. */ +export type SubWalletKind = 'shielded' | 'dust' | 'unshielded'; + +export interface WalletCacheOptions { + logger: Logger; + env: EnvironmentConfiguration; + shieldedSeed: Uint8Array; + dustSeed: Uint8Array; + unshieldedSeed: Uint8Array; +} + +/** + * On-disk persistence for the three sub-wallets: seed-derived `.states/` + * paths, pre-warmed cache import, restore-or-build, and post-sync + * snapshots. + * + * Shielded and dust are cached because they are slow to sync on real + * networks (shielded trial-decrypts every note; dust streams the global + * unfiltered ledger event log). Unshielded is cheap to scan but the sync + * gate waits on its progress too, so an uncached unshielded sub-wallet + * re-syncs from genesis and becomes the long pole on every cached boot. + */ +export class WalletCache { + /** Seed-derived `.states/` path for the shielded snapshot. */ + readonly shieldedCacheFilePath: string; + /** Seed-derived `.states/` path for the dust snapshot. */ + readonly dustCacheFilePath: string; + /** Seed-derived `.states/` path for the unshielded snapshot. */ + readonly unshieldedCacheFilePath: string; + readonly #logger: Logger; + + constructor(opts: WalletCacheOptions) { + this.#logger = opts.logger; + this.shieldedCacheFilePath = computeCacheFilePath( + opts.env, + opts.shieldedSeed, + 'shielded', + ); + this.dustCacheFilePath = computeCacheFilePath( + opts.env, + opts.dustSeed, + 'dust', + ); + this.unshieldedCacheFilePath = computeCacheFilePath( + opts.env, + opts.unshieldedSeed, + 'unshielded', + ); + } + + /** + * Drop a user-supplied wallet-state file into `.states/` under the + * seed-derived filename so the existing restore path picks it up. + * Detects gzip via magic bytes (`0x1f 0x8b`); raw JSON is gzipped on + * the way in. Throws {@link WalletError} on read failure so a bad path + * fails fast instead of silently doing a cold sync from genesis. + * + * Safety guarantees: + * 1. **Source is read-only.** Only `readFileSync` touches `srcPath`. + * 2. **Backup is preserved forever.** If the target `.gz` already + * exists, it is `copyFileSync`'d to `.bak` *before* any + * write — never deleted, never overwritten by this helper. A user + * who hits a bad-format import can roll back with + * `mv .bak ` and re-run. + * 3. **Write is atomic.** New bytes land in `.tmp` first, + * then `rename(2)` over the final path. POSIX rename is atomic + * within the same filesystem, so a mid-write crash can never leave + * the existing cache half-overwritten. A stale `.tmp` left by a + * failed rename is harmless (cache load only scans `.gz`) and gets + * overwritten by the next import attempt. + */ + importSeedCache(srcPath: string, kind: SubWalletKind): void { + const targetPath = { + shielded: this.shieldedCacheFilePath, + dust: this.dustCacheFilePath, + unshielded: this.unshieldedCacheFilePath, + }[kind]; + const absoluteSrc = resolve(process.cwd(), srcPath); + let bytes: Buffer; + try { + bytes = readFileSync(absoluteSrc); + } catch (e) { + throw new WalletError( + `--seed-cache-from-${kind}: cannot read ${absoluteSrc}: ${(e as Error).message}`, + ); + } + const isGzipped = + bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; + const payload = isGzipped ? bytes : gzipSync(bytes); + const backupPath = `${targetPath}.bak`; + const tempPath = `${targetPath}.tmp`; + mkdirSync(pathDir(targetPath), { recursive: true }); + // Preserve the previous cache forever as `.bak` so a + // bad-format import is always recoverable by hand. We use copy (not + // rename) so the live `` keeps its bytes throughout the + // window before the atomic rename below. + const backedUp = existsSync(targetPath); + if (backedUp) { + copyFileSync(targetPath, backupPath); + } + // 0o600: a wallet state snapshot exposes the full UTXO set and every + // derived address. Set at create time so the bytes are never group- or + // world-readable, not even between write and rename. + writeFileSync(tempPath, payload, { mode: 0o600 }); + renameSync(tempPath, targetPath); + this.#logger.info( + `Imported ${kind} cache: ${absoluteSrc} → ${targetPath}${ + backedUp ? ` (previous cache backed up to ${backupPath})` : '' + }`, + ); + } + + async loadOrCreateShieldedWallet(args: { + config: ConfigShape; + seed: Uint8Array; + skipCache: boolean; + }): Promise { + const { config, seed, skipCache } = args; + const logger = this.#logger; + const cacheFilePath = this.shieldedCacheFilePath; + + if (!skipCache && existsSync(cacheFilePath)) { + try { + const dir = pathDir(cacheFilePath); + const filename = pathBase(cacheFilePath); + const loader = new WalletSaveStateProvider(logger, '', dir, filename); + const serializedState = await loader.load(); + const restored = await WalletFactory.restoreShieldedWallet( + config as Parameters[0], + serializedState, + ); + logger.info(`Restored wallet state from ${cacheFilePath}`); + return restored as ShieldedWalletAPI; + } catch (e) { + logger.warn( + { err: (e as Error).message, cacheFilePath }, + 'Wallet cache restore failed; falling back to fresh sync', + ); + } + } else if (skipCache) { + logger.info('Wallet cache disabled (--no-cache); doing fresh sync'); + } + + return WalletFactory.createShieldedWallet( + config as Parameters[0], + seed, + ) as ShieldedWalletAPI; + } + + /** + * Restore dust wallet from cache, else build fresh. Routes through + * `DustWallet(config).restore(...)` because testkit doesn't expose a + * `WalletFactory.restoreDustWallet`. Caching turns preprod's 1 h+ + * first-run dust sync into seconds on subsequent boots. + */ + async loadOrCreateDustWallet(args: { + config: ConfigShape; + seed: Uint8Array; + dustOptions: DustWalletOptions; + skipCache: boolean; + }): Promise { + const { config, seed, dustOptions, skipCache } = args; + const logger = this.#logger; + const cacheFilePath = this.dustCacheFilePath; + + if (!skipCache && existsSync(cacheFilePath)) { + try { + const dir = pathDir(cacheFilePath); + const filename = pathBase(cacheFilePath); + const loader = new WalletSaveStateProvider(logger, '', dir, filename); + const serializedState = await loader.load(); + // `costParameters` is runtime state on the builder, not baked + // into the snapshot. Re-apply `dustOptions` so the restored + // wallet honours our `additionalFeeOverhead` override. + const dustConfig = buildDustConfig(config, dustOptions); + const dustClass = DustWallet( + dustConfig as Parameters[0], + ); + const restored = dustClass.restore(serializedState); + logger.info(`Restored dust wallet state from ${cacheFilePath}`); + return restored as unknown as DustWalletAPI; + } catch (e) { + logger.warn( + { err: (e as Error).message, cacheFilePath }, + 'Dust wallet cache restore failed; falling back to fresh sync', + ); + } + } else if (skipCache) { + logger.info('Dust wallet cache disabled (--no-cache); doing fresh sync'); + } + + return WalletFactory.createDustWallet( + config as Parameters[0], + seed, + dustOptions, + ); + } + + /** + * Restore the unshielded wallet from cache, else build fresh. Routes + * through `UnshieldedWallet(config).restore(...)` because testkit + * doesn't expose a `WalletFactory.restoreUnshieldedWallet`. The + * snapshot carries the public key, so no keystore is needed to + * rehydrate; the keystore is only used on the fresh path. + */ + async loadOrCreateUnshieldedWallet(args: { + config: ConfigShape; + keystore: UnshieldedKeystore; + skipCache: boolean; + }): Promise { + const { config, keystore, skipCache } = args; + const logger = this.#logger; + const cacheFilePath = this.unshieldedCacheFilePath; + + if (!skipCache && existsSync(cacheFilePath)) { + try { + const dir = pathDir(cacheFilePath); + const filename = pathBase(cacheFilePath); + const loader = new WalletSaveStateProvider(logger, '', dir, filename); + const serializedState = await loader.load(); + const unshieldedClass = UnshieldedWallet( + config as Parameters[0], + ); + const restored = unshieldedClass.restore(serializedState); + logger.info(`Restored unshielded wallet state from ${cacheFilePath}`); + return restored; + } catch (e) { + logger.warn( + { err: (e as Error).message, cacheFilePath }, + 'Unshielded wallet cache restore failed; falling back to fresh sync', + ); + } + } else if (skipCache) { + logger.info( + 'Unshielded wallet cache disabled (--no-cache); doing fresh sync', + ); + } + + return WalletFactory.createUnshieldedWallet( + config as Parameters[0], + keystore as Parameters[1], + ); + } + + /** + * Snapshot every sub-wallet to disk. Best-effort and independent per + * sub-wallet: one failure never blocks the others. + */ + async save(wallet: { + shielded: unknown; + dust: unknown; + unshielded: unknown; + }): Promise { + await Promise.allSettled([ + this.#saveSubWalletCache( + this.shieldedCacheFilePath, + wallet.shielded, + 'shielded', + ), + this.#saveSubWalletCache(this.dustCacheFilePath, wallet.dust, 'dust'), + this.#saveSubWalletCache( + this.unshieldedCacheFilePath, + wallet.unshielded, + 'unshielded', + ), + ]); + } + + async #saveSubWalletCache( + filePath: string, + subWallet: unknown, + label: string, + ): Promise { + try { + const dir = pathDir(filePath); + const filename = pathBase(filePath); + // `seed` param only feeds the default filename; we pass an + // explicit one, so the empty string is fine. + const saver = new WalletSaveStateProvider( + this.#logger, + '', + dir, + filename, + ); + await saver.save(subWallet as Parameters[0]); + // WalletSaveStateProvider writes at the process umask and offers no + // mode control, so tighten the snapshot after the fact. + chmodSync(filePath, 0o600); + } catch (e) { + this.#logger.warn( + { err: (e as Error).message, label, filePath }, + 'Wallet sub-wallet cache save failed; continuing', + ); + } + } +} + +/** + * `--.gz`. Per-kind suffix prevents + * cross-loading one sub-wallet's snapshot into another (different state + * schemas). Don't reuse testkit's helper: it embeds the seed verbatim + * and gates the network on env vars instead of runtime ID. + */ +function computeCacheFilePath( + env: EnvironmentConfiguration, + seed: Uint8Array, + kind: SubWalletKind, +): string { + const seedHash = createHash('sha256').update(seed).digest('hex').slice(0, 16); + const filename = `${env.walletNetworkId}-${seedHash}-${kind}.gz`; + return resolve(process.cwd(), DEFAULT_WALLET_STATE_DIRECTORY, filename); +} + +/** Layer `dustOptions` onto the base config so cache-restored wallets honour `additionalFeeOverhead`. */ +function buildDustConfig( + config: ConfigShape, + dustOptions: DustWalletOptions, +): ConfigShape { + return { + ...(config as Record), + costParameters: { + ledgerParams: dustOptions.ledgerParams, + additionalFeeOverhead: dustOptions.additionalFeeOverhead, + feeBlocksMargin: dustOptions.feeBlocksMargin, + }, + } as ConfigShape; +} + +function pathDir(p: string): string { + return dirname(p); +} + +function pathBase(p: string): string { + return basename(p); +} diff --git a/packages/deployer/src/services/wallet-sync.test.ts b/packages/deployer/src/services/wallet-sync.test.ts new file mode 100644 index 0000000..b718ec8 --- /dev/null +++ b/packages/deployer/src/services/wallet-sync.test.ts @@ -0,0 +1,365 @@ +import type { MidnightWalletProvider } from '@midnight-ntwrk/testkit-js'; +import pino from 'pino'; +import * as Rx from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; +import { UnfundedWalletError } from '../errors.ts'; +import { + describeProgress, + logWalletAddresses, + syncAndVerifyFunds, +} from './wallet-sync.ts'; + +// Identity-throttle so the progress + checkpoint subscriptions fire on the +// first state emission instead of waiting 30 s / 5 min in real wall-clock +// for the trailing tick. +vi.mock('rxjs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + throttleTime: + () => + (src: import('rxjs').Observable): import('rxjs').Observable => + src, + }; +}); + +vi.mock('@midnight-ntwrk/midnight-js-network-id', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('@midnight-ntwrk/midnight-js-network-id') + >(); + return { + ...actual, + // logWalletAddresses passes whatever this returns to the codec + // mock which ignores the arg. Opaque value is fine. + getNetworkId: vi.fn(() => 0), + }; +}); + +// Stub the bech32 codec triplet so `logWalletAddresses` reaches its +// happy-path info logs instead of catching at the encode call. +vi.mock('@midnight-ntwrk/wallet-sdk-address-format', () => { + const codec = { + encode: vi.fn(() => ({ toString: () => 'addr1stub' })), + }; + return { + ShieldedAddress: { codec }, + UnshieldedAddress: { codec }, + DustAddress: { codec }, + }; +}); + +const silentLogger = pino({ level: 'silent' }); + +/** Pino at silent level with `info`/`warn` swapped for spies. */ +function spyLogger(): { logger: typeof silentLogger; info: Mock; warn: Mock } { + const info = vi.fn(); + const warn = vi.fn(); + const logger = pino({ level: 'silent' }); + Object.assign(logger, { info, warn }); + return { logger, info, warn }; +} + +type Mock = ReturnType; + +/** Balance map that reports the same amount for every token key. */ +function anyToken(amount: bigint): Record { + return new Proxy({} as Record, { get: () => amount }); +} + +interface ProgressStub { + strictlyComplete?: boolean; + withinGap?: boolean; +} + +function progress(stub: ProgressStub = {}): unknown { + return { + isStrictlyComplete: () => stub.strictlyComplete ?? true, + isCompleteWithin: () => stub.withinGap ?? true, + }; +} + +/** One `FacadeState` with caller-supplied balances and per-sub-wallet progress. */ +function facadeState( + opts: { + shielded?: Record; + unshielded?: Record; + shieldedProgress?: ProgressStub; + unshieldedProgress?: ProgressStub; + dustProgress?: ProgressStub; + } = {}, +): unknown { + return { + shielded: { + balances: opts.shielded ?? anyToken(1n), + state: { progress: progress(opts.shieldedProgress) }, + }, + unshielded: { + balances: opts.unshielded ?? anyToken(1n), + progress: progress(opts.unshieldedProgress), + }, + dust: { + state: { progress: progress(opts.dustProgress) }, + balance: () => 1n, + }, + }; +} + +function fakeWallet( + state$: Rx.Observable, + coinKey = '0xCOIN', +): MidnightWalletProvider { + const addr = Rx.of({ address: 'addr-bytes' }); + return { + getCoinPublicKey: () => coinKey, + wallet: { + state: () => state$, + shielded: { state: addr }, + unshielded: { state: addr }, + dust: { state: addr }, + }, + } as unknown as MidnightWalletProvider; +} + +describe('describeProgress', () => { + it('should render a percentage from the shielded/dust index fields', () => { + expect( + describeProgress({ + isStrictlyComplete: () => false, + appliedIndex: 10n, + highestIndex: 100n, + isConnected: true, + } as never), + ).toBe('10/100 (10%) connected=true complete=false'); + }); + + it('should render a percentage from the unshielded id fields', () => { + expect( + describeProgress({ + isStrictlyComplete: () => true, + appliedId: 5n, + highestTransactionId: 50n, + isConnected: true, + } as never), + ).toBe('5/50 (10%) connected=true complete=true'); + }); + + it('should report an unknown tip before the indexer reports a max event id', () => { + // highest=0 means "indexer has not told us the tip yet"; surfacing the + // connection state lets a user tell "connecting" from "no events yet". + expect(describeProgress({ isStrictlyComplete: () => false } as never)).toBe( + 'applied=0 highest=? connected=false complete=false', + ); + }); +}); + +describe('logWalletAddresses', () => { + it('should log the three bech32m addresses on the happy path', async () => { + const { logger, info } = spyLogger(); + await logWalletAddresses(fakeWallet(Rx.NEVER), logger); + expect(info).toHaveBeenCalledWith( + 'Wallet addresses (verify these match your seed):', + ); + expect(info).toHaveBeenCalledWith( + expect.stringMatching(/shielded:.*addr1stub/), + ); + expect(info).toHaveBeenCalledWith( + expect.stringMatching(/unshielded:.*addr1stub/), + ); + expect(info).toHaveBeenCalledWith( + expect.stringMatching(/dust:.*addr1stub/), + ); + }); + + it('should warn and continue when a sub-wallet state cannot be read', async () => { + const { logger, warn } = spyLogger(); + const wallet = fakeWallet(Rx.NEVER); + wallet.wallet.shielded = { + state: Rx.throwError(() => new Error('no state')), + } as never; + await expect(logWalletAddresses(wallet, logger)).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ err: 'no state' }), + 'Could not derive wallet addresses for display; continuing', + ); + }); +}); + +describe('syncAndVerifyFunds', () => { + const base = { timeoutMs: 1000, logger: silentLogger }; + + it('should reject with a timeout error when the wallet never reaches chain tip', async () => { + await expect( + syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(Rx.NEVER, '0xSTUCK'), + timeoutMs: 50, + }), + ).rejects.toThrow(/Wallet sync timeout after 50ms/); + }); + + it('should complete when sub-wallets are within the gap but not strictly complete', async () => { + // Live-chain shape (issue #115): the global dust stream keeps + // advancing, so dust is never strictly complete (gap 0) but settles + // within the tolerated gap. The old strict `isSynced` gate would hang + // here forever; the tolerant `isCompleteWithin` gate must proceed. + const state = facadeState({ + dustProgress: { strictlyComplete: false, withinGap: true }, + }); + await expect( + syncAndVerifyFunds({ ...base, wallet: fakeWallet(Rx.of(state)) }), + ).resolves.toBeUndefined(); + }); + + it('should NOT complete while any sub-wallet is outside the gap', async () => { + // Dust still outside the tolerated gap: the gate must keep waiting and + // ultimately time out rather than deploy against a half-synced wallet. + const state = facadeState({ + dustProgress: { strictlyComplete: false, withinGap: false }, + }); + await expect( + syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(Rx.concat(Rx.of(state), Rx.NEVER)), + timeoutMs: 50, + }), + ).rejects.toThrow(/Wallet sync timeout after 50ms/); + }); + + it('should throw UnfundedWalletError when shielded and unshielded balances are both empty', async () => { + const state = facadeState({ shielded: {}, unshielded: {} }); + await expect( + syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(Rx.of(state), '0xEMPTY'), + }), + ).rejects.toBeInstanceOf(UnfundedWalletError); + }); + + it('should NOT throw when only the unshielded side has a positive balance', async () => { + const state = facadeState({ shielded: {}, unshielded: anyToken(5n) }); + await expect( + syncAndVerifyFunds({ ...base, wallet: fakeWallet(Rx.of(state)) }), + ).resolves.toBeUndefined(); + }); + + it('should NOT throw when only the shielded side has a positive balance', async () => { + const state = facadeState({ shielded: anyToken(3n), unshielded: {} }); + await expect( + syncAndVerifyFunds({ ...base, wallet: fakeWallet(Rx.of(state)) }), + ).resolves.toBeUndefined(); + }); + + it('should log per-sub-wallet completion and mid-sync progress lines', async () => { + const { logger, info } = spyLogger(); + const mid = facadeState({ + shieldedProgress: { strictlyComplete: false, withinGap: false }, + unshieldedProgress: { strictlyComplete: false, withinGap: false }, + dustProgress: { strictlyComplete: false, withinGap: false }, + }); + await syncAndVerifyFunds({ + ...base, + logger, + wallet: fakeWallet(Rx.of(mid, facadeState())), + }); + expect(info).toHaveBeenCalledWith(expect.stringContaining('Still syncing')); + expect(info).toHaveBeenCalledWith( + expect.stringContaining('Unshielded sync complete'), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining('Dust sync complete'), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining('Shielded sync complete'), + ); + }); + + it('should render elapsed time as minutes and seconds past the first minute', async () => { + const { logger, info } = spyLogger(); + const state$ = new Rx.Subject(); + let now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + try { + const pending = syncAndVerifyFunds({ + ...base, + logger, + wallet: fakeWallet(state$), + }); + now += 90_000; + state$.next(facadeState()); + await pending; + } finally { + clock.mockRestore(); + } + expect(info).toHaveBeenCalledWith( + expect.stringContaining('Still syncing (1m 30s elapsed)'), + ); + expect(info).toHaveBeenCalledWith('Sync complete after 1m 30s'); + }); + + it('should invoke onCheckpoint on a state emission', async () => { + const onCheckpoint = vi.fn(async () => undefined); + await syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(Rx.of(facadeState())), + onCheckpoint, + }); + expect(onCheckpoint).toHaveBeenCalledTimes(1); + }); + + it('should not start a second checkpoint while the first is still in flight', async () => { + // Re-entrancy guard: two emissions arrive before the first save + // settles. A second concurrent snapshot would race the first over + // the same cache file. + let release: () => void = () => undefined; + const inFlight = new Promise((r) => { + release = r; + }); + const onCheckpoint = vi.fn(() => inFlight); + await syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(Rx.of(facadeState(), facadeState())), + onCheckpoint, + }); + expect(onCheckpoint).toHaveBeenCalledTimes(1); + release(); + await inFlight; + }); + + it('should tear down every subscription once the tip gate resolves', async () => { + const state$ = new Rx.Subject(); + const pending = syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(state$), + onCheckpoint: vi.fn(async () => undefined), + }); + expect(state$.observed).toBe(true); + state$.next(facadeState()); + await pending; + // A surviving subscription leaks the wallet's rxjs pipeline for the + // rest of the process. + expect(state$.observed).toBe(false); + }); + + it('should tear down every subscription when the tip gate times out', async () => { + const state$ = new Rx.Subject(); + const pending = syncAndVerifyFunds({ + ...base, + wallet: fakeWallet(state$), + timeoutMs: 20, + onCheckpoint: vi.fn(async () => undefined), + }); + await expect(pending).rejects.toThrow(/Wallet sync timeout/); + expect(state$.observed).toBe(false); + }); + + it('should not subscribe a checkpoint tap when onCheckpoint is omitted', async () => { + const state$ = new Rx.Subject(); + const pending = syncAndVerifyFunds({ ...base, wallet: fakeWallet(state$) }); + // Progress tap, balance tap and the tip gate: three subscribers, one + // fewer than the owned-wallet path that also checkpoints. + expect(state$.observers).toHaveLength(3); + state$.next(facadeState()); + await pending; + }); +}); diff --git a/packages/deployer/src/services/wallet-sync.ts b/packages/deployer/src/services/wallet-sync.ts new file mode 100644 index 0000000..7268ef4 --- /dev/null +++ b/packages/deployer/src/services/wallet-sync.ts @@ -0,0 +1,265 @@ +import { shieldedToken, unshieldedToken } from '@midnight-ntwrk/ledger-v8'; +import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id'; +import type { MidnightWalletProvider } from '@midnight-ntwrk/testkit-js'; +import { + DustAddress, + ShieldedAddress, + UnshieldedAddress, +} from '@midnight-ntwrk/wallet-sdk-address-format'; +import type { FacadeState } from '@midnight-ntwrk/wallet-sdk-facade'; +import type { Logger } from 'pino'; +import * as Rx from 'rxjs'; +import { UnfundedWalletError } from '../errors.ts'; + +/** + * Default sync ceiling (10 min). Overrides testkit-js's hardcoded 90 s + * `waitForFunds` timeout, which is too short for real networks. + */ +export const DEFAULT_SYNC_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Tolerated sync gap, in events, for the chain-tip gate. + * `FacadeState.isSynced` requires every sub-wallet to be *strictly* + * complete (gap 0). On a live network the global dust stream advances + * continuously, so the dust wallet sits a few events behind the tip + * indefinitely and strict `isSynced` never flips. The gate would then time + * out on a wallet that is in fact fully usable. `isCompleteWithin(50)` is the + * SDK default (and what the unshielded wallet uses internally): it treats + * "within 50 events of tip" as synced, which a live wallet reaches and + * holds. See OpenZeppelin/compact-tools#115. + */ +export const SYNC_MAX_GAP = 50n; + +/** + * Print the wallet's three bech32m addresses so the user can verify + * the seed before a long sync. Best-effort: warn-and-continue on + * failure. + */ +export async function logWalletAddresses( + wallet: MidnightWalletProvider, + logger: Logger, +): Promise { + try { + const networkId = getNetworkId(); + const [shieldedState, unshieldedState, dustState] = await Promise.all([ + Rx.firstValueFrom(wallet.wallet.shielded.state), + Rx.firstValueFrom(wallet.wallet.unshielded.state), + Rx.firstValueFrom(wallet.wallet.dust.state), + ]); + const shielded = ShieldedAddress.codec + .encode(networkId, shieldedState.address) + .toString(); + const unshielded = UnshieldedAddress.codec + .encode(networkId, unshieldedState.address) + .toString(); + const dust = DustAddress.codec + .encode(networkId, dustState.address) + .toString(); + logger.info('Wallet addresses (verify these match your seed):'); + logger.info(` shielded: ${shielded}`); + logger.info(` unshielded: ${unshielded}`); + logger.info(` dust: ${dust}`); + } catch (e) { + logger.warn( + { err: (e as Error).message }, + 'Could not derive wallet addresses for display; continuing', + ); + } +} + +/** + * One-liner progress string for "Still syncing". Accepts both progress + * shapes (shielded/dust use `appliedIndex`/`highestIndex`; unshielded + * uses `appliedId`/`highestTransactionId`). + */ +export function describeProgress(p: { + isStrictlyComplete: () => boolean; +}): string { + const complete = p.isStrictlyComplete(); + const fields = p as unknown as { + appliedIndex?: bigint; + highestIndex?: bigint; + highestRelevantIndex?: bigint; + appliedId?: bigint; + highestTransactionId?: bigint; + isConnected?: boolean; + }; + const applied = fields.appliedIndex ?? fields.appliedId ?? 0n; + const highest = fields.highestIndex ?? fields.highestTransactionId ?? 0n; + const connected = fields.isConnected ?? false; + // Once the indexer has told the wallet its max event id, we can + // render a real progress percentage. Until then surface "applied, + // highest unknown" and the subscription's connection state so the + // user can tell "still connecting" from "connected but no events yet" + // from "events flowing". + if (highest === 0n) { + return `applied=${applied} highest=? connected=${connected} complete=${complete}`; + } + const pct = Number((applied * 100n) / highest); + return `${applied}/${highest} (${pct}%) connected=${connected} complete=${complete}`; +} + +export interface SyncAndVerifyFundsArgs { + wallet: MidnightWalletProvider; + timeoutMs: number; + logger: Logger; + /** Periodic checkpoint so a Ctrl+C mid-sync survives. Owned-wallet branch only. */ + onCheckpoint?: () => Promise; +} + +/** + * Drive the wallet to chain tip and assert spendable funds. Gates on each + * sub-wallet being within {@link SYNC_MAX_GAP} events of the tip rather + * than strictly complete: on a live network the global dust stream never + * settles to gap 0, so strict `FacadeState.isSynced` never fires and the + * gate times out on a perfectly usable wallet (#115). The gate still waits + * on all three sub-wallets; dropping the dust/shielded wait entirely + * regressed on local with `Invalid Transaction (custom error 170)`. + * Throttles progress logs to once per 30 s. Throws + * {@link UnfundedWalletError} on empty wallet. + */ +export async function syncAndVerifyFunds( + args: SyncAndVerifyFundsArgs, +): Promise { + const { wallet, timeoutMs, logger, onCheckpoint } = args; + logger.info( + `Syncing wallet to chain tip (timeout ${Math.round(timeoutMs / 1000)}s)…`, + ); + const start = Date.now(); + + // Two subscriptions to the same observable: one logs throttled + // progress lines for UX, the other waits for completion. The progress + // tap deliberately runs through `Rx.throttleTime(30_000)` so the + // shielded-sync flood doesn't drown the terminal; the completion gate + // doesn't throttle, so the deploy proceeds the instant sync flips. + const state$ = wallet.wallet.state(); + const progressSub = state$ + .pipe( + Rx.throttleTime(30_000, undefined, { leading: false, trailing: true }), + ) + .subscribe((s) => { + const elapsedSec = Math.round((Date.now() - start) / 1000); + const elapsedHms = + elapsedSec < 60 + ? `${elapsedSec}s` + : `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`; + // Pull running balance projections each tick so the user can + // see funds materialise mid-sync (NIGHT becomes visible the + // moment unshielded completes; dust accumulates as the wallet + // processes events even before its sync is strictly complete). + const shieldedBal = s.shielded.balances[shieldedToken().raw] ?? 0n; + const unshieldedBal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; + const dustBal = s.dust.balance(new Date()); + logger.info( + `Still syncing (${elapsedHms} elapsed). ` + + `shielded ${describeProgress(s.shielded.state.progress)} balance=${shieldedBal}; ` + + `unshielded ${describeProgress(s.unshielded.progress)} balance=${unshieldedBal}; ` + + `dust ${describeProgress(s.dust.state.progress)} balance=${dustBal}`, + ); + }); + + // Periodic checkpoint: snapshot the wallet caches every 5 min so a + // user who Ctrl+C's a long preprod first-run can resume from the + // latest snapshot instead of starting at id=0 again. Best-effort: + // a failed save logs a warning and the sync keeps going. Skipped + // when `onCheckpoint` is not provided (i.e. injected-wallet callers + // where the deployer doesn't own persistence). + let checkpointInFlight = false; + const checkpointSub = onCheckpoint + ? state$ + .pipe( + Rx.throttleTime(5 * 60 * 1000, undefined, { + leading: false, + trailing: true, + }), + ) + .subscribe(() => { + if (checkpointInFlight) return; + checkpointInFlight = true; + onCheckpoint().finally(() => { + checkpointInFlight = false; + }); + }) + : undefined; + + // Per-sub-wallet edge-trigger: the first time each sub-wallet flips + // to `complete=true`, log its current balance immediately. This lets + // a user with NIGHT+dust (the typical preprod-faucet wallet shape) + // see their unshielded balance after ~30 s instead of waiting for + // the full shielded sync (30 – 60 min) to surface anything. + const seenComplete = { shielded: false, unshielded: false, dust: false }; + const balanceSub = state$.subscribe((s) => { + if ( + !seenComplete.unshielded && + s.unshielded.progress.isStrictlyComplete() + ) { + seenComplete.unshielded = true; + const bal = s.unshielded.balances[unshieldedToken().raw] ?? 0n; + logger.info(`Unshielded sync complete — NIGHT balance: ${bal}`); + } + if (!seenComplete.dust && s.dust.state.progress.isStrictlyComplete()) { + seenComplete.dust = true; + const bal = s.dust.balance(new Date()); + logger.info(`Dust sync complete — dust balance: ${bal}`); + } + if ( + !seenComplete.shielded && + s.shielded.state.progress.isStrictlyComplete() + ) { + seenComplete.shielded = true; + const bal = s.shielded.balances[shieldedToken().raw] ?? 0n; + logger.info(`Shielded sync complete — shielded balance: ${bal}`); + } + }); + + let synced: FacadeState; + try { + synced = await Rx.firstValueFrom( + state$.pipe( + // Tolerant tip gate: all three sub-wallets within SYNC_MAX_GAP + // events of the tip. Strict `s.isSynced` never fires on a live + // chain because the global dust stream keeps advancing (#115). + Rx.filter( + (s: FacadeState) => + s.shielded.state.progress.isCompleteWithin(SYNC_MAX_GAP) && + s.dust.state.progress.isCompleteWithin(SYNC_MAX_GAP) && + s.unshielded.progress.isCompleteWithin(SYNC_MAX_GAP), + ), + Rx.timeout({ + each: timeoutMs, + with: () => + Rx.throwError( + () => new Error(`Wallet sync timeout after ${timeoutMs}ms`), + ), + }), + ), + ); + } finally { + progressSub.unsubscribe(); + balanceSub.unsubscribe(); + checkpointSub?.unsubscribe(); + } + + const totalSec = Math.round((Date.now() - start) / 1000); + const totalHms = + totalSec < 60 + ? `${totalSec}s` + : `${Math.floor(totalSec / 60)}m ${totalSec % 60}s`; + logger.info(`Sync complete after ${totalHms}`); + + // Accept funds in either shielded or unshielded. Preprod faucets + // hand out unshielded NIGHT, while a freshly bridged wallet may sit + // entirely in the shielded layer. Both are deployable: dust for + // fees auto-generates from either NIGHT or shielded holdings. + // Mirrors midnight-apps's `waitForUnshieldedFunds` semantics. + const shieldedBal = synced.shielded.balances[shieldedToken().raw]; + const unshieldedBal = synced.unshielded.balances[unshieldedToken().raw]; + const hasShielded = shieldedBal !== undefined && shieldedBal > 0n; + const hasUnshielded = unshieldedBal !== undefined && unshieldedBal > 0n; + if (!hasShielded && !hasUnshielded) { + throw new UnfundedWalletError(wallet.getCoinPublicKey()); + } + logger.info( + `Wallet balance: shielded=${shieldedBal ?? 0n}, unshielded=${unshieldedBal ?? 0n}`, + ); +} diff --git a/packages/deployer/src/wallet/handler.test.ts b/packages/deployer/src/wallet/handler.test.ts index bdb4a65..c735540 100644 --- a/packages/deployer/src/wallet/handler.test.ts +++ b/packages/deployer/src/wallet/handler.test.ts @@ -1,13 +1,10 @@ import { chmodSync, - copyFileSync, existsSync, - mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs'; -import { gzipSync } from 'node:zlib'; import type { EnvironmentConfiguration, MidnightWalletProvider, @@ -20,7 +17,10 @@ import { WalletSaveStateProvider, WalletSeeds, } from '@midnight-ntwrk/testkit-js'; -import { createKeystore } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; +import { + createKeystore, + UnshieldedWallet, +} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; import type { Logger } from 'pino'; import { afterEach, @@ -76,6 +76,9 @@ vi.mock('@midnight-ntwrk/testkit-js', () => ({ vi.mock('@midnight-ntwrk/wallet-sdk-unshielded-wallet', () => ({ createKeystore: vi.fn(() => ({ tag: 'keystore' })), + UnshieldedWallet: vi.fn(() => ({ + restore: vi.fn(() => ({ tag: 'unshielded-restored' })), + })), })); vi.mock('@midnight-ntwrk/ledger-v8', () => ({ @@ -85,12 +88,20 @@ vi.mock('@midnight-ntwrk/ledger-v8', () => ({ interface FakeProvider { stop: Mock; - wallet: { shielded: { tag: string } }; + wallet: { + shielded: { tag: string }; + dust: { tag: string }; + unshielded: { tag: string }; + }; } function fakeProvider(opts: { failsOnStop?: boolean } = {}): FakeProvider { return { - wallet: { shielded: { tag: 'shielded-on-provider' } }, + wallet: { + shielded: { tag: 'shielded-on-provider' }, + dust: { tag: 'dust-on-provider' }, + unshielded: { tag: 'unshielded-on-provider' }, + }, stop: vi.fn( opts.failsOnStop ? async () => { @@ -298,17 +309,6 @@ describe('WalletHandler', () => { }); describe('wallet-state cache', () => { - it('should build the shielded sub-wallet fresh when no cache file exists', async () => { - vi.mocked(existsSync).mockReturnValue(false); - wireTestkitChain(fakeProvider()); - await WalletHandler.build(logger, fakeEnv(), { - kind: 'hex', - value: '00', - }); - expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); - expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); - }); - it('should restore the shielded sub-wallet from cache when the file exists', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(WalletSaveStateProvider).mockImplementation(function ( @@ -333,28 +333,13 @@ describe('WalletHandler', () => { expect(WalletFactory.createShieldedWallet).not.toHaveBeenCalled(); }); - it('should skip the cache entirely when skipWalletCache is true', async () => { - vi.mocked(existsSync).mockReturnValue(true); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv(), - { kind: 'hex', value: '00' }, - { skipWalletCache: true }, - ); - expect(WalletFactory.restoreShieldedWallet).not.toHaveBeenCalled(); - expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); - }); - - it('should fall back to a fresh build when restore throws', async () => { + it('should restore the unshielded sub-wallet from cache instead of rebuilding from the keystore', async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(WalletSaveStateProvider).mockImplementation(function ( this: object, ) { Object.assign(this, { - load: vi.fn(async () => { - throw new Error('corrupt'); - }), + load: vi.fn(async () => 'serialized-state'), save: vi.fn(async () => undefined), }); } as unknown as new ( @@ -365,39 +350,11 @@ describe('WalletHandler', () => { kind: 'hex', value: '00', }); - expect(WalletFactory.createShieldedWallet).toHaveBeenCalledTimes(1); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ err: 'corrupt' }), - expect.stringContaining('Wallet cache restore failed'), - ); + expect(UnshieldedWallet).toHaveBeenCalledTimes(1); + expect(WalletFactory.createUnshieldedWallet).not.toHaveBeenCalled(); }); - it('should swallow save() failures with a warn log on saveCache()', async () => { - vi.mocked(WalletSaveStateProvider).mockImplementation(function ( - this: object, - ) { - Object.assign(this, { - load: vi.fn(), - save: vi.fn(async () => { - throw new Error('disk-full'); - }), - }); - } as unknown as new ( - ...args: unknown[] - ) => InstanceType); - wireTestkitChain(fakeProvider()); - const handler = await WalletHandler.build(logger, fakeEnv(), { - kind: 'hex', - value: '00', - }); - await expect(handler.saveCache()).resolves.toBeUndefined(); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ err: 'disk-full' }), - 'Wallet sub-wallet cache save failed; continuing', - ); - }); - - it('should save the shielded sub-wallet through WalletSaveStateProvider on saveCache()', async () => { + it('should delegate saveCache() to the cache service for all three sub-wallets', async () => { const save = vi.fn(async () => undefined); vi.mocked(WalletSaveStateProvider).mockImplementation(function ( this: object, @@ -414,6 +371,8 @@ describe('WalletHandler', () => { }); await handler.saveCache(); expect(save).toHaveBeenCalledWith(provider.wallet.shielded); + expect(save).toHaveBeenCalledWith(provider.wallet.dust); + expect(save).toHaveBeenCalledWith(provider.wallet.unshielded); // WalletSaveStateProvider writes at the umask; the snapshot must end // up owner-only. expect(chmodSync).toHaveBeenCalledWith(expect.any(String), 0o600); @@ -421,139 +380,39 @@ describe('WalletHandler', () => { }); describe('seed cache import', () => { - it('should import a raw-JSON dust source by gzipping into the seed-derived path', async () => { - const raw = Buffer.from('{"state":"raw-json"}', 'utf8'); - vi.mocked(readFileSync).mockReturnValue(raw); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv('preview'), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/path/to/state.json' }, - ); - // Atomic write: payload lands in `.tmp`, then rename to - // ``. Asserts both halves so a future regression that - // skips the rename (or writes directly to target) fails loudly. - expect(writeFileSync).toHaveBeenCalled(); - const [tempPath, payload] = vi.mocked(writeFileSync).mock.calls[0] ?? []; - expect(String(tempPath)).toMatch(/preview-[0-9a-f]{16}-dust\.gz\.tmp$/); - // Payload was raw → must be gzipped on the way in (magic bytes). - const payloadBuf = payload as Buffer; - expect(payloadBuf[0]).toBe(0x1f); - expect(payloadBuf[1]).toBe(0x8b); - // rename(2) is atomic on POSIX within the same filesystem. - const [renameFrom, renameTo] = vi.mocked(renameSync).mock.calls[0] ?? []; - expect(String(renameFrom)).toBe(String(tempPath)); - expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-dust\.gz$/); - }); - - it('should pass a gzipped dust source through unchanged (no double-gzip)', async () => { - const gzipped = gzipSync(Buffer.from('{"state":"raw-json"}', 'utf8')); - vi.mocked(readFileSync).mockReturnValue(gzipped); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv('preview'), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/path/to/state.gz' }, - ); - const payload = vi.mocked(writeFileSync).mock.calls[0]?.[1]; - expect(payload).toEqual(gzipped); - }); - - it('should write the imported cache with mode 0o600', async () => { + it('should route each --seed-cache-from-* source to its own sub-wallet cache', async () => { vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); wireTestkitChain(fakeProvider()); await WalletHandler.build( logger, - fakeEnv(), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/state.json' }, - ); - // Owner-only from creation: the snapshot exposes the full UTXO set. - expect(writeFileSync).toHaveBeenCalledWith( - expect.any(String), - expect.anything(), - { mode: 0o600 }, - ); - }); - - it('should ensure the .states/ directory exists before writing', async () => { - vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv(), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/state.json' }, - ); - expect(mkdirSync).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ recursive: true }), - ); - }); - - it('should throw WalletError when the source file is missing', async () => { - vi.mocked(readFileSync).mockImplementationOnce(() => { - throw new Error('ENOENT: no such file'); - }); - wireTestkitChain(fakeProvider()); - await expect( - WalletHandler.build( - logger, - fakeEnv(), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/missing.json' }, - ), - ).rejects.toThrow(/--seed-cache-from-dust:.*missing\.json/); - }); - - it('should back up an existing target cache to .bak before overwriting', async () => { - vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); - vi.mocked(existsSync).mockReturnValue(true); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv(), - { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/state.json' }, - ); - // copyFileSync MUST be called with (target, target.bak) so the - // previous cache bytes are preserved forever. If this assertion - // breaks, the safety net we promised the user is gone. - expect(copyFileSync).toHaveBeenCalledTimes(1); - const [src, dest] = vi.mocked(copyFileSync).mock.calls[0] ?? []; - expect(String(src)).toMatch(/-dust\.gz$/); - expect(String(dest)).toMatch(/-dust\.gz\.bak$/); - expect(String(dest)).toBe(`${String(src)}.bak`); - const sawBackupLog = vi - .mocked(logger.info) - .mock.calls.some((c) => - String(c[0]).includes('previous cache backed up to'), - ); - expect(sawBackupLog).toBe(true); - }); - - it('should NOT create a .bak when the target cache does not already exist', async () => { - vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); - vi.mocked(existsSync).mockReturnValue(false); - wireTestkitChain(fakeProvider()); - await WalletHandler.build( - logger, - fakeEnv(), + fakeEnv('preview'), { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheDust: '/state.json' }, + { + seedCacheShielded: '/shielded.json', + seedCacheDust: '/dust.json', + seedCacheUnshielded: '/unshielded.json', + }, ); - expect(copyFileSync).not.toHaveBeenCalled(); + // Crossed routing would load one sub-wallet's snapshot into + // another, whose state schema differs. + const targets = vi.mocked(renameSync).mock.calls.map((c) => String(c[1])); + expect(targets).toHaveLength(3); + expect(targets[0]).toMatch(/-shielded\.gz$/); + expect(targets[1]).toMatch(/-dust\.gz$/); + expect(targets[2]).toMatch(/-unshielded\.gz$/); }); - it('should warn and skip the import when --no-cache is also set', async () => { + it.each([ + ['seedCacheShielded'], + ['seedCacheDust'], + ['seedCacheUnshielded'], + ])('should warn and skip the import when --no-cache is set alongside %s', async (flag) => { wireTestkitChain(fakeProvider()); await WalletHandler.build( logger, fakeEnv(), { kind: 'hex', value: 'aa'.repeat(32) }, - { skipWalletCache: true, seedCacheDust: '/state.json' }, + { skipWalletCache: true, [flag]: '/state.json' }, ); expect(writeFileSync).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( @@ -561,19 +420,17 @@ describe('WalletHandler', () => { ); }); - it('should import a shielded source into the matching -shielded.gz path', async () => { - vi.mocked(readFileSync).mockReturnValue(Buffer.from('{}', 'utf8')); + it('should not warn when --no-cache is set without any --seed-cache-from-*', async () => { wireTestkitChain(fakeProvider()); await WalletHandler.build( logger, - fakeEnv('preview'), + fakeEnv(), { kind: 'hex', value: 'aa'.repeat(32) }, - { seedCacheShielded: '/state.json' }, + { skipWalletCache: true }, + ); + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('--seed-cache-from-*'), ); - // Final atomic rename lands on `-shielded.gz`; the intermediate - // tmp write goes to `-shielded.gz.tmp`. - const renameTo = vi.mocked(renameSync).mock.calls[0]?.[1]; - expect(String(renameTo)).toMatch(/preview-[0-9a-f]{16}-shielded\.gz$/); }); }); diff --git a/packages/deployer/src/wallet/handler.ts b/packages/deployer/src/wallet/handler.ts index ed1b491..e06c212 100644 --- a/packages/deployer/src/wallet/handler.ts +++ b/packages/deployer/src/wallet/handler.ts @@ -1,39 +1,21 @@ -import { createHash } from 'node:crypto'; -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, resolve } from 'node:path'; -import { gzipSync } from 'node:zlib'; import { DustSecretKey, ZswapSecretKeys } from '@midnight-ntwrk/ledger-v8'; import { DEFAULT_DUST_OPTIONS, - DEFAULT_WALLET_STATE_DIRECTORY, type DustWalletOptions, type EnvironmentConfiguration, FluentWalletBuilder, MidnightWalletProvider, WalletFactory, - WalletSaveStateProvider, WalletSeeds, } from '@midnight-ntwrk/testkit-js'; -import { - DustWallet, - type DustWalletAPI, -} from '@midnight-ntwrk/wallet-sdk-dust-wallet'; import type { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade'; -import type { ShieldedWalletAPI } from '@midnight-ntwrk/wallet-sdk-shielded'; import { createKeystore, type UnshieldedKeystore, } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet'; import type { Logger } from 'pino'; -import { WalletError } from '../errors.ts'; +import type { ConfigShape } from '../services/wallet-cache.ts'; +import { WalletCache } from '../services/wallet-cache.ts'; import type { WalletSeed } from './seeds.ts'; /** @@ -75,6 +57,8 @@ export interface WalletHandlerBuildOptions { seedCacheDust?: string; /** Like {@link seedCacheDust} but for the shielded sub-wallet. */ seedCacheShielded?: string; + /** Like {@link seedCacheDust} but for the unshielded sub-wallet. */ + seedCacheUnshielded?: string; /** * Sync batch size for the shielded + dust sub-wallets. Defaults to * {@link DEFAULT_SYNC_BATCH_SIZE} (5000). Raise it to replay a long dust @@ -85,9 +69,10 @@ export interface WalletHandlerBuildOptions { } /** - * Owned wallet handle: a built `MidnightWalletProvider` plus its - * shielded + dust on-disk caches. Acquire via {@link build} and an - * `AsyncDisposableStack.use()`; call {@link saveCache} after sync. + * Owned wallet handle: a built `MidnightWalletProvider` plus the + * on-disk caches for its three sub-wallets. Acquire via {@link build} + * and an `AsyncDisposableStack.use()`; call {@link saveCache} after + * sync. */ export class WalletHandler implements AsyncDisposable { /** The underlying testkit-js wallet provider. */ @@ -95,21 +80,18 @@ export class WalletHandler implements AsyncDisposable { /** The unshielded keystore the wallet was built with. */ readonly unshieldedKeystore: UnshieldedKeystore; readonly #logger: Logger; - readonly #shieldedCacheFilePath: string; - readonly #dustCacheFilePath: string; + readonly #cache: WalletCache; private constructor( provider: MidnightWalletProvider, keystore: UnshieldedKeystore, logger: Logger, - shieldedCacheFilePath: string, - dustCacheFilePath: string, + cache: WalletCache, ) { this.provider = provider; this.unshieldedKeystore = keystore; this.#logger = logger; - this.#shieldedCacheFilePath = shieldedCacheFilePath; - this.#dustCacheFilePath = dustCacheFilePath; + this.#cache = cache; } /** @@ -118,8 +100,8 @@ export class WalletHandler implements AsyncDisposable { * 1. Tunes `additionalFeeOverhead` for non-mainnet wallet sizes. * 2. Routes mnemonic vs hex seed through the right derivation path * (they derive *different* wallets from the same input). - * 3. Restores the shielded + dust sub-wallets from on-disk cache - * when present (saves the 30–60 min first-preprod sync). + * 3. Restores all three sub-wallets from on-disk cache when present + * (saves the 30–60 min first-preprod sync). * Caller drives `provider.start()`; call {@link saveCache} post-sync. */ static async build( @@ -167,62 +149,56 @@ export class WalletHandler implements AsyncDisposable { env.walletNetworkId as Parameters[1], ); - const shieldedCacheFilePath = computeCacheFilePath( - env, - walletSeeds.shielded, - 'shielded', - ); - const dustCacheFilePath = computeCacheFilePath( + const cache = new WalletCache({ + logger, env, - walletSeeds.dust, - 'dust', - ); + shieldedSeed: walletSeeds.shielded, + dustSeed: walletSeeds.dust, + unshieldedSeed: walletSeeds.unshielded, + }); // Pre-warmed cache import: place the user-supplied state file at // the seed-derived `.states/` path so the existing restore path // picks it up. Mutual exclusion with `--no-cache` is a warn, not a // hard error — keeps the flag combinations cheap. if (opts.skipWalletCache === true) { - if (opts.seedCacheShielded || opts.seedCacheDust) { + if ( + opts.seedCacheShielded || + opts.seedCacheDust || + opts.seedCacheUnshielded + ) { logger.warn( '--seed-cache-from-* is ignored under --no-cache (cache load is disabled)', ); } } else { if (opts.seedCacheShielded) { - importSeedCache( - logger, - opts.seedCacheShielded, - shieldedCacheFilePath, - 'shielded', - ); + cache.importSeedCache(opts.seedCacheShielded, 'shielded'); } if (opts.seedCacheDust) { - importSeedCache(logger, opts.seedCacheDust, dustCacheFilePath, 'dust'); + cache.importSeedCache(opts.seedCacheDust, 'dust'); + } + if (opts.seedCacheUnshielded) { + cache.importSeedCache(opts.seedCacheUnshielded, 'unshielded'); } } - const shieldedWallet = await loadOrCreateShieldedWallet({ - logger, + const shieldedWallet = await cache.loadOrCreateShieldedWallet({ config, seed: walletSeeds.shielded, - cacheFilePath: shieldedCacheFilePath, skipCache: opts.skipWalletCache === true, }); - const unshieldedWallet = WalletFactory.createUnshieldedWallet( - config as Parameters[0], - unshieldedKeystore as Parameters< - typeof WalletFactory.createUnshieldedWallet - >[1], - ); + const unshieldedWallet = await cache.loadOrCreateUnshieldedWallet({ + config, + keystore: unshieldedKeystore, + skipCache: opts.skipWalletCache === true, + }); - const dustWallet = await loadOrCreateDustWallet({ - logger, + const dustWallet = await cache.loadOrCreateDustWallet({ config, seed: walletSeeds.dust, dustOptions, - cacheFilePath: dustCacheFilePath, skipCache: opts.skipWalletCache === true, }); @@ -245,62 +221,19 @@ export class WalletHandler implements AsyncDisposable { >[5], ); - return new WalletHandler( - provider, - unshieldedKeystore, - logger, - shieldedCacheFilePath, - dustCacheFilePath, - ); + return new WalletHandler(provider, unshieldedKeystore, logger, cache); } /** - * Snapshot the shielded + dust sub-wallets to disk. Best-effort and - * independent per sub-wallet. Both are cached because on real - * networks both are slow on first sync (shielded trial-decrypts every - * note; dust streams the global unfiltered ledger event log). + * Snapshot all three sub-wallets to disk. Best-effort and independent + * per sub-wallet. Shielded and dust are cached because they are slow + * on first sync (shielded trial-decrypts every note; dust streams the + * global unfiltered ledger event log). Unshielded is cached because + * the sync gate waits on its progress, so an uncached one re-syncs + * from genesis and dominates an otherwise-warm boot. */ async saveCache(): Promise { - await Promise.allSettled([ - this.#saveSubWalletCache( - this.#shieldedCacheFilePath, - this.provider.wallet.shielded, - 'shielded', - ), - this.#saveSubWalletCache( - this.#dustCacheFilePath, - this.provider.wallet.dust, - 'dust', - ), - ]); - } - - async #saveSubWalletCache( - filePath: string, - subWallet: unknown, - label: string, - ): Promise { - try { - const dir = pathDir(filePath); - const filename = pathBase(filePath); - // `seed` param only feeds the default filename; we pass an - // explicit one, so the empty string is fine. - const saver = new WalletSaveStateProvider( - this.#logger, - '', - dir, - filename, - ); - await saver.save(subWallet as Parameters[0]); - // WalletSaveStateProvider writes at the process umask and offers no - // mode control, so tighten the snapshot after the fact. - chmodSync(filePath, 0o600); - } catch (e) { - this.#logger.warn( - { err: (e as Error).message, label, filePath }, - 'Wallet sub-wallet cache save failed; continuing', - ); - } + await this.#cache.save(this.provider.wallet); } async [Symbol.asyncDispose](): Promise { @@ -311,196 +244,3 @@ export class WalletHandler implements AsyncDisposable { } } } - -// --------------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------------- - -/** Opaque testkit-js `FluentWalletBuilder.config` (not exported by testkit). */ -type ConfigShape = unknown; - -/** - * Drop a user-supplied wallet-state file into `.states/` under the - * seed-derived filename so the existing restore path picks it up. - * Detects gzip via magic bytes (`0x1f 0x8b`); raw JSON is gzipped on - * the way in. Throws {@link WalletError} on read failure so a bad path - * fails fast instead of silently doing a cold sync from genesis. - * - * Safety guarantees: - * 1. **Source is read-only.** Only `readFileSync` touches `srcPath`. - * 2. **Backup is preserved forever.** If the target `.gz` already - * exists, it is `copyFileSync`'d to `.bak` *before* any - * write — never deleted, never overwritten by this helper. A user - * who hits a bad-format import can roll back with - * `mv .bak ` and re-run. - * 3. **Write is atomic.** New bytes land in `.tmp` first, - * then `rename(2)` over the final path. POSIX rename is atomic - * within the same filesystem, so a mid-write crash can never leave - * the existing cache half-overwritten. A stale `.tmp` left by a - * failed rename is harmless (cache load only scans `.gz`) and gets - * overwritten by the next import attempt. - */ -function importSeedCache( - logger: Logger, - srcPath: string, - targetPath: string, - kind: 'shielded' | 'dust', -): void { - const absoluteSrc = resolve(process.cwd(), srcPath); - let bytes: Buffer; - try { - bytes = readFileSync(absoluteSrc); - } catch (e) { - throw new WalletError( - `--seed-cache-from-${kind}: cannot read ${absoluteSrc}: ${(e as Error).message}`, - ); - } - const isGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; - const payload = isGzipped ? bytes : gzipSync(bytes); - const backupPath = `${targetPath}.bak`; - const tempPath = `${targetPath}.tmp`; - mkdirSync(pathDir(targetPath), { recursive: true }); - // Preserve the previous cache forever as `.bak` so a - // bad-format import is always recoverable by hand. We use copy (not - // rename) so the live `` keeps its bytes throughout the - // window before the atomic rename below. - const backedUp = existsSync(targetPath); - if (backedUp) { - copyFileSync(targetPath, backupPath); - } - // 0o600: a wallet state snapshot exposes the full UTXO set and every - // derived address. Set at create time so the bytes are never group- or - // world-readable, not even between write and rename. - writeFileSync(tempPath, payload, { mode: 0o600 }); - renameSync(tempPath, targetPath); - logger.info( - `Imported ${kind} cache: ${absoluteSrc} → ${targetPath}${ - backedUp ? ` (previous cache backed up to ${backupPath})` : '' - }`, - ); -} - -/** - * `--.gz`. Per-kind suffix prevents - * shielded/dust cross-load (different state schemas). Don't reuse - * testkit's helper: it embeds the seed verbatim and gates the - * network on env vars instead of runtime ID. - */ -function computeCacheFilePath( - env: EnvironmentConfiguration, - seed: Uint8Array, - kind: 'shielded' | 'dust', -): string { - const seedHash = createHash('sha256').update(seed).digest('hex').slice(0, 16); - const filename = `${env.walletNetworkId}-${seedHash}-${kind}.gz`; - return resolve(process.cwd(), DEFAULT_WALLET_STATE_DIRECTORY, filename); -} - -/** - * Restore dust wallet from cache, else build fresh. Routes through - * `DustWallet(config).restore(...)` because testkit doesn't expose a - * `WalletFactory.restoreDustWallet`. Caching turns preprod's 1 h+ - * first-run dust sync into seconds on subsequent boots. - */ -async function loadOrCreateDustWallet(args: { - logger: Logger; - config: ConfigShape; - seed: Uint8Array; - dustOptions: DustWalletOptions; - cacheFilePath: string; - skipCache: boolean; -}): Promise { - const { logger, config, seed, dustOptions, cacheFilePath, skipCache } = args; - - if (!skipCache && existsSync(cacheFilePath)) { - try { - const dir = pathDir(cacheFilePath); - const filename = pathBase(cacheFilePath); - const loader = new WalletSaveStateProvider(logger, '', dir, filename); - const serializedState = await loader.load(); - // `costParameters` is runtime state on the builder, not baked - // into the snapshot. Re-apply `dustOptions` so the restored - // wallet honours our `additionalFeeOverhead` override. - const dustConfig = buildDustConfig(config, dustOptions); - const dustClass = DustWallet( - dustConfig as Parameters[0], - ); - const restored = dustClass.restore(serializedState); - logger.info(`Restored dust wallet state from ${cacheFilePath}`); - return restored as unknown as DustWalletAPI; - } catch (e) { - logger.warn( - { err: (e as Error).message, cacheFilePath }, - 'Dust wallet cache restore failed; falling back to fresh sync', - ); - } - } else if (skipCache) { - logger.info('Dust wallet cache disabled (--no-cache); doing fresh sync'); - } - - return WalletFactory.createDustWallet( - config as Parameters[0], - seed, - dustOptions, - ); -} - -async function loadOrCreateShieldedWallet(args: { - logger: Logger; - config: ConfigShape; - seed: Uint8Array; - cacheFilePath: string; - skipCache: boolean; -}): Promise { - const { logger, config, seed, cacheFilePath, skipCache } = args; - - if (!skipCache && existsSync(cacheFilePath)) { - try { - const dir = pathDir(cacheFilePath); - const filename = pathBase(cacheFilePath); - const loader = new WalletSaveStateProvider(logger, '', dir, filename); - const serializedState = await loader.load(); - const restored = await WalletFactory.restoreShieldedWallet( - config as Parameters[0], - serializedState, - ); - logger.info(`Restored wallet state from ${cacheFilePath}`); - return restored as ShieldedWalletAPI; - } catch (e) { - logger.warn( - { err: (e as Error).message, cacheFilePath }, - 'Wallet cache restore failed; falling back to fresh sync', - ); - } - } else if (skipCache) { - logger.info('Wallet cache disabled (--no-cache); doing fresh sync'); - } - - return WalletFactory.createShieldedWallet( - config as Parameters[0], - seed, - ) as ShieldedWalletAPI; -} - -/** Layer `dustOptions` onto the base config so cache-restored wallets honour `additionalFeeOverhead`. */ -function buildDustConfig( - config: ConfigShape, - dustOptions: DustWalletOptions, -): ConfigShape { - return { - ...(config as Record), - costParameters: { - ledgerParams: dustOptions.ledgerParams, - additionalFeeOverhead: dustOptions.additionalFeeOverhead, - feeBlocksMargin: dustOptions.feeBlocksMargin, - }, - } as ConfigShape; -} - -function pathDir(p: string): string { - return dirname(p); -} - -function pathBase(p: string): string { - return basename(p); -} diff --git a/packages/deployer/src/wallet/keystore.test.ts b/packages/deployer/src/wallet/keystore.test.ts index 4e8b9d1..14c68ec 100644 --- a/packages/deployer/src/wallet/keystore.test.ts +++ b/packages/deployer/src/wallet/keystore.test.ts @@ -159,6 +159,18 @@ describe('Keystore', () => { ); }); + it('should label a schema failure with no path as ', () => { + // Clears the shallow field checks but is not a plain object, so zod + // reports at the top level and the issue path is empty. + const arrayShaped = Object.assign([], { + version: 'midnight-1', + crypto: { kdf: 'scrypt', cipher: 'aes-128-ctr' }, + }); + expect(() => Keystore.fromJSON(arrayShaped)).toThrow( + /: Expected object/, + ); + }); + it('should reject a keystore missing kdfparams with WalletError', () => { const ks = Keystore.encrypt(SEED, 'pw', FAST_OPTS).toJSON(); const { kdfparams: _dropped, ...crypto } = ks.crypto; diff --git a/packages/deployer/src/wallet/seeds.test.ts b/packages/deployer/src/wallet/seeds.test.ts index 3da18d2..addbebb 100644 --- a/packages/deployer/src/wallet/seeds.test.ts +++ b/packages/deployer/src/wallet/seeds.test.ts @@ -29,11 +29,14 @@ function fakeConfig(rootDir: string, keystore?: string): CompactConfig { } function fakeNetwork(opts: { local?: { index?: number } } = {}): NetworkConfig { - return { - wallet: opts.local - ? { source: 'local', index: opts.local.index ?? 0 } - : undefined, - } as unknown as NetworkConfig; + if (!opts.local) return {} as unknown as NetworkConfig; + // `index` stays absent unless the caller sets one, so the resolver's + // own default is what the no-index case exercises. + const wallet = + opts.local.index === undefined + ? { source: 'local' } + : { source: 'local', index: opts.local.index }; + return { wallet } as unknown as NetworkConfig; } describe('classifySeed', () => { diff --git a/packages/deployer/vitest.config.ts b/packages/deployer/vitest.config.ts index 8b2eb8b..3a9b41e 100644 --- a/packages/deployer/vitest.config.ts +++ b/packages/deployer/vitest.config.ts @@ -12,10 +12,10 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/**/*.test.ts', 'src/index.ts'], thresholds: { - statements: 95, - branches: 90, + statements: 100, + branches: 100, functions: 100, - lines: 95, + lines: 100, }, }, }, From 70ca1cc6c3dcfb7f6a7b8b182a5e89a4896c6758 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 28 Aug 2026 11:29:54 +0200 Subject: [PATCH 9/9] fix(deployer): address CodeRabbit review on PR 86 Catch a rejected onCheckpoint in syncAndVerifyFunds. The callback ran as `onCheckpoint().finally(...)`, so a rejection was never consumed and Node would terminate the process, contradicting the documented best-effort contract of the surrounding comment. Reject a keystore dklen other than 32 in encrypt. The value reached scryptSync directly, and a shorter derived key left an empty MAC key, so encrypt would write a keystore that its own schema then refuses to read back. Fix the default_network test fixture. It appended a second [profile] table, which smol-toml rejects as a redefinition, so the test passed on the TOML parse error and never reached the validation branch it names. Covering that branch properly left the invalid-TOML path untested, so it gets its own case. Document compact-deploy in the CLI README, which listed only the compiler and builder binaries while requiring Node 24 for a deployer it never mentioned. Correct the deployer README's real-network guidance, which pointed at preview while its own known-issues section records preview as null-routed. --- packages/cli/README.md | 30 ++++++++++++++----- packages/deployer/README.md | 2 +- .../src/config/compact-config.test.ts | 19 ++++++++++-- .../deployer/src/services/wallet-sync.test.ts | 19 ++++++++++++ packages/deployer/src/services/wallet-sync.ts | 13 ++++++-- packages/deployer/src/wallet/keystore.test.ts | 8 +++++ packages/deployer/src/wallet/keystore.ts | 5 ++++ 7 files changed, 82 insertions(+), 14 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index d751b5a..9b08209 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,9 +1,11 @@ # @openzeppelin/compact-cli -CLI wrapper around [`@openzeppelin/compact-builder`](../builder). -Provides the `compact-compiler` and `compact-builder` binaries for use in -`package.json` scripts. Contains no programmatic API of its own. If you want -to call the compiler/builder from TypeScript, use the library package directly. +CLI wrapper around [`@openzeppelin/compact-builder`](../builder) and +[`@openzeppelin/compact-deployer`](../deployer). Provides the +`compact-compiler`, `compact-builder`, and `compact-deploy` binaries for use +in `package.json` scripts. Contains no programmatic API of its own. If you +want to call the compiler, builder, or deployer from TypeScript, use the +library package directly. ## Install @@ -16,6 +18,7 @@ yarn add --dev @openzeppelin/compact-cli ```bash yarn compact-compiler --help yarn compact-builder --help +yarn compact-deploy --help ``` Typical `package.json` scripts (replace `` with the Compact @@ -34,9 +37,20 @@ toolchain release you want to pin, e.g. `+0.29.0`): ## Options -Both binaries accept the same compiler-side options (forwarded to the -underlying library); `compact-builder` additionally accepts dist-layout -options: +### `compact-deploy` + +Deploys a compiled contract to a Midnight network. Options are documented +in full under [`@openzeppelin/compact-deployer`](../deployer); the common +ones are `--network`, `--config`, `--seed-file`, `--dry-run`, and `--json`. + +```bash +compact-deploy --network local +``` + +### `compact-compiler` and `compact-builder` + +Both accept the same compiler-side options (forwarded to the underlying +library); `compact-builder` additionally accepts dist-layout options: | Flag | Applies to | Description | |---|---|---| @@ -55,7 +69,7 @@ documentation, programmatic API, and behavioural details. ## Requirements -- Node.js >= 24 — the deployer uses explicit resource management (`await using` / `AsyncDisposableStack`), global only from Node 24 +- Node.js >= 24 for `compact-deploy`, which uses explicit resource management (`await using` / `AsyncDisposableStack`), global only from Node 24. The compiler and builder binaries run on older releases. - Midnight Compact toolchain installed and available in `PATH` ```bash diff --git a/packages/deployer/README.md b/packages/deployer/README.md index c7a1a12..a8d4026 100644 --- a/packages/deployer/README.md +++ b/packages/deployer/README.md @@ -59,7 +59,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea ## Deploying to real networks (preprod, preview, testnet) -> Preprod is blocked on an upstream wallet-SDK bug. Use `--network preview`. See [Known issues](#known-issues-may-2026). +> Neither public network is usable right now. Preprod is blocked on an upstream wallet-SDK bug and the preview endpoints are null-routed, so `make env-up` (local standalone) is the only working target. See [Known issues](#known-issues-may-2026). - **First sync is slow** (~3 min on preview, 30–60 min on preprod from genesis). Cache makes reruns near-instant. - **Bump sync timeout**: `--sync-timeout 3600` (default 10 min). diff --git a/packages/deployer/src/config/compact-config.test.ts b/packages/deployer/src/config/compact-config.test.ts index 9ec4c4a..2ef37c6 100644 --- a/packages/deployer/src/config/compact-config.test.ts +++ b/packages/deployer/src/config/compact-config.test.ts @@ -46,9 +46,24 @@ describe('CompactConfig', () => { }); it('should reject a config whose default_network does not exist', async () => { - const dir = tmpRepo(`${MIN_VALID}\n[profile]\ndefault_network = "ghost"\n`); + // Rewrite the existing key rather than appending a second [profile]: + // a duplicate table is invalid TOML, so the parse would fail before + // the schema ever checks default_network. + const dir = tmpRepo( + MIN_VALID.replace( + 'default_network = "local"', + 'default_network = "ghost"', + ), + ); await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( - ConfigError, + /default_network must reference a defined \[networks\.X\] block/, + ); + }); + + it('should reject a compact.toml that is not valid TOML', async () => { + const dir = tmpRepo('[profile\ndefault_network = "local"\n'); + await expect(CompactConfig.load(undefined, dir)).rejects.toThrow( + /Invalid TOML in /, ); }); diff --git a/packages/deployer/src/services/wallet-sync.test.ts b/packages/deployer/src/services/wallet-sync.test.ts index b718ec8..26c7209 100644 --- a/packages/deployer/src/services/wallet-sync.test.ts +++ b/packages/deployer/src/services/wallet-sync.test.ts @@ -307,6 +307,25 @@ describe('syncAndVerifyFunds', () => { expect(onCheckpoint).toHaveBeenCalledTimes(1); }); + it('should warn and keep syncing when onCheckpoint rejects', async () => { + // An unconsumed rejection here terminates the process by default, + // which would turn a best-effort snapshot into a failed deploy. + const onCheckpoint = vi.fn(() => Promise.reject(new Error('disk full'))); + const { logger, warn } = spyLogger(); + await expect( + syncAndVerifyFunds({ + ...base, + logger, + wallet: fakeWallet(Rx.of(facadeState())), + onCheckpoint, + }), + ).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + { err: 'disk full' }, + 'Wallet cache checkpoint failed; continuing sync', + ); + }); + it('should not start a second checkpoint while the first is still in flight', async () => { // Re-entrancy guard: two emissions arrive before the first save // settles. A second concurrent snapshot would race the first over diff --git a/packages/deployer/src/services/wallet-sync.ts b/packages/deployer/src/services/wallet-sync.ts index 7268ef4..078d077 100644 --- a/packages/deployer/src/services/wallet-sync.ts +++ b/packages/deployer/src/services/wallet-sync.ts @@ -176,9 +176,16 @@ export async function syncAndVerifyFunds( .subscribe(() => { if (checkpointInFlight) return; checkpointInFlight = true; - onCheckpoint().finally(() => { - checkpointInFlight = false; - }); + onCheckpoint() + .catch((e: unknown) => { + logger.warn( + { err: (e as Error).message }, + 'Wallet cache checkpoint failed; continuing sync', + ); + }) + .finally(() => { + checkpointInFlight = false; + }); }) : undefined; diff --git a/packages/deployer/src/wallet/keystore.test.ts b/packages/deployer/src/wallet/keystore.test.ts index 14c68ec..a45e648 100644 --- a/packages/deployer/src/wallet/keystore.test.ts +++ b/packages/deployer/src/wallet/keystore.test.ts @@ -47,6 +47,14 @@ describe('Keystore', () => { ); }); + it('should reject a dklen the schema cannot read back', () => { + // A short derived key leaves an empty MAC key, so the keystore + // would be written and then fail its own schema on load. + expect(() => + Keystore.encrypt(SEED, 'pw', { ...FAST_OPTS, dklen: 16 }), + ).toThrow(/dklen must be 32/); + }); + it('should reject a wrong passphrase with MAC mismatch', () => { const ks = Keystore.encrypt(SEED, 'hunter2', FAST_OPTS); expect(() => ks.decrypt('wrong')).toThrow(/MAC mismatch/); diff --git a/packages/deployer/src/wallet/keystore.ts b/packages/deployer/src/wallet/keystore.ts index f8fe66f..5a89320 100644 --- a/packages/deployer/src/wallet/keystore.ts +++ b/packages/deployer/src/wallet/keystore.ts @@ -107,6 +107,11 @@ export class Keystore { ): Keystore { const seed = seedFromHex(seedHex); const { scryptN, scryptP, scryptR, dklen } = { ...DEFAULTS, ...opts }; + // `decrypt` splits the derived key 16/16 and the schema pins `dklen` to + // 32, so any other length writes a keystore that can never be read back. + if (dklen !== 32) { + throw new WalletError(`Keystore dklen must be 32, got ${dklen}`); + } const salt = randomBytes(32); const iv = randomBytes(16);