From 929c63abcf6e9991da647341ac4d8b8fcf443f1a Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 8 Sep 2026 16:43:26 -0400 Subject: [PATCH] refactor(cli): standardize operational scripts on a shared command runner - Create @sub-rosa/command package providing runCommand, context helpers, standard exit codes, signal handling, and structured diagnostics - Migrate all root check scripts and operational service scripts to runCommand - Eliminate raw process.exit calls in operational scripts, delegating process termination to the runner - Add scripts/check-command-runner.mjs and test to guard against command runner drift - Register command:test, command:typecheck, command-runner:check, and command-runner:test in root package.json --- package.json | 10 +- packages/command/package.json | 49 +++ packages/command/src/errors.d.ts | 31 ++ packages/command/src/errors.js | 78 ++++ packages/command/src/index.d.ts | 17 + packages/command/src/index.js | 10 + packages/command/src/repo-root.d.ts | 7 + packages/command/src/repo-root.js | 25 ++ packages/command/src/runner.d.ts | 21 + packages/command/src/runner.js | 261 ++++++++++++ packages/command/src/types.d.ts | 51 +++ packages/command/src/types.js | 1 + packages/command/test/runner.test.ts | 341 +++++++++++++++ packages/command/tsconfig.json | 15 + packages/sdk/package.json | 1 + packages/sdk/scripts/live-smoke.ts | 266 ++++++------ packages/sdk/scripts/mainnet-micro.ts | 227 +++++----- packages/sdk/scripts/mainnet-ready.ts | 138 +++--- packages/sdk/scripts/mainnet-verify.ts | 108 ++--- packages/tlock/package.json | 2 + packages/tlock/src/recover-identities.cli.ts | 38 +- pnpm-lock.yaml | 52 ++- scripts/check-command-runner.mjs | 152 +++++++ scripts/check-command-runner.test.mjs | 71 ++++ scripts/check-deploy-docs.mjs | 112 ++--- scripts/check-direct-time-access.mjs | 45 +- scripts/check-fixture-sizes.mjs | 56 +-- scripts/check-links.js | 211 +++++----- scripts/check-logging.mjs | 18 +- scripts/check-round-errors.mjs | 93 +++-- scripts/check-snapshots.mjs | 80 ++-- scripts/check-threat-model-anchors.mjs | 45 +- scripts/run-ts-coverage.mjs | 26 +- services/agent/package.json | 1 + services/agent/scripts/agents-e2e.ts | 417 ++++++++++--------- services/agent/scripts/usdc-setup.ts | 104 ++--- services/appraisal-api/package.json | 1 + services/appraisal-api/scripts/usdc-setup.ts | 95 +++-- services/appraisal-api/scripts/x402-e2e.ts | 240 ++++++----- services/auction-template/package.json | 1 + services/auction-template/sealed-auction.ts | 84 ++-- services/keeper/package.json | 1 + services/keeper/scripts/keeper-e2e.ts | 254 ++++++----- services/keeper/scripts/lifecycle-e2e.ts | 391 +++++++++-------- services/keeper/scripts/mainnet-settle.ts | 240 ++++++----- services/keeper/scripts/usdc-setup.ts | 113 ++--- services/keeper/src/queue.ts | 112 +++-- services/keeper/src/run.ts | 77 ++-- services/keeper/src/serve.ts | 166 ++++---- services/keeper/src/watch.ts | 95 ++--- services/receipt-cli/package.json | 1 + services/receipt-cli/src/index.ts | 108 +++-- 52 files changed, 3214 insertions(+), 1945 deletions(-) create mode 100644 packages/command/package.json create mode 100644 packages/command/src/errors.d.ts create mode 100644 packages/command/src/errors.js create mode 100644 packages/command/src/index.d.ts create mode 100644 packages/command/src/index.js create mode 100644 packages/command/src/repo-root.d.ts create mode 100644 packages/command/src/repo-root.js create mode 100644 packages/command/src/runner.d.ts create mode 100644 packages/command/src/runner.js create mode 100644 packages/command/src/types.d.ts create mode 100644 packages/command/src/types.js create mode 100644 packages/command/test/runner.test.ts create mode 100644 packages/command/tsconfig.json create mode 100644 scripts/check-command-runner.mjs create mode 100644 scripts/check-command-runner.test.mjs diff --git a/package.json b/package.json index 3619f968..4e80d968 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,14 @@ "receipt:typecheck": "pnpm --filter @sub-rosa/receipt-cli typecheck", "time:test": "pnpm --filter @sub-rosa/time test", "time:guard": "node scripts/check-direct-time-access.mjs", - "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs" + "time:guard:test": "node --test scripts/check-direct-time-access.test.mjs", + "command:test": "pnpm --filter @sub-rosa/command test", + "command:typecheck": "pnpm --filter @sub-rosa/command typecheck", + "command-runner:check": "node scripts/check-command-runner.mjs", + "command-runner:test": "node --test scripts/check-command-runner.test.mjs" + }, + "devDependencies": { + "@sub-rosa/command": "workspace:*", + "@sub-rosa/logging": "workspace:*" } } diff --git a/packages/command/package.json b/packages/command/package.json new file mode 100644 index 00000000..dc470d44 --- /dev/null +++ b/packages/command/package.json @@ -0,0 +1,49 @@ +{ + "name": "@sub-rosa/command", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Standardized command runner with preflight, signal lifecycle, cleanup registration, and exit codes.", + "repository": { + "type": "git", + "url": "https://github.com/Sub-Rosa-Issue/sub-rosa-issue.git", + "directory": "packages/command" + }, + "main": "src/index.js", + "types": "src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./errors": { + "types": "./src/errors.d.ts", + "default": "./src/errors.js" + }, + "./types": { + "types": "./src/types.d.ts", + "default": "./src/types.js" + }, + "./runner": { + "types": "./src/runner.d.ts", + "default": "./src/runner.js" + }, + "./repo-root": { + "types": "./src/repo-root.d.ts", + "default": "./src/repo-root.js" + } + }, + "scripts": { + "test": "node --import tsx --test test/runner.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@sub-rosa/logging": "workspace:*", + "@sub-rosa/time": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/packages/command/src/errors.d.ts b/packages/command/src/errors.d.ts new file mode 100644 index 00000000..da7161da --- /dev/null +++ b/packages/command/src/errors.d.ts @@ -0,0 +1,31 @@ +export declare const ExitCode: { + readonly SUCCESS: 0; + readonly UNEXPECTED: 1; + readonly USAGE: 2; + readonly CONFIG: 3; + readonly DEPENDENCY: 4; + readonly INTERRUPTED: 130; +}; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; + +export declare class CommandError extends Error { + readonly exitCode: number; + constructor(message: string, exitCode?: number); +} + +export declare class UsageError extends CommandError { + constructor(message: string); +} + +export declare class ConfigError extends CommandError { + constructor(message: string); +} + +export declare class DependencyError extends CommandError { + constructor(message: string); +} + +export declare class InterruptedError extends CommandError { + constructor(message?: string); +} diff --git a/packages/command/src/errors.js b/packages/command/src/errors.js new file mode 100644 index 00000000..ef3e26fa --- /dev/null +++ b/packages/command/src/errors.js @@ -0,0 +1,78 @@ +/** + * Stable exit-code categories for command execution. + */ +export const ExitCode = Object.freeze({ + SUCCESS: 0, + UNEXPECTED: 1, + USAGE: 2, + CONFIG: 3, + DEPENDENCY: 4, + INTERRUPTED: 130, +}); + +/** + * Base error class for operational command failures with exit codes. + */ +export class CommandError extends Error { + /** + * @param {string} message + * @param {number} [exitCode=ExitCode.UNEXPECTED] + */ + constructor(message, exitCode = ExitCode.UNEXPECTED) { + super(message); + this.name = "CommandError"; + this.exitCode = exitCode; + } +} + +/** + * Error indicating invalid CLI arguments, options, or command usage. + */ +export class UsageError extends CommandError { + /** + * @param {string} message + */ + constructor(message) { + super(message, ExitCode.USAGE); + this.name = "UsageError"; + } +} + +/** + * Error indicating missing or invalid environment variables or configuration. + */ +export class ConfigError extends CommandError { + /** + * @param {string} message + */ + constructor(message) { + super(message, ExitCode.CONFIG); + this.name = "ConfigError"; + } +} + +/** + * Error indicating external service, binary, RPC, or network failure. + */ +export class DependencyError extends CommandError { + /** + * @param {string} message + */ + constructor(message) { + super(message, ExitCode.DEPENDENCY); + this.name = "DependencyError"; + } +} + +/** + * Error indicating process cancellation via abort signal or interrupt. + */ +export class InterruptedError extends CommandError { + /** + * @param {string} [message="Command interrupted"] + */ + constructor(message = "Command interrupted") { + super(message, ExitCode.INTERRUPTED); + this.name = "InterruptedError"; + } +} diff --git a/packages/command/src/index.d.ts b/packages/command/src/index.d.ts new file mode 100644 index 00000000..52e6effe --- /dev/null +++ b/packages/command/src/index.d.ts @@ -0,0 +1,17 @@ +export { + CommandError, + ConfigError, + DependencyError, + ExitCode, + InterruptedError, + UsageError, +} from "./errors.js"; +export { findRepoRoot } from "./repo-root.js"; +export { formatHelp, runCommand } from "./runner.js"; +export type { + CommandContext, + CommandDefinition, + CommandOption, + PositionalDescription, + RunOptions, +} from "./types.js"; diff --git a/packages/command/src/index.js b/packages/command/src/index.js new file mode 100644 index 00000000..48cb2f5d --- /dev/null +++ b/packages/command/src/index.js @@ -0,0 +1,10 @@ +export { + CommandError, + ConfigError, + DependencyError, + ExitCode, + InterruptedError, + UsageError, +} from "./errors.js"; +export { findRepoRoot } from "./repo-root.js"; +export { formatHelp, runCommand } from "./runner.js"; diff --git a/packages/command/src/repo-root.d.ts b/packages/command/src/repo-root.d.ts new file mode 100644 index 00000000..a486d5a6 --- /dev/null +++ b/packages/command/src/repo-root.d.ts @@ -0,0 +1,7 @@ +/** + * Ascends the filesystem hierarchy to find the repository root directory. + * + * @param startDir - Initial directory to start searching from. + * @returns Absolute path to the repository root. + */ +export declare function findRepoRoot(startDir?: string): string; diff --git a/packages/command/src/repo-root.js b/packages/command/src/repo-root.js new file mode 100644 index 00000000..e869497e --- /dev/null +++ b/packages/command/src/repo-root.js @@ -0,0 +1,25 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +/** + * Ascends the filesystem hierarchy to find the repository root directory. + * + * @param {string} [startDir] - Initial directory to start searching from. + * @returns {string} - Absolute path to the repository root. + */ +export function findRepoRoot(startDir) { + let current = resolve(startDir || process.cwd()); + while (true) { + if ( + existsSync(resolve(current, "pnpm-workspace.yaml")) || + existsSync(resolve(current, ".git")) + ) { + return current; + } + const parent = dirname(current); + if (parent === current) { + return resolve(startDir || process.cwd()); + } + current = parent; + } +} diff --git a/packages/command/src/runner.d.ts b/packages/command/src/runner.d.ts new file mode 100644 index 00000000..934d06fb --- /dev/null +++ b/packages/command/src/runner.d.ts @@ -0,0 +1,21 @@ +import type { CommandDefinition, RunOptions } from "./types.js"; + +/** + * Formats help text for a command definition. + * + * @param definition - Command definition to generate help text for. + * @returns Formatted help string. + */ +export declare function formatHelp(definition: CommandDefinition): string; + +/** + * Executes a command definition with lifecycle management. + * + * @param definition - Command specification including arguments, environment, and run handler. + * @param options - Execution options controlling arguments, environment, streams, and termination. + * @returns Promise resolving to the numeric exit code. + */ +export declare function runCommand>( + definition: CommandDefinition, + options?: RunOptions +): Promise; diff --git a/packages/command/src/runner.js b/packages/command/src/runner.js new file mode 100644 index 00000000..9d41ba44 --- /dev/null +++ b/packages/command/src/runner.js @@ -0,0 +1,261 @@ +import { parseArgs } from "node:util"; +import { isAbsolute, resolve } from "node:path"; +import { createLogger } from "@sub-rosa/logging"; +import { + CommandError, + ConfigError, + ExitCode, + InterruptedError, + UsageError, +} from "./errors.js"; +import { findRepoRoot } from "./repo-root.js"; + +/** + * Formats help text for a command definition. + * + * @param {import("./types.js").CommandDefinition} definition + * @returns {string} + */ +export function formatHelp(definition) { + const lines = []; + const usage = definition.usage || "[options]"; + lines.push(`Usage: ${definition.name} ${usage}`); + lines.push(""); + lines.push(definition.description); + lines.push(""); + + /** @type {Record} */ + const options = { + help: { type: "boolean", short: "h", description: "Show help" }, + ...(definition.options || {}), + }; + + lines.push("Options:"); + for (const [key, opt] of Object.entries(options)) { + const flag = opt.short ? `-${opt.short}, --${key}` : ` --${key}`; + const desc = opt.description || ""; + const def = opt.default !== undefined ? ` (default: ${JSON.stringify(opt.default)})` : ""; + lines.push(` ${flag.padEnd(20)} ${desc}${def}`); + } + + if (definition.positionals && definition.positionals.length > 0) { + lines.push(""); + lines.push("Positional Arguments:"); + for (const pos of definition.positionals) { + const req = pos.required ? " (required)" : " (optional)"; + lines.push(` ${pos.name.padEnd(20)} ${pos.description}${req}`); + } + } + + if (definition.requiredEnv && definition.requiredEnv.length > 0) { + lines.push(""); + lines.push("Required Environment Variables:"); + for (const envVar of definition.requiredEnv) { + lines.push(` ${envVar.padEnd(20)} Required`); + } + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * Executes a command definition with lifecycle management. + * + * @template [TValues=Record] + * @param {import("./types.js").CommandDefinition} definition + * @param {import("./types.js").RunOptions} [options={}] + * @returns {Promise} + */ +export async function runCommand(definition, options = {}) { + const rawArgs = options.argv ?? process.argv.slice(2); + const env = options.env ?? process.env; + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const shouldTerminate = options.terminate ?? true; + const repoRoot = options.repoRoot ?? findRepoRoot(process.cwd()); + const logger = createLogger(definition.name); + + if (rawArgs.includes("--help") || rawArgs.includes("-h")) { + stdout.write(formatHelp(definition)); + if (shouldTerminate) { + process.exit(ExitCode.SUCCESS); + } + return ExitCode.SUCCESS; + } + + /** @type {Array<() => Promise | void>} */ + const cleanups = []; + let cleanupsExecuted = false; + + const runCleanups = async () => { + if (cleanupsExecuted) { + return; + } + cleanupsExecuted = true; + const reversed = cleanups.slice().reverse(); + for (const cleanup of reversed) { + try { + await cleanup(); + } catch (cleanupError) { + const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + stderr.write(`Cleanup error: ${msg}\n`); + } + } + }; + + const abortController = new AbortController(); + if (options.signal) { + if (options.signal.aborted) { + abortController.abort(options.signal.reason); + } else { + options.signal.addEventListener("abort", () => { + abortController.abort(options.signal?.reason); + }, { once: true }); + } + } + + let interruptedBySignal = false; + /** @type {((sig: string) => void) | null} */ + let sigHandler = null; + + if (shouldTerminate) { + sigHandler = (sig) => { + interruptedBySignal = true; + abortController.abort(new InterruptedError(`Interrupted by ${sig}`)); + runCleanups().finally(() => { + process.exit(ExitCode.INTERRUPTED); + }); + }; + process.once("SIGINT", sigHandler); + process.once("SIGTERM", sigHandler); + } + + /** + * @param {...string} segments + * @returns {string} + */ + const resolvePath = (...segments) => { + const combined = segments.length === 1 ? segments[0] : resolve(...segments); + return isAbsolute(combined) ? combined : resolve(repoRoot, combined); + }; + + let parsedArgs = /** @type {TValues} */ ({}); + let positionals = /** @type {string[]} */ ([]); + + try { + if (definition.parseArgs) { + const parsed = definition.parseArgs(rawArgs); + parsedArgs = parsed.args; + positionals = parsed.positionals; + } else { + /** @type {Record} */ + const schemaOptions = { + help: { type: "boolean", short: "h" }, + }; + if (definition.options) { + for (const [key, opt] of Object.entries(definition.options)) { + /** @type {{ type: "string" | "boolean"; short?: string; default?: any; multiple?: boolean }} */ + const entry = { type: opt.type }; + if (opt.short !== undefined) { + entry.short = opt.short; + } + if (opt.default !== undefined) { + entry.default = opt.default; + } + if (opt.multiple !== undefined) { + entry.multiple = opt.multiple; + } + schemaOptions[key] = entry; + } + } + + try { + const result = parseArgs({ + args: rawArgs, + options: schemaOptions, + allowPositionals: true, + strict: true, + }); + parsedArgs = /** @type {TValues} */ (result.values); + positionals = result.positionals; + } catch (parseError) { + const msg = parseError instanceof Error ? parseError.message : String(parseError); + throw new UsageError(msg); + } + } + + if (definition.positionals) { + for (let i = 0; i < definition.positionals.length; i++) { + const posDef = definition.positionals[i]; + if (posDef.required && !positionals[i]) { + throw new UsageError(`Missing required argument: <${posDef.name}>`); + } + } + } + + if (definition.requiredEnv) { + for (const envName of definition.requiredEnv) { + if (!env[envName]) { + throw new ConfigError(`Missing required environment variable: ${envName}`); + } + } + } + + /** @type {import("./types.js").CommandContext} */ + const ctx = { + args: parsedArgs, + options: parsedArgs, + positionals, + rawArgs, + env, + signal: abortController.signal, + registerCleanup: (fn) => { + cleanups.push(fn); + }, + logger, + repoRoot, + resolvePath, + }; + + if (definition.preflight) { + await definition.preflight(ctx); + } + + const runResult = await definition.run(ctx); + const finalExitCode = typeof runResult === "number" ? runResult : ExitCode.SUCCESS; + + await runCleanups(); + if (sigHandler) { + process.removeListener("SIGINT", sigHandler); + process.removeListener("SIGTERM", sigHandler); + } + + if (shouldTerminate) { + process.exit(finalExitCode); + } + return finalExitCode; + } catch (error) { + /** @type {number} */ + let exitCode = ExitCode.UNEXPECTED; + if (interruptedBySignal || abortController.signal.aborted) { + exitCode = ExitCode.INTERRUPTED; + } else if (error instanceof CommandError) { + exitCode = error.exitCode; + } + + const message = error instanceof Error ? error.message : String(error); + stderr.write(`${definition.name} failed: ${message}\n`); + + await runCleanups(); + if (sigHandler) { + process.removeListener("SIGINT", sigHandler); + process.removeListener("SIGTERM", sigHandler); + } + + if (shouldTerminate) { + process.exit(exitCode); + } + return exitCode; + } +} diff --git a/packages/command/src/types.d.ts b/packages/command/src/types.d.ts new file mode 100644 index 00000000..4afd84de --- /dev/null +++ b/packages/command/src/types.d.ts @@ -0,0 +1,51 @@ +import type { Logger } from "@sub-rosa/logging"; +import type { ExitCode } from "./errors.js"; + +export interface CommandOption { + type: "string" | "boolean"; + short?: string; + description?: string; + default?: string | boolean | string[]; + multiple?: boolean; +} + +export interface PositionalDescription { + name: string; + description: string; + required?: boolean; +} + +export interface CommandContext> { + args: TValues; + options: TValues; + positionals: string[]; + rawArgs: string[]; + env: Record; + signal: AbortSignal; + registerCleanup: (cleanup: () => Promise | void) => void; + logger: Logger; + repoRoot: string; + resolvePath: (...segments: string[]) => string; +} + +export interface CommandDefinition> { + name: string; + description: string; + usage?: string; + options?: Record; + positionals?: PositionalDescription[]; + requiredEnv?: string[]; + parseArgs?: (rawArgs: string[]) => { args: TValues; positionals: string[] }; + preflight?: (ctx: CommandContext) => Promise | void; + run: (ctx: CommandContext) => Promise | number | void; +} + +export interface RunOptions { + argv?: string[]; + env?: Record; + repoRoot?: string; + signal?: AbortSignal; + terminate?: boolean; + stdout?: { write: (chunk: string) => boolean | void }; + stderr?: { write: (chunk: string) => boolean | void }; +} diff --git a/packages/command/src/types.js b/packages/command/src/types.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/command/src/types.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/command/test/runner.test.ts b/packages/command/test/runner.test.ts new file mode 100644 index 00000000..c4affcc0 --- /dev/null +++ b/packages/command/test/runner.test.ts @@ -0,0 +1,341 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CommandError, + ConfigError, + DependencyError, + ExitCode, + UsageError, + findRepoRoot, + runCommand, +} from "../src/index.js"; + +/** + * Creates a mock writable stream capturing written chunks into an array. + * + * @returns {{ stream: { write: (chunk: string) => boolean }, getOutput: () => string }} + */ +function createBufferStream() { + const chunks: string[] = []; + return { + stream: { + write(chunk: string) { + chunks.push(chunk); + return true; + }, + }, + getOutput() { + return chunks.join(""); + }, + }; +} + +describe("Command Runner Lifecycle", () => { + it("displays help and exits with 0 without running preflight or run handler", async () => { + let preflightRan = false; + let runRan = false; + const stdout = createBufferStream(); + + const exitCode = await runCommand( + { + name: "test-cmd", + description: "A test command for help validation", + options: { + flag: { type: "boolean", short: "f", description: "A sample flag" }, + }, + requiredEnv: ["MANDATORY_ENV_VAR"], + preflight() { + preflightRan = true; + }, + run() { + runRan = true; + }, + }, + { + argv: ["--help"], + env: {}, + stdout: stdout.stream, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.SUCCESS); + assert.equal(preflightRan, false); + assert.equal(runRan, false); + assert.match(stdout.getOutput(), /Usage: test-cmd/); + assert.match(stdout.getOutput(), /A test command for help validation/); + assert.match(stdout.getOutput(), /MANDATORY_ENV_VAR/); + }); + + it("fails with usage exit code on invalid arguments or missing positionals", async () => { + const stderr = createBufferStream(); + + const exitCodeUnknown = await runCommand( + { + name: "test-cmd", + description: "Test argument validation", + options: { + verbose: { type: "boolean", short: "v" }, + }, + run() {}, + }, + { + argv: ["--unknown-option"], + stderr: stderr.stream, + terminate: false, + } + ); + assert.equal(exitCodeUnknown, ExitCode.USAGE); + + const exitCodeMissingPositional = await runCommand( + { + name: "test-cmd", + description: "Test positional validation", + positionals: [ + { name: "target", description: "Target identifier", required: true }, + ], + run() {}, + }, + { + argv: [], + stderr: stderr.stream, + terminate: false, + } + ); + assert.equal(exitCodeMissingPositional, ExitCode.USAGE); + }); + + it("fails with config exit code when required environment variables are absent", async () => { + const stderr = createBufferStream(); + + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test env preflight", + requiredEnv: ["CRITICAL_CONFIG_KEY"], + run() {}, + }, + { + argv: [], + env: {}, + stderr: stderr.stream, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.CONFIG); + assert.match(stderr.getOutput(), /Missing required environment variable: CRITICAL_CONFIG_KEY/); + }); + + it("fails with dependency exit code on external dependency failure", async () => { + const stderr = createBufferStream(); + + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test dependency error mapping", + run() { + throw new DependencyError("RPC endpoint unreachable"); + }, + }, + { + argv: [], + stderr: stderr.stream, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.DEPENDENCY); + assert.match(stderr.getOutput(), /RPC endpoint unreachable/); + }); + + it("maps unexpected errors to code 1", async () => { + const stderr = createBufferStream(); + + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test unexpected failure", + run() { + throw new Error("Unexpected database corruption"); + }, + }, + { + argv: [], + stderr: stderr.stream, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.UNEXPECTED); + assert.match(stderr.getOutput(), /Unexpected database corruption/); + }); + + it("propagates cancellation and returns interrupted exit code 130", async () => { + const controller = new AbortController(); + const stderr = createBufferStream(); + let abortedInHandler = false; + + const promise = runCommand( + { + name: "test-cmd", + description: "Test cancellation", + async run(ctx) { + controller.abort(); + if (ctx.signal.aborted) { + abortedInHandler = true; + } + throw new Error("Execution cancelled"); + }, + }, + { + argv: [], + signal: controller.signal, + stderr: stderr.stream, + terminate: false, + } + ); + + const exitCode = await promise; + assert.equal(abortedInHandler, true); + assert.equal(exitCode, ExitCode.INTERRUPTED); + }); + + it("executes registered cleanups in strict LIFO order exactly once", async () => { + const events: string[] = []; + const stderr = createBufferStream(); + + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test LIFO cleanup execution", + run(ctx) { + ctx.registerCleanup(() => { + events.push("cleanup-1"); + }); + ctx.registerCleanup(() => { + events.push("cleanup-2"); + }); + ctx.registerCleanup(() => { + events.push("cleanup-3"); + }); + throw new Error("Failure triggering cleanup"); + }, + }, + { + argv: [], + stderr: stderr.stream, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.UNEXPECTED); + assert.deepEqual(events, ["cleanup-3", "cleanup-2", "cleanup-1"]); + }); + + it("continues executing remaining cleanups if an individual cleanup throws", async () => { + const events: string[] = []; + const stderr = createBufferStream(); + + await runCommand( + { + name: "test-cmd", + description: "Test resilient cleanup", + run(ctx) { + ctx.registerCleanup(() => { + events.push("first-cleanup"); + }); + ctx.registerCleanup(() => { + throw new Error("Faulty cleanup"); + }); + ctx.registerCleanup(() => { + events.push("third-cleanup"); + }); + }, + }, + { + argv: [], + stderr: stderr.stream, + terminate: false, + } + ); + + assert.deepEqual(events, ["third-cleanup", "first-cleanup"]); + assert.match(stderr.getOutput(), /Cleanup error: Faulty cleanup/); + }); + + it("operates independently of caller working directory and resolves repo root", async () => { + const tmp = mkdtempSync(join(tmpdir(), "cmd-test-")); + try { + const detectedRoot = findRepoRoot(tmp); + assert.ok(detectedRoot.length > 0); + + let resolvedPath = ""; + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test cwd independence", + run(ctx) { + resolvedPath = ctx.resolvePath("package.json"); + }, + }, + { + argv: [], + repoRoot: detectedRoot, + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.SUCCESS); + assert.equal(resolvedPath, join(detectedRoot, "package.json")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("supports custom integer exit codes returned from run handler", async () => { + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test numeric return", + run() { + return 5; + }, + }, + { + argv: [], + terminate: false, + } + ); + assert.equal(exitCode, 5); + }); + + it("supports custom argument parser callback", async () => { + let capturedArg = ""; + const exitCode = await runCommand( + { + name: "test-cmd", + description: "Test custom parser", + parseArgs(rawArgs) { + return { + args: { custom: rawArgs[0] || "" }, + positionals: rawArgs.slice(1), + }; + }, + run(ctx) { + capturedArg = ctx.args.custom; + }, + }, + { + argv: ["hello-world", "pos1"], + terminate: false, + } + ); + + assert.equal(exitCode, ExitCode.SUCCESS); + assert.equal(capturedArg, "hello-world"); + }); +}); diff --git a/packages/command/tsconfig.json b/packages/command/tsconfig.json new file mode 100644 index 00000000..55df9ae4 --- /dev/null +++ b/packages/command/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "allowJs": true, + "checkJs": true, + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 720e2ac2..7057743c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@openzeppelin/relayer-plugin-channels": "^0.20.0", "@stellar/stellar-sdk": "^15.1.0", diff --git a/packages/sdk/scripts/live-smoke.ts b/packages/sdk/scripts/live-smoke.ts index 758c47f0..a44f3ccd 100644 --- a/packages/sdk/scripts/live-smoke.ts +++ b/packages/sdk/scripts/live-smoke.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; +import { runCommand } from "@sub-rosa/command"; const diagnostics = createLogger("packages.sdk.scripts.live-smoke"); // Live smoke gate — proves the SDK's sign → submit → poll → read path actually // works against a real Soroban network, end to end, with no mock and no @@ -50,140 +51,135 @@ const NETWORK = const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); -async function main() { - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const bidderSecret = reqEnv("BIDDER_SECRET"); - const wasmHash = reqEnv("WASM_HASH"); - const usdc = reqEnv("USDC_SAC"); // native XLM SAC id for the smoke run - - const operatorKp = Keypair.fromSecret(operatorSecret); - const bidderKp = Keypair.fromSecret(bidderSecret); - const operatorSigner = basicNodeSigner(operatorKp, NETWORK); - - diagnostics.info("operator", "· operator:", { "value1_0": operatorKp.publicKey() }); - diagnostics.info("bidder", "· bidder: ", { "value1_0": bidderKp.publicKey() }); - diagnostics.info("token", "· token: ", { "usdc_0": usdc, "value2_1": "(native XLM SAC)" }); - - // ── 1. Deploy a fresh Round (constructor over live RPC) ──────────────── - diagnostics.info("1-5-deploying-round-running-constructor", "\n[1/5] deploying Round + running __constructor…"); - const deployTx = await RoundContract.deploy( - { - drand_pubkey: hex(DRAND_PUBKEY_C1C0), - g2_neg_generator: hex(DRAND_NEGGEN_C1C0), - dst: Buffer.from(DST, "utf8"), - drand_genesis: DRAND_GENESIS, - drand_period: DRAND_PERIOD, - usdc, - }, - { - wasmHash, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, +runCommand({ + name: "sdk.live-smoke", + description: "Live smoke gate verifying end-to-end Soroban contract interaction", + async run(ctx) { + const operatorSecret = reqEnv("OPERATOR_SECRET"); + const bidderSecret = reqEnv("BIDDER_SECRET"); + const wasmHash = reqEnv("WASM_HASH"); + const usdc = reqEnv("USDC_SAC"); + + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + + const operatorKp = Keypair.fromSecret(operatorSecret); + const bidderKp = Keypair.fromSecret(bidderSecret); + const operatorSigner = basicNodeSigner(operatorKp, network); + + diagnostics.info("operator", "· operator:", { "value1_0": operatorKp.publicKey() }); + diagnostics.info("bidder", "· bidder: ", { "value1_0": bidderKp.publicKey() }); + diagnostics.info("token", "· token: ", { "usdc_0": usdc, "value2_1": "(native XLM SAC)" }); + + diagnostics.info("1-5-deploying-round-running-constructor", "\n[1/5] deploying Round + running __constructor…"); + const deployTx = await RoundContract.deploy( + { + drand_pubkey: hex(DRAND_PUBKEY_C1C0), + g2_neg_generator: hex(DRAND_NEGGEN_C1C0), + dst: Buffer.from(DST, "utf8"), + drand_genesis: DRAND_GENESIS, + drand_period: DRAND_PERIOD, + usdc, + }, + { + wasmHash, + rpcUrl, + networkPassphrase: network, + publicKey: operatorKp.publicKey(), + signTransaction: operatorSigner.signTransaction, + }, + ); + const deployed = await deployTx.signAndSend(); + const contractId = deployed.result.options.contractId; + diagnostics.info("deployed", " ✔ deployed:", { "contractId_0": contractId }); + + const operator = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: operatorSecret, + }); + + const now = systemClock.nowSeconds(); + const tReveal = now + 300; + const revealRound = Math.ceil((tReveal - Number(DRAND_GENESIS)) / Number(DRAND_PERIOD)); + const tRevealExact = Number(DRAND_GENESIS) + Number(DRAND_PERIOD) * revealRound; + const commitDeadline = now + 90; + const revealDeadline = tRevealExact + 300; + + const auditor = generateAuditorKeypair(); + + diagnostics.info("2-5-createround", "\n[2/5] createRound…", { "value1_0": { revealRound, commitDeadline, revealDeadline } }); + const roundId = await operator.createRound({ + itemRef: sha256("sub-rosa://smoke/item-1"), + revealRound, + commitDeadline, + revealDeadline, + auditorPubkey: auditor.publicKey, + clearingRule: "HighestBid", + }); + diagnostics.info("round-id", " ✔ round id:", { "value1_0": roundId.toString() }); + + diagnostics.info("3-5-sealing-bid-to-quicknet-round-r-commit", "\n[3/5] sealing bid to quicknet round R + commit…"); + const drand = await quicknet(); + const value = 10_000_000n; + const escrow = 50_000_000n; + const nonce = generateNonce(); + const identity = new TextEncoder().encode("bidder:smoke@sub-rosa"); + const sealed = await sealBid({ + value, + nonce, + round: revealRound, + client: drand, + identity, + auditorPublicKey: auditor.publicKey, + }); + + const bidder = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: bidderSecret, + }); + await bidder.commit({ roundId, sealed, escrow }); + diagnostics.info("committed-escrow-locked", " ✔ committed, escrow locked:", { "value1_0": escrow.toString(), "value2_1": "stroops" }); + + diagnostics.info("4-5-reading-state-back-over-rpc", "\n[4/5] reading state back over RPC…"); + const reader = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, publicKey: operatorKp.publicKey(), - signTransaction: operatorSigner.signTransaction, - }, - ); - const deployed = await deployTx.signAndSend(); - const contractId = deployed.result.options.contractId; - diagnostics.info("deployed", " ✔ deployed:", { "contractId_0": contractId }); - - // ── 2. createRound via the SDK (operator signs) ──────────────────────── - const operator = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: operatorSecret, - }); - - const now = systemClock.nowSeconds(); - // Pick R so time(R) = genesis + period·R lands ~5 min in the future, then - // bracket it: now < commit_deadline < time(R) < reveal_deadline. - const tReveal = now + 300; - const revealRound = Math.ceil((tReveal - Number(DRAND_GENESIS)) / Number(DRAND_PERIOD)); - const tRevealExact = Number(DRAND_GENESIS) + Number(DRAND_PERIOD) * revealRound; - const commitDeadline = now + 90; - const revealDeadline = tRevealExact + 300; - - const auditor = generateAuditorKeypair(); - - diagnostics.info("2-5-createround", "\n[2/5] createRound…", { "value1_0": { revealRound, commitDeadline, revealDeadline } }); - const roundId = await operator.createRound({ - itemRef: sha256("sub-rosa://smoke/item-1"), - revealRound, - commitDeadline, - revealDeadline, - auditorPubkey: auditor.publicKey, - clearingRule: "HighestBid", - }); - diagnostics.info("round-id", " ✔ round id:", { "value1_0": roundId.toString() }); - - // ── 3. Seal a real bid to round R and commit (bidder signs) ──────────── - diagnostics.info("3-5-sealing-bid-to-quicknet-round-r-commit", "\n[3/5] sealing bid to quicknet round R + commit…"); - const drand = await quicknet(); - const value = 10_000_000n; // 1 XLM (stroops) bid - const escrow = 50_000_000n; // 5 XLM budget locked - const nonce = generateNonce(); - const identity = new TextEncoder().encode("bidder:smoke@sub-rosa"); - const sealed = await sealBid({ - value, - nonce, - round: revealRound, - client: drand, - identity, - auditorPublicKey: auditor.publicKey, - }); - - const bidder = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: bidderSecret, - }); - await bidder.commit({ roundId, sealed, escrow }); - diagnostics.info("committed-escrow-locked", " ✔ committed, escrow locked:", { "value1_0": escrow.toString(), "value2_1": "stroops" }); - - // ── 4. Read everything back (read-only simulation) ───────────────────── - diagnostics.info("4-5-reading-state-back-over-rpc", "\n[4/5] reading state back over RPC…"); - const reader = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - publicKey: operatorKp.publicKey(), - }); - const round = await reader.getRound(roundId); - const bidders = await reader.getBidders(roundId); - const bidState = await reader.getBidState(roundId, bidderKp.publicKey()); - const seal = await reader.getSeal(roundId, bidderKp.publicKey()); - - // ── 5. Assert the round-trip is exactly what we wrote ────────────────── - diagnostics.info("5-5-verifying", "\n[5/5] verifying…"); - const fail = (m: string): never => { - throw new Error(`smoke assertion failed: ${m}`); - }; - if (round.status.tag !== "Open") fail(`status ${round.status.tag} != Open`); - if (bidders.length !== 1) fail(`bidders ${bidders.length} != 1`); - if (bidders[0] !== bidderKp.publicKey()) fail("bidder index mismatch"); - if (bidState.escrow !== escrow) fail(`escrow ${bidState.escrow} != ${escrow}`); - if (bidState.valid !== false) fail("bid valid before reveal"); - if (!seal) throw new Error("smoke assertion failed: seal not found"); - if (seal.ciphertext.length !== sealed.ciphertext.length) { - fail(`ciphertext len ${seal.ciphertext.length} != ${sealed.ciphertext.length}`); - } - if (Buffer.compare(Buffer.from(bidState.commitment), Buffer.from(sealed.commitment)) !== 0) { - fail("on-chain commitment != off-chain H"); - } - if (seal.auditor_blob.length !== sealed.auditorBlob.length) { - fail("auditor blob length mismatch"); - } - - diagnostics.info("status-open-1-bidder-escrow-locked-commitment-matches-h", " ✔ status Open, 1 bidder, escrow locked, commitment matches H"); - diagnostics.info("on-chain-ciphertext-auditor-blob-match-the-off-chain-se", " ✔ on-chain ciphertext + auditor blob match the off-chain seal"); - diagnostics.info("live-smoke-passed-sign-submit-poll-read-all-work-on-tes", "\n✅ LIVE SMOKE PASSED — sign/submit/poll/read all work on testnet."); - diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString() }); -} - -main().catch((err) => { - diagnostics.error("live-smoke-failed", "\n❌ LIVE SMOKE FAILED"); - diagnostics.error("progress", err); - process.exit(1); + }); + const round = await reader.getRound(roundId); + const bidders = await reader.getBidders(roundId); + const bidState = await reader.getBidState(roundId, bidderKp.publicKey()); + const seal = await reader.getSeal(roundId, bidderKp.publicKey()); + + diagnostics.info("5-5-verifying", "\n[5/5] verifying…"); + const fail = (m: string): never => { + throw new Error(`smoke assertion failed: ${m}`); + }; + if (round.status.tag !== "Open") fail(`status ${round.status.tag} != Open`); + if (bidders.length !== 1) fail(`bidders ${bidders.length} != 1`); + if (bidders[0] !== bidderKp.publicKey()) fail("bidder index mismatch"); + if (bidState.escrow !== escrow) fail(`escrow ${bidState.escrow} != ${escrow}`); + if (bidState.valid !== false) fail("bid valid before reveal"); + if (!seal) throw new Error("smoke assertion failed: seal not found"); + if (seal.ciphertext.length !== sealed.ciphertext.length) { + fail(`ciphertext len ${seal.ciphertext.length} != ${sealed.ciphertext.length}`); + } + if (Buffer.compare(Buffer.from(bidState.commitment), Buffer.from(sealed.commitment)) !== 0) { + fail("on-chain commitment != off-chain H"); + } + if (seal.auditor_blob.length !== sealed.auditorBlob.length) { + fail("auditor blob length mismatch"); + } + + diagnostics.info("status-open-1-bidder-escrow-locked-commitment-matches-h", " ✔ status Open, 1 bidder, escrow locked, commitment matches H"); + diagnostics.info("on-chain-ciphertext-auditor-blob-match-the-off-chain-se", " ✔ on-chain ciphertext + auditor blob match the off-chain seal"); + diagnostics.info("live-smoke-passed-sign-submit-poll-read-all-work-on-tes", "\n✅ LIVE SMOKE PASSED — sign/submit/poll/read all work on testnet."); + diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString() }); + return 0; + }, }); diff --git a/packages/sdk/scripts/mainnet-micro.ts b/packages/sdk/scripts/mainnet-micro.ts index 13c34bed..dd4e5a92 100644 --- a/packages/sdk/scripts/mainnet-micro.ts +++ b/packages/sdk/scripts/mainnet-micro.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("packages.sdk.scripts.mainnet-micro"); -// Optional mainnet micro commit on an EXISTING deployed Round contract. -// -// Default: checklist + dry-run only — no transactions. -// Execute: requires MAINNET_CONFIRM=SUB_ROSA_MAINNET and explicit --execute. -// Amounts are capped well below testnet demo sizes (never 700 USDC-scale). - +import { runCommand } from "@sub-rosa/command"; import { randomBytes } from "node:crypto"; import { Keypair } from "@stellar/stellar-sdk"; @@ -24,11 +18,13 @@ import { import { generateAuditorKeypair, generateNonce, quicknet, sealBid } from "@sub-rosa/tlock"; import { systemClock } from "@sub-rosa/time"; +const diagnostics = createLogger("packages.sdk.scripts.mainnet-micro"); + const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; -const DEFAULT_BID = 500_000n; // 0.05 XLM -const DEFAULT_ESCROW = 1_000_000n; // 0.1 XLM +const DEFAULT_BID = 500_000n; +const DEFAULT_ESCROW = 1_000_000n; function reqEnv(name: string): string { const v = process.env[name]; @@ -68,119 +64,120 @@ function printChecklist(bid: bigint, escrow: bigint, execute: boolean) { diagnostics.info("progress-2", ""); } -async function main() { - const execute = process.argv.includes("--execute"); - const bid = parseStroops("MICRO_BID_STROOPS", DEFAULT_BID); - const escrow = parseStroops("MICRO_ESCROW_STROOPS", DEFAULT_ESCROW); - assertMicroAmounts(bid, escrow); +runCommand({ + name: "sdk.mainnet-micro", + description: "Mainnet micro commit on an existing deployed Round contract", + options: { + execute: { type: "boolean" }, + }, + async run(ctx) { + const execute = Boolean(ctx.options.execute); + const bid = parseStroops("MICRO_BID_STROOPS", DEFAULT_BID); + const escrow = parseStroops("MICRO_ESCROW_STROOPS", DEFAULT_ESCROW); + assertMicroAmounts(bid, escrow); + + printChecklist(bid, escrow, execute); + + if (!execute) { + diagnostics.info("dry-run-complete-to-send-txs", "DRY-RUN complete. To send txs:"); + diagnostics.info("mainnet-confirm-sub-rosa-mainnet-operator-secret-s-bidd", " MAINNET_CONFIRM=SUB_ROSA_MAINNET OPERATOR_SECRET=S… BIDDER_SECRET=S… \\"); + diagnostics.info("pnpm-mainnet-micro-execute", " pnpm mainnet:micro -- --execute"); + return 0; + } - printChecklist(bid, escrow, execute); + if (ctx.env.MAINNET_CONFIRM !== "SUB_ROSA_MAINNET") { + throw new Error('set MAINNET_CONFIRM=SUB_ROSA_MAINNET to execute on mainnet'); + } + assertMainnetConfirmed(); - if (!execute) { - diagnostics.info("dry-run-complete-to-send-txs", "DRY-RUN complete. To send txs:"); - diagnostics.info("mainnet-confirm-sub-rosa-mainnet-operator-secret-s-bidd", " MAINNET_CONFIRM=SUB_ROSA_MAINNET OPERATOR_SECRET=S… BIDDER_SECRET=S… \\"); - diagnostics.info("pnpm-mainnet-micro-execute", " pnpm mainnet:micro -- --execute"); - return; - } + const operatorSecret = reqEnv("OPERATOR_SECRET"); + const bidderSecret = reqEnv("BIDDER_SECRET"); + const contractId = ctx.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; + const rpcUrl = ctx.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; + const network = ctx.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; - if (process.env.MAINNET_CONFIRM !== "SUB_ROSA_MAINNET") { - throw new Error('set MAINNET_CONFIRM=SUB_ROSA_MAINNET to execute on mainnet'); - } - assertMainnetConfirmed(); - - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const bidderSecret = reqEnv("BIDDER_SECRET"); - const contractId = process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; - const rpcUrl = process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; - const network = process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; - - const operatorKp = Keypair.fromSecret(operatorSecret); - const bidderKp = Keypair.fromSecret(bidderSecret); - - const reader = new SubRosaClient({ - rpcUrl, - networkPassphrase: network, - contractId, - publicKey: operatorKp.publicKey(), - }); - - const readiness = await runMainnetReadiness( - defaultMainnetReadinessInput({ + const operatorKp = Keypair.fromSecret(operatorSecret); + const bidderKp = Keypair.fromSecret(bidderSecret); + + const reader = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, - withBalances: true, - operatorAccount: operatorKp.publicKey(), - bidderAccount: bidderKp.publicKey(), - }), - { reader }, - ); - assertReadinessForExecute(readiness.checks); - - // Pick next round id: max existing + 1 (probe up to 32). - let nextRound = 1n; - for (let id = 1n; id <= 32n; id++) { - try { - await reader.getRound(id); - nextRound = id + 1n; - } catch { - break; + publicKey: operatorKp.publicKey(), + }); + + const readiness = await runMainnetReadiness( + defaultMainnetReadinessInput({ + rpcUrl, + networkPassphrase: network, + contractId, + withBalances: true, + operatorAccount: operatorKp.publicKey(), + bidderAccount: bidderKp.publicKey(), + }), + { reader }, + ); + assertReadinessForExecute(readiness.checks); + + let nextRound = 1n; + for (let id = 1n; id <= 32n; id++) { + try { + await reader.getRound(id); + nextRound = id + 1n; + } catch { + break; + } } - } - const now = systemClock.nowSeconds(); - const revealRound = Math.ceil((now + 300 - DRAND_GENESIS) / DRAND_PERIOD); - const commitDeadline = now + 120; - const revealDeadline = DRAND_GENESIS + DRAND_PERIOD * revealRound + 180; - const auditor = generateAuditorKeypair(); - - diagnostics.info("createround-id", `→ createRound id≈${nextRound} R=${revealRound}…`); - const operator = new SubRosaClient({ - rpcUrl, - networkPassphrase: network, - contractId, - secretKey: operatorSecret, - }); - const roundId = await operator.createRound({ - itemRef: randomBytes(32), - revealRound, - commitDeadline, - revealDeadline, - auditorPubkey: auditor.publicKey, - clearingRule: "HighestBid", - }); - - const drand = quicknet(); - const nonce = generateNonce(); - const sealed = await sealBid({ - value: bid, - nonce, - round: revealRound, - client: drand, - identity: new TextEncoder().encode(`micro:${bidderKp.publicKey()}`), - auditorPublicKey: auditor.publicKey, - }); - - diagnostics.info("commit-micro-sealed-bid", "→ commit micro sealed bid…"); - const bidder = new SubRosaClient({ - rpcUrl, - networkPassphrase: network, - contractId, - secretKey: bidderSecret, - }); - await bidder.commit({ roundId, sealed, escrow }); - - diagnostics.info("mainnet-micro-commit-sent", "\n✅ MAINNET MICRO COMMIT SENT"); - diagnostics.info("contract", " contract:", { "contractId_0": contractId }); - diagnostics.info("round", " round: ", { "value1_0": roundId.toString() }); - diagnostics.info("r", " R: ", { "revealRound_0": revealRound }); - diagnostics.info("bid", " bid: ", { "value1_0": (Number(bid) / 1e7).toFixed(7), "value2_1": "XLM" }); - diagnostics.info("escrow", " escrow: ", { "value1_0": (Number(escrow) / 1e7).toFixed(7), "value2_1": "XLM" }); - diagnostics.info("next-wait-for-r-then-pnpm-mainnet-settle-with-round-id", "\nNext: wait for R, then pnpm mainnet:settle with ROUND_ID=", { "value1_0": roundId.toString() }); -} + const now = systemClock.nowSeconds(); + const revealRound = Math.ceil((now + 300 - DRAND_GENESIS) / DRAND_PERIOD); + const commitDeadline = now + 120; + const revealDeadline = DRAND_GENESIS + DRAND_PERIOD * revealRound + 180; + const auditor = generateAuditorKeypair(); -main().catch((err) => { - diagnostics.error("mainnet-micro-failed", "\n❌ MAINNET MICRO FAILED"); - diagnostics.error("progress-3", err); - process.exit(1); + diagnostics.info("createround-id", `→ createRound id≈${nextRound} R=${revealRound}…`); + const operator = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: operatorSecret, + }); + const roundId = await operator.createRound({ + itemRef: randomBytes(32), + revealRound, + commitDeadline, + revealDeadline, + auditorPubkey: auditor.publicKey, + clearingRule: "HighestBid", + }); + + const drand = quicknet(); + const nonce = generateNonce(); + const sealed = await sealBid({ + value: bid, + nonce, + round: revealRound, + client: drand, + identity: new TextEncoder().encode(`micro:${bidderKp.publicKey()}`), + auditorPublicKey: auditor.publicKey, + }); + + diagnostics.info("commit-micro-sealed-bid", "→ commit micro sealed bid…"); + const bidder = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: bidderSecret, + }); + await bidder.commit({ roundId, sealed, escrow }); + + diagnostics.info("mainnet-micro-commit-sent", "\n✅ MAINNET MICRO COMMIT SENT"); + diagnostics.info("contract", " contract:", { "contractId_0": contractId }); + diagnostics.info("round", " round: ", { "value1_0": roundId.toString() }); + diagnostics.info("r", " R: ", { "revealRound_0": revealRound }); + diagnostics.info("bid", " bid: ", { "value1_0": (Number(bid) / 1e7).toFixed(7), "value2_1": "XLM" }); + diagnostics.info("escrow", " escrow: ", { "value1_0": (Number(escrow) / 1e7).toFixed(7), "value2_1": "XLM" }); + diagnostics.info("next-wait-for-r-then-pnpm-mainnet-settle-with-round-id", "\nNext: wait for R, then pnpm mainnet:settle with ROUND_ID=", { "value1_0": roundId.toString() }); + return 0; + }, }); diff --git a/packages/sdk/scripts/mainnet-ready.ts b/packages/sdk/scripts/mainnet-ready.ts index 3c43565d..75f94528 100644 --- a/packages/sdk/scripts/mainnet-ready.ts +++ b/packages/sdk/scripts/mainnet-ready.ts @@ -1,12 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("packages.sdk.scripts.mainnet-ready"); -// Consolidated mainnet launch readiness — read-only by default. -// -// Usage: -// pnpm mainnet:ready -// pnpm mainnet:ready -- --dry-run -// pnpm mainnet:ready -- --with-balances --strict - +import { runCommand } from "@sub-rosa/command"; import { Keypair } from "@stellar/stellar-sdk"; import { SubRosaClient } from "../src/client.js"; @@ -18,77 +11,82 @@ import { runMainnetReadiness, } from "../src/mainnet-readiness.js"; +const diagnostics = createLogger("packages.sdk.scripts.mainnet-ready"); + const DEFAULT_READER_PUBKEY = "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR"; -async function main() { - const dryRun = - process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; - const withBalances = process.argv.includes("--with-balances"); - const strict = process.argv.includes("--strict"); - - const rpcUrl = process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; - const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; - const contractId = - process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; +runCommand({ + name: "sdk.mainnet-ready", + description: "Consolidated mainnet launch readiness check", + options: { + "dry-run": { type: "boolean" }, + "with-balances": { type: "boolean" }, + strict: { type: "boolean" }, + }, + async run(ctx) { + const dryRun = Boolean(ctx.options["dry-run"]) || ctx.env.MAINNET_DRY_RUN === "1"; + const withBalances = Boolean(ctx.options["with-balances"]); + const strict = Boolean(ctx.options.strict); - const operatorAccount = process.env.OPERATOR_SECRET - ? Keypair.fromSecret(process.env.OPERATOR_SECRET).publicKey() - : undefined; - const keeperAccount = process.env.KEEPER_SECRET - ? Keypair.fromSecret(process.env.KEEPER_SECRET).publicKey() - : undefined; - const bidderAccount = process.env.BIDDER_SECRET - ? Keypair.fromSecret(process.env.BIDDER_SECRET).publicKey() - : undefined; + const rpcUrl = ctx.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl; + const networkPassphrase = + ctx.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase; + const contractId = + ctx.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId; - const input = defaultMainnetReadinessInput({ - rpcUrl, - networkPassphrase, - contractId, - live: !dryRun, - withBalances, - operatorAccount, - keeperAccount, - bidderAccount, - }); + const operatorAccount = ctx.env.OPERATOR_SECRET + ? Keypair.fromSecret(ctx.env.OPERATOR_SECRET).publicKey() + : undefined; + const keeperAccount = ctx.env.KEEPER_SECRET + ? Keypair.fromSecret(ctx.env.KEEPER_SECRET).publicKey() + : undefined; + const bidderAccount = ctx.env.BIDDER_SECRET + ? Keypair.fromSecret(ctx.env.BIDDER_SECRET).publicKey() + : undefined; - const reader = dryRun - ? undefined - : new SubRosaClient({ - rpcUrl, - networkPassphrase, - contractId, - publicKey: - process.env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, - }); + const input = defaultMainnetReadinessInput({ + rpcUrl, + networkPassphrase, + contractId, + live: !dryRun, + withBalances, + operatorAccount, + keeperAccount, + bidderAccount, + }); - const report = await runMainnetReadiness(input, { reader }); - diagnostics.info("progress", formatReadinessReport(report)); + const reader = dryRun + ? undefined + : new SubRosaClient({ + rpcUrl, + networkPassphrase, + contractId, + publicKey: + ctx.env.MAINNET_READER_PUBKEY ?? DEFAULT_READER_PUBKEY, + }); - if (strict && hasBlockingFailures(report.checks)) { - throw new Error("readiness checks failed in strict mode"); - } + const report = await runMainnetReadiness(input, { reader }); + diagnostics.info("progress", formatReadinessReport(report)); - if (report.blockCount > 0) { - diagnostics.info("blocking-issues-must-be-resolved-before-mainnet-executi", "\nBlocking issues must be resolved before mainnet execution."); - diagnostics.info("value-moving-commands-require", "Value-moving commands require:"); - diagnostics.info("mainnet-confirm", ` MAINNET_CONFIRM=${MAINNET_CONFIRM_PHRASE}`); - process.exit(1); - } + if (strict && hasBlockingFailures(report.checks)) { + throw new Error("readiness checks failed in strict mode"); + } - diagnostics.info("mainnet-readiness-ok", "\n✅ MAINNET READINESS OK"); - diagnostics.info("recommended-launch-checklist", "Recommended launch checklist:"); - diagnostics.info("1-pnpm-mainnet-ready-strict", " 1. pnpm mainnet:ready -- --strict"); - diagnostics.info("2-pnpm-mainnet-verify", " 2. pnpm mainnet:verify"); - diagnostics.info("3-pnpm-mainnet-micro-dry-run", " 3. pnpm mainnet:micro # dry-run"); - diagnostics.info("4-mainnet-confirm-sub-rosa-mainnet-pnpm-mainnet-micro-e", " 4. MAINNET_CONFIRM=SUB_ROSA_MAINNET … pnpm mainnet:micro -- --execute"); - diagnostics.info("5-mainnet-confirm-sub-rosa-mainnet-pnpm-mainnet-settle", " 5. MAINNET_CONFIRM=SUB_ROSA_MAINNET … pnpm mainnet:settle"); -} + if (report.blockCount > 0) { + diagnostics.info("blocking-issues-must-be-resolved-before-mainnet-executi", "\nBlocking issues must be resolved before mainnet execution."); + diagnostics.info("value-moving-commands-require", "Value-moving commands require:"); + diagnostics.info("mainnet-confirm", ` MAINNET_CONFIRM=${MAINNET_CONFIRM_PHRASE}`); + return 1; + } -main().catch((err) => { - diagnostics.error("mainnet-readiness-failed", "\n❌ MAINNET READINESS FAILED"); - diagnostics.error("progress-2", err); - process.exit(1); + diagnostics.info("mainnet-readiness-ok", "\n✅ MAINNET READINESS OK"); + diagnostics.info("recommended-launch-checklist", "Recommended launch checklist:"); + diagnostics.info("1-pnpm-mainnet-ready-strict", " 1. pnpm mainnet:ready -- --strict"); + diagnostics.info("2-pnpm-mainnet-verify", " 2. pnpm mainnet:verify"); + diagnostics.info("3-pnpm-mainnet-micro-dry-run", " 3. pnpm mainnet:micro # dry-run"); + diagnostics.info("4-mainnet-confirm-sub-rosa-mainnet-pnpm-mainnet-micro-e", " 4. MAINNET_CONFIRM=SUB_ROSA_MAINNET … pnpm mainnet:micro -- --execute"); + diagnostics.info("5-mainnet-confirm-sub-rosa-mainnet-pnpm-mainnet-settle", " 5. MAINNET_CONFIRM=SUB_ROSA_MAINNET … pnpm mainnet:settle"); + return 0; + }, }); diff --git a/packages/sdk/scripts/mainnet-verify.ts b/packages/sdk/scripts/mainnet-verify.ts index 7baff131..707147cb 100644 --- a/packages/sdk/scripts/mainnet-verify.ts +++ b/packages/sdk/scripts/mainnet-verify.ts @@ -1,66 +1,66 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("packages.sdk.scripts.mainnet-verify"); -// Read-only mainnet proof checker — no transactions, no secrets required. -// -// Verifies the deployed Round contract and settled round 1 match frozen artifacts. - +import { runCommand } from "@sub-rosa/command"; import { SubRosaClient } from "../src/client.js"; import { MAINNET_ARTIFACTS } from "../src/mainnet-artifacts.js"; import { verifySettledRoundProof } from "../src/mainnet-readiness.js"; -async function main() { - const dryRun = process.argv.includes("--dry-run") || process.env.MAINNET_DRY_RUN === "1"; +const diagnostics = createLogger("packages.sdk.scripts.mainnet-verify"); - diagnostics.info("sub-rosa-mainnet-settlement-proof-read-only", "Sub Rosa — mainnet settlement proof (read-only)\n"); - diagnostics.info("checklist", "Checklist:"); - diagnostics.info("contract-id-matches-frozen-artifact", " [ ] Contract id matches frozen artifact"); - diagnostics.info("round-1-status-is-settled", " [ ] Round 1 status is Settled"); - diagnostics.info("drand-reveal-round-r-matches-artifact", " [ ] Drand reveal round R matches artifact"); - diagnostics.info("bid-escrow-stroops-match-micro-smoke-amounts-1-5-xlm", " [ ] Bid/escrow stroops match micro smoke amounts (1 / 5 XLM)"); - diagnostics.info("bidder-marked-valid-settled", " [ ] Bidder marked valid + settled\n"); +runCommand({ + name: "sdk.mainnet-verify", + description: "Read-only mainnet proof checker", + options: { + "dry-run": { type: "boolean" }, + }, + async run(ctx) { + const dryRun = Boolean(ctx.options["dry-run"]) || ctx.env.MAINNET_DRY_RUN === "1"; - if (dryRun) { - diagnostics.info("dry-run-would-read-rpc-only-re-run-without-dry-run-to-f", "DRY-RUN — would read RPC only. Re-run without --dry-run to fetch live state.\n"); - diagnostics.info("expected", "Expected:"); - diagnostics.info("progress", JSON.stringify( - { - contractId: MAINNET_ARTIFACTS.contractId, - roundId: MAINNET_ARTIFACTS.settledRoundId, - status: MAINNET_ARTIFACTS.status, - revealRound: MAINNET_ARTIFACTS.revealRound, - bidStroops: MAINNET_ARTIFACTS.bidStroops.toString(), - escrowStroops: MAINNET_ARTIFACTS.escrowStroops.toString(), - }, - null, - 2, - )); - return; - } + diagnostics.info("sub-rosa-mainnet-settlement-proof-read-only", "Sub Rosa — mainnet settlement proof (read-only)\n"); + diagnostics.info("checklist", "Checklist:"); + diagnostics.info("contract-id-matches-frozen-artifact", " [ ] Contract id matches frozen artifact"); + diagnostics.info("round-1-status-is-settled", " [ ] Round 1 status is Settled"); + diagnostics.info("drand-reveal-round-r-matches-artifact", " [ ] Drand reveal round R matches artifact"); + diagnostics.info("bid-escrow-stroops-match-micro-smoke-amounts-1-5-xlm", " [ ] Bid/escrow stroops match micro smoke amounts (1 / 5 XLM)"); + diagnostics.info("bidder-marked-valid-settled", " [ ] Bidder marked valid + settled\n"); - const reader = new SubRosaClient({ - rpcUrl: process.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl, - networkPassphrase: process.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase, - contractId: process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId, - publicKey: process.env.MAINNET_READER_PUBKEY ?? "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", - }); + if (dryRun) { + diagnostics.info("dry-run-would-read-rpc-only-re-run-without-dry-run-to-f", "DRY-RUN — would read RPC only. Re-run without --dry-run to fetch live state.\n"); + diagnostics.info("expected", "Expected:"); + diagnostics.info("progress", JSON.stringify( + { + contractId: MAINNET_ARTIFACTS.contractId, + roundId: MAINNET_ARTIFACTS.settledRoundId, + status: MAINNET_ARTIFACTS.status, + revealRound: MAINNET_ARTIFACTS.revealRound, + bidStroops: MAINNET_ARTIFACTS.bidStroops.toString(), + escrowStroops: MAINNET_ARTIFACTS.escrowStroops.toString(), + }, + null, + 2, + )); + return 0; + } - const roundId = BigInt(process.env.ROUND_ID ?? String(MAINNET_ARTIFACTS.settledRoundId)); - await verifySettledRoundProof(reader, roundId, { - bidStroops: MAINNET_ARTIFACTS.bidStroops, - escrowStroops: MAINNET_ARTIFACTS.escrowStroops, - revealRound: MAINNET_ARTIFACTS.revealRound, - }); + const reader = new SubRosaClient({ + rpcUrl: ctx.env.RPC_URL ?? MAINNET_ARTIFACTS.rpcUrl, + networkPassphrase: ctx.env.NETWORK_PASSPHRASE ?? MAINNET_ARTIFACTS.networkPassphrase, + contractId: ctx.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId, + publicKey: ctx.env.MAINNET_READER_PUBKEY ?? "GCDARJFKKSTJYAZC647H4ZSSSPXPPSKOWOHGMUNCT22VG74KXZ5BHVNR", + }); - diagnostics.info("mainnet-verify-passed", "✅ MAINNET VERIFY PASSED"); - diagnostics.info("contract", " contract:", { "value1_0": process.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); - diagnostics.info("round", " round: ", { "value1_0": roundId.toString(), "value2_1": "status:", "status_2": MAINNET_ARTIFACTS.status }); - diagnostics.info("r", " R: ", { "value1_0": MAINNET_ARTIFACTS.revealRound.toString() }); - diagnostics.info("bid", " bid: ", { "bidXlm_0": MAINNET_ARTIFACTS.bidXlm, "value2_1": "XLM" }); - diagnostics.info("escrow", " escrow: ", { "escrowXlm_0": MAINNET_ARTIFACTS.escrowXlm, "value2_1": "XLM" }); -} + const roundId = BigInt(ctx.env.ROUND_ID ?? String(MAINNET_ARTIFACTS.settledRoundId)); + await verifySettledRoundProof(reader, roundId, { + bidStroops: MAINNET_ARTIFACTS.bidStroops, + escrowStroops: MAINNET_ARTIFACTS.escrowStroops, + revealRound: MAINNET_ARTIFACTS.revealRound, + }); -main().catch((err) => { - diagnostics.error("mainnet-verify-failed", "\n❌ MAINNET VERIFY FAILED"); - diagnostics.error("progress-2", err); - process.exit(1); + diagnostics.info("mainnet-verify-passed", "✅ MAINNET VERIFY PASSED"); + diagnostics.info("contract", " contract:", { "value1_0": ctx.env.ROUND_CONTRACT_ID ?? MAINNET_ARTIFACTS.contractId }); + diagnostics.info("round", " round: ", { "value1_0": roundId.toString(), "value2_1": "status:", "status_2": MAINNET_ARTIFACTS.status }); + diagnostics.info("r", " R: ", { "value1_0": MAINNET_ARTIFACTS.revealRound.toString() }); + diagnostics.info("bid", " bid: ", { "bidXlm_0": MAINNET_ARTIFACTS.bidXlm, "value2_1": "XLM" }); + diagnostics.info("escrow", " escrow: ", { "escrowXlm_0": MAINNET_ARTIFACTS.escrowXlm, "value2_1": "XLM" }); + return 0; + }, }); diff --git a/packages/tlock/package.json b/packages/tlock/package.json index 4a90ebd9..32fd7945 100644 --- a/packages/tlock/package.json +++ b/packages/tlock/package.json @@ -28,6 +28,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", + "@sub-rosa/logging": "workspace:*", "@sub-rosa/time": "workspace:*", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", diff --git a/packages/tlock/src/recover-identities.cli.ts b/packages/tlock/src/recover-identities.cli.ts index d3ced9c2..490f35d2 100644 --- a/packages/tlock/src/recover-identities.cli.ts +++ b/packages/tlock/src/recover-identities.cli.ts @@ -1,25 +1,25 @@ // Copyright (c) 2026 Sub Rosa contributors import process from "node:process"; - +import { runCommand } from "@sub-rosa/command"; import { runAuditorRecoveryCli, usage } from "./auditor-recovery-cli.js"; -const args = process.argv.slice(2); - -if (args.includes("--help") || args.includes("-h")) { - process.stdout.write(`${usage()}\n`); - process.exit(0); -} +runCommand({ + name: "tlock.recover-identities", + description: "Auditor identity recovery CLI", + usage: usage(), + async run(ctx) { + const stdin = process.stdin.isTTY ? "" : await new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); -const stdin = process.stdin.isTTY ? "" : await new Promise((resolve, reject) => { - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { - data += chunk; - }); - process.stdin.on("end", () => resolve(data)); - process.stdin.on("error", reject); + const result = runAuditorRecoveryCli(ctx.rawArgs, stdin); + process.stdout.write(`${JSON.stringify(result.output, null, 2)}\n`); + return result.exitCode; + }, }); - -const result = runAuditorRecoveryCli(args, stdin); -process.stdout.write(`${JSON.stringify(result.output, null, 2)}\n`); -process.exit(result.exitCode); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f68a677..4bcddce2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,14 @@ packageExtensionsChecksum: sha256-Z9PoklkhhwcDAg079Of1cxD2PjhVdMMdBUjh59gsj38= importers: - .: {} + .: + devDependencies: + '@sub-rosa/command': + specifier: workspace:* + version: link:packages/command + '@sub-rosa/logging': + specifier: workspace:* + version: link:packages/logging apps/web: dependencies: @@ -83,6 +90,25 @@ importers: specifier: ^6.3.5 version: 6.4.3(@types/node@25.9.1)(tsx@4.22.4) + packages/command: + dependencies: + '@sub-rosa/logging': + specifier: workspace:* + version: link:../logging + '@sub-rosa/time': + specifier: workspace:* + version: link:../time + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/logging: {} packages/round-bindings: @@ -112,6 +138,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/command': + specifier: workspace:* + version: link:../command '@sub-rosa/logging': specifier: workspace:* version: link:../logging @@ -158,6 +187,12 @@ importers: '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 + '@sub-rosa/command': + specifier: workspace:* + version: link:../command + '@sub-rosa/logging': + specifier: workspace:* + version: link:../logging '@sub-rosa/time': specifier: workspace:* version: link:../time @@ -186,6 +221,9 @@ importers: '@sub-rosa/appraisal-api': specifier: workspace:* version: link:../appraisal-api + '@sub-rosa/command': + specifier: workspace:* + version: link:../../packages/command '@sub-rosa/keeper': specifier: workspace:* version: link:../keeper @@ -220,6 +258,9 @@ importers: '@stellar/stellar-sdk': specifier: ^15.1.0 version: 15.1.0 + '@sub-rosa/command': + specifier: workspace:* + version: link:../../packages/command '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -242,6 +283,9 @@ importers: services/auction-template: dependencies: + '@sub-rosa/command': + specifier: workspace:* + version: link:../../packages/command '@sub-rosa/keeper': specifier: workspace:* version: link:../keeper @@ -295,6 +339,9 @@ importers: services/keeper: dependencies: + '@sub-rosa/command': + specifier: workspace:* + version: link:../../packages/command '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging @@ -323,6 +370,9 @@ importers: services/receipt-cli: dependencies: + '@sub-rosa/command': + specifier: workspace:* + version: link:../../packages/command '@sub-rosa/logging': specifier: workspace:* version: link:../../packages/logging diff --git a/scripts/check-command-runner.mjs b/scripts/check-command-runner.mjs new file mode 100644 index 00000000..2ee429b1 --- /dev/null +++ b/scripts/check-command-runner.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createLogger } from "@sub-rosa/logging"; +import { runCommand } from "@sub-rosa/command"; + +const diagnostics = createLogger("scripts.check-command-runner"); +const ROOT = new URL("..", import.meta.url).pathname; + +const STATIC_TARGET_FILES = [ + "packages/tlock/src/recover-identities.cli.ts", + "services/receipt-cli/src/index.ts", + "services/keeper/src/run.ts", + "services/keeper/src/watch.ts", + "services/keeper/src/serve.ts", + "services/keeper/src/queue.ts", + "services/auction-template/sealed-auction.ts", +]; + +/** + * Checks a script content for command runner compliance. + * @param {string} content + * @param {string} relPath + * @returns {{ relPath: string, rule: string, message: string }[]} + */ +export function findViolations(content, relPath) { + const violations = []; + + const importsRunCommand = /(?:import\s*\{[^}]*\brunCommand\b[^}]*\}|const\s*\{[^}]*\brunCommand\b[^}]*\}\s*=)/.test(content); + const importsFromCommandPackage = /(?:['"]@sub-rosa\/command['"]|['"][^\x27"]*packages\/command)/.test(content); + + if (!importsRunCommand || !importsFromCommandPackage) { + violations.push({ + relPath, + rule: "missing-run-command-import", + message: "File does not import runCommand from @sub-rosa/command", + }); + } + + const callsRunCommand = /\brunCommand\s*\(/.test(content); + if (!callsRunCommand) { + violations.push({ + relPath, + rule: "missing-run-command-call", + message: "File does not call runCommand({ ... })", + }); + } + + const hasRawProcessExit = /(? { + it("flags missing runCommand import", () => { + const hits = findViolations( + 'runCommand({ name: "test", run: () => 0 });', + "packages/test/scripts/foo.ts", + ); + assert.equal(hits.length, 1); + assert.equal(hits[0].rule, "missing-run-command-import"); + }); + + it("flags missing runCommand call", () => { + const hits = findViolations( + 'import { runCommand } from "@sub-rosa/command";\nconsole.log("done");', + "packages/test/scripts/foo.ts", + ); + assert.equal(hits.length, 1); + assert.equal(hits[0].rule, "missing-run-command-call"); + }); + + it("flags direct process.exit calls in operational scripts", () => { + const hits = findViolations( + 'import { runCommand } from "@sub-rosa/command";\nrunCommand({ run: () => process.exit(1) });', + "packages/test/scripts/foo.ts", + ); + assert.equal(hits.length, 1); + assert.equal(hits[0].rule, "direct-process-exit"); + }); + + it("passes compliant ESM command runner script", () => { + const hits = findViolations( + 'import { runCommand } from "@sub-rosa/command";\nrunCommand({ name: "test", run: () => 0 });', + "packages/test/scripts/foo.ts", + ); + assert.equal(hits.length, 0); + }); + + it("passes compliant CJS command runner script", () => { + const hits = findViolations( + 'const { runCommand } = require("@sub-rosa/command");\nrunCommand({ name: "test", run: () => 0 });', + "scripts/check-test.js", + ); + assert.equal(hits.length, 0); + }); +}); + +describe("discoverTargetScripts", () => { + it("discovers all root check scripts and operational service scripts", () => { + const targets = discoverTargetScripts(); + assert.ok(targets.includes("scripts/check-links.js")); + assert.ok(targets.includes("scripts/check-command-runner.mjs")); + assert.ok(targets.includes("services/keeper/src/run.ts")); + assert.ok(targets.includes("services/receipt-cli/src/index.ts")); + assert.ok(targets.includes("packages/sdk/scripts/live-smoke.ts")); + }); +}); + +describe("scanTargetScripts", () => { + it("passes on the current repository tree with zero violations", () => { + const violations = scanTargetScripts(); + assert.equal( + violations.length, + 0, + violations.map((v) => `${v.relPath}: [${v.rule}] ${v.message}`).join("\n"), + ); + }); +}); diff --git a/scripts/check-deploy-docs.mjs b/scripts/check-deploy-docs.mjs index 2eb7345c..0f55497d 100644 --- a/scripts/check-deploy-docs.mjs +++ b/scripts/check-deploy-docs.mjs @@ -25,20 +25,7 @@ const diagnostics = createLogger("scripts.check-deploy-docs"); import { readFileSync } from "node:fs"; import { resolve } from "node:path"; - -// `DEPLOY_DOCS_ROOT` lets tests redirect reads to a fixture project tree. -// In normal use, `pnpm docs:check` runs from the repo root, so process.cwd() -// is the project root. -const ROOT = process.env.DEPLOY_DOCS_ROOT - ? process.env.DEPLOY_DOCS_ROOT - : process.cwd(); - -const PATHS = { - deployDoc: resolve(ROOT, "docs/DEPLOY.md"), - rootEnv: resolve(ROOT, ".env.example"), - webEnv: resolve(ROOT, "apps/web/.env.example"), - rootPkg: resolve(ROOT, "package.json"), -}; +import { runCommand } from "@sub-rosa/command"; const SKIP_PNPM = new Set(["install"]); @@ -64,16 +51,15 @@ const ENV_TABLE_RE = /\|\s*`?([A-Z][A-Z0-9_]{2,})`?\s*\|/g; // `pnpm --filter @scope/pkg ` (matches if and only if `--filter` is present) const PNPM_FILTER_RE = /\bpnpm\s+--filter\s+(\S+)\s+([a-z][a-z0-9_.:-]*)/g; -// `pnpm ` where is a top-level script key (e.g. `pnpm web:build`). -// Does not match `pnpm --filter ...`. -const PNPM_PLAIN_RE = /\bpnpm\s+(?!--filter\b)([a-z][a-z0-9_.:-]*)/g; +const TABLE_ENV_RE = /\|\s*`?([A-Z][A-Z0-9_]{2,})`?\s*\|/g; +const PNPM_CMD_RE = /\bpnpm(?:\s+--filter\s+(\S+))?\s+([a-z][a-z0-9_.:-]*)/g; // Walk a handful of well-known workspace package.json paths to build a // `name -> Set` map. Kept deliberately lightweight and explicit -// (no glob walking, no yaml parsing) so the script stays trivially auditable -// and runnable without extra deps. Keep this list in sync with -// pnpm-workspace.yaml when workspace layout changes. const WORKSPACE_PKG_DIRS = [ + "packages/command", + "packages/logging", + "packages/time", "packages/sdk", "packages/round-bindings", "packages/tlock", @@ -86,27 +72,35 @@ const WORKSPACE_PKG_DIRS = [ "apps/web", ]; +function getPaths(root) { + return { + deployDoc: resolve(root, "docs/DEPLOY.md"), + rootEnv: resolve(root, ".env.example"), + webEnv: resolve(root, "apps/web/.env.example"), + rootPkg: resolve(root, "package.json"), + }; +} + function loadEnvKeys(filePath) { const text = readFileSync(filePath, "utf8"); - // Match either an active `NAME=…` line or a commented `# NAME=…` line. const re = /^[ \t]*(?:#[ \t]*)?([A-Z][A-Z0-9_]{2,})\s*=/gm; return new Set(Array.from(text.matchAll(re), (m) => m[1])); } -function loadRootScripts() { - const pkg = JSON.parse(readFileSync(PATHS.rootPkg, "utf8")); +function loadRootScripts(rootPkgPath) { + const pkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); return new Set(Object.keys(pkg.scripts ?? {})); } -function loadWorkspaceScripts() { +function loadWorkspaceScripts(root) { const map = new Map(); for (const dir of WORKSPACE_PKG_DIRS) { - const pkgPath = resolve(ROOT, dir, "package.json"); + const pkgPath = resolve(root, dir, "package.json"); let raw; try { raw = readFileSync(pkgPath, "utf8"); } catch (err) { - if (err.code === "ENOENT") continue; // package not present -- fine + if (err.code === "ENOENT") continue; throw err; } const pkg = JSON.parse(raw); @@ -119,42 +113,54 @@ function loadWorkspaceScripts() { function isLikelyEnvVar(name) { if (KNOWN_NON_ENVS.has(name)) return false; - // Skip pure-digit or 1-letter placeholders like `S…`, `C…`, `G…` which - // intentionally do not start with a letter; this is a defensive belt. - return /^[A-Z][A-Z0-9_]{2,}$/.test(name); + if (!/^[A-Z]/.test(name)) return false; + return true; } function findDocEnvVars(docText) { - const set = new Set(); - for (const m of docText.matchAll(ENV_ASSIGN_RE)) set.add(m[1]); - for (const m of docText.matchAll(ENV_TABLE_RE)) set.add(m[1]); - return [...set].filter(isLikelyEnvVar).sort(); + const found = new Set(); + for (const match of docText.matchAll(ENV_ASSIGN_RE)) { + const name = match[1]; + if (isLikelyEnvVar(name)) found.add(name); + } + for (const match of docText.matchAll(TABLE_ENV_RE)) { + const name = match[1]; + if (isLikelyEnvVar(name)) found.add(name); + } + return Array.from(found).sort(); } function findDocPnpmCommands(docText) { const out = []; - for (const m of docText.matchAll(PNPM_FILTER_RE)) { - const cmd = m[2]; + for (const match of docText.matchAll(PNPM_CMD_RE)) { + const pkg = match[1]; + const cmd = match[2]; if (SKIP_PNPM.has(cmd)) continue; - out.push({ kind: "filter", pkg: m[1], cmd, spec: `pnpm --filter ${m[1]} ${cmd}` }); - } - for (const m of docText.matchAll(PNPM_PLAIN_RE)) { - const cmd = m[1]; - if (SKIP_PNPM.has(cmd)) continue; - out.push({ kind: "plain", pkg: null, cmd, spec: `pnpm ${cmd}` }); + if (pkg) { + out.push({ kind: "filtered", pkg, cmd, spec: `pnpm --filter ${pkg} ${cmd}` }); + } else { + out.push({ kind: "plain", cmd, spec: `pnpm ${cmd}` }); + } } - // De-duplicate by `spec`. const seen = new Set(); return out.filter((c) => (seen.has(c.spec) ? false : (seen.add(c.spec), true))); } -function main() { - const docText = readFileSync(PATHS.deployDoc, "utf8"); - - const rootAllowed = loadEnvKeys(PATHS.rootEnv); - const webAllowed = loadEnvKeys(PATHS.webEnv); - const rootScripts = loadRootScripts(); - const wsScripts = loadWorkspaceScripts(); +/** + * Validates deploy documentation references against environment templates and scripts. + * + * @param {string} [targetRoot] + * @returns {number} + */ +export function main(targetRoot) { + const root = process.env.DEPLOY_DOCS_ROOT || targetRoot || process.cwd(); + const paths = getPaths(root); + const docText = readFileSync(paths.deployDoc, "utf8"); + + const rootAllowed = loadEnvKeys(paths.rootEnv); + const webAllowed = loadEnvKeys(paths.webEnv); + const rootScripts = loadRootScripts(paths.rootPkg); + const wsScripts = loadWorkspaceScripts(root); const envVars = findDocEnvVars(docText); const commands = findDocPnpmCommands(docText); @@ -201,4 +207,10 @@ function main() { return 1; } -process.exit(main()); +runCommand({ + name: "scripts.check-deploy-docs", + description: "Check docs/DEPLOY.md references for consistency with .env.example files and package.json scripts", + run(ctx) { + return main(ctx.repoRoot); + }, +}); diff --git a/scripts/check-direct-time-access.mjs b/scripts/check-direct-time-access.mjs index 0c4b6855..6bbd9252 100644 --- a/scripts/check-direct-time-access.mjs +++ b/scripts/check-direct-time-access.mjs @@ -9,6 +9,7 @@ const diagnostics = createLogger("scripts.check-direct-time-access"); import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; +import { runCommand } from "@sub-rosa/command"; const ROOT = new URL("..", import.meta.url).pathname; const SCAN_ROOTS = ["packages", "services", "apps"]; @@ -90,27 +91,29 @@ export function scanTree(rootDir = ROOT) { return all; } -function main() { - const violations = scanTree(); - diagnostics.info("direct-time-access-guard", "\nDirect time-access guard"); - diagnostics.info("progress", "=".repeat(72)); - diagnostics.info("scanned", ` scanned: ${SCAN_ROOTS.join(", ")}`); - diagnostics.info("allowed", ` allowed: ${[...ALLOWED].join(", ")}`); - diagnostics.info("progress-2", "=".repeat(72)); - - if (violations.length === 0) { - diagnostics.info("pass-no-direct-date-timer-usage-outside-sub-rosa-time", "PASS no direct Date/timer usage outside @sub-rosa/time."); - process.exit(0); - } +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runCommand({ + name: "scripts.check-direct-time-access", + description: "Fail when first-party code calls wall-clock or timer globals directly", + run(ctx) { + const violations = scanTree(ctx.repoRoot); + diagnostics.info("direct-time-access-guard", "\nDirect time-access guard"); + diagnostics.info("progress", "=".repeat(72)); + diagnostics.info("scanned", ` scanned: ${SCAN_ROOTS.join(", ")}`); + diagnostics.info("allowed", ` allowed: ${[...ALLOWED].join(", ")}`); + diagnostics.info("progress-2", "=".repeat(72)); - diagnostics.error("fail", `FAIL ${violations.length} violation(s):`); - for (const v of violations) { - diagnostics.error("progress-3", ` ${v.relPath}:${v.line} ${v.pattern} ${v.text}`); - } - diagnostics.error("use-sub-rosa-time-systemtime-fakeclock-fakescheduler-in", "\nUse @sub-rosa/time (systemTime, FakeClock, FakeScheduler) instead."); - process.exit(1); -} + if (violations.length === 0) { + diagnostics.info("pass-no-direct-date-timer-usage-outside-sub-rosa-time", "PASS no direct Date/timer usage outside @sub-rosa/time."); + return 0; + } -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main(); + diagnostics.error("fail", `FAIL ${violations.length} violation(s):`); + for (const v of violations) { + diagnostics.error("progress-3", ` ${v.relPath}:${v.line} ${v.pattern} ${v.text}`); + } + diagnostics.error("use-sub-rosa-time-systemtime-fakeclock-fakescheduler-in", "\nUse @sub-rosa/time (systemTime, FakeClock, FakeScheduler) instead."); + return 1; + }, + }); } diff --git a/scripts/check-fixture-sizes.mjs b/scripts/check-fixture-sizes.mjs index 7e983d29..465345bd 100644 --- a/scripts/check-fixture-sizes.mjs +++ b/scripts/check-fixture-sizes.mjs @@ -2,7 +2,8 @@ import { createLogger } from '../packages/logging/src/index.cjs'; const diagnostics = createLogger("scripts.check-fixture-sizes"); import { existsSync, readdirSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +import { join, relative, resolve } from "node:path"; +import { runCommand } from "@sub-rosa/command"; const GROUPS = [ { @@ -40,9 +41,6 @@ function walk(dir, include) { } } } catch (error) { - // Un directorio ausente es normal al recorrer en profundidad. Cualquier - // otro error, como un permiso denegado, no lo es: tragarselo hacia que un - // grupo ilegible se viera igual que uno vacio. if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; } return files; @@ -54,10 +52,11 @@ function formatBytes(bytes) { return `${bytes} B`; } -function checkGroup(group) { - const files = walk(group.dir, group.include).map((f) => { +function checkGroup(group, rootDir) { + const targetDir = resolve(rootDir, group.dir); + const files = walk(targetDir, group.include).map((f) => { const bytes = statSync(f).size; - return { path: relative(process.cwd(), f), bytes, ok: bytes <= group.perFileBytes }; + return { path: relative(rootDir, f), bytes, ok: bytes <= group.perFileBytes }; }); const totalBytes = files.reduce((s, f) => s + f.bytes, 0); @@ -67,7 +66,7 @@ function checkGroup(group) { return { label: group.label, dir: group.dir, - dirExists: existsSync(group.dir), + dirExists: existsSync(targetDir), files, totalBytes, totalOk, @@ -75,19 +74,18 @@ function checkGroup(group) { }; } -function main() { +/** + * Validates fixture file sizes against designated budget thresholds. + * + * @param {string} rootDir + * @returns {number} + */ +export function main(rootDir) { let allPassed = true; for (const group of GROUPS) { - const result = checkGroup(group); - - // Todo grupo configurado en GROUPS es obligatorio. Antes un grupo ausente - // o vacio salia por SKIP y el proceso terminaba en 0, asi que borrar un - // grupo entero desactivaba en silencio el presupuesto que lo cuidaba. - // - // Los dos casos se informan por separado a proposito: "no existe" y "esta - // vacio" se arreglan distinto, y un solo mensaje para los dos te obliga a - // ir a mirar cual de los dos fue. + const result = checkGroup(group, rootDir); + if (!result.dirExists) { diagnostics.info("fail", ` [FAIL] ${group.label} — required fixture directory is missing: ${group.dir}`); allPassed = false; @@ -128,12 +126,22 @@ function main() { diagnostics.info("progress-7", ""); if (allPassed) { diagnostics.info("all-fixture-size-budgets-are-within-limits", "All fixture size budgets are within limits."); - process.exit(0); - } else { - diagnostics.info("fixture-check-failed-a-budget-was-exceeded-or-a-require", "Fixture check failed: a budget was exceeded or a required group is missing."); - diagnostics.info("to-update-budgets-edit-groups-in-scripts-check-fixture", "To update budgets, edit GROUPS in scripts/check-fixture-sizes.mjs."); - process.exit(1); + return 0; } + diagnostics.info("fixture-check-failed-a-budget-was-exceeded-or-a-require", "Fixture check failed: a budget was exceeded or a required group is missing."); + diagnostics.info("to-update-budgets-edit-groups-in-scripts-check-fixture", "To update budgets, edit GROUPS in scripts/check-fixture-sizes.mjs."); + return 1; } -main(); +runCommand({ + name: "scripts.check-fixture-sizes", + description: "Verify fixture size budgets remain within limits", + run(ctx) { + const baseDir = + process.env.FIXTURE_CHECK_ROOT || + (existsSync(resolve(process.cwd(), "services/receipt-cli/src/fixtures")) + ? process.cwd() + : ctx.repoRoot); + return main(baseDir); + }, +}); diff --git a/scripts/check-links.js b/scripts/check-links.js index 73d6ab4c..5c078102 100644 --- a/scripts/check-links.js +++ b/scripts/check-links.js @@ -6,10 +6,8 @@ const diagnostics = createLogger("scripts.check-links"); const fs = require('fs'); const path = require('path'); +const { runCommand } = require('@sub-rosa/command'); -const ROOT = path.resolve(__dirname, '..'); - -// Markdown files to scan (relative to ROOT) const FILES = [ 'README.md', 'ARCHITECTURE.md', @@ -28,12 +26,14 @@ const FILES = [ 'packages/round-bindings/README.md', ]; -// Allowlist for intentional placeholder links. -// Format: "source-file:link-target" (both relative to ROOT). -// Add entries here when a link is deliberately forward-looking. const ALLOWLIST = new Set([]); -// GitHub-compatible heading slug (matches GitHub Markdown rendering) +/** + * Normalizes text to a markdown header slug. + * + * @param {string} text + * @returns {string} + */ function headingSlug(text) { return text .toLowerCase() @@ -42,7 +42,12 @@ function headingSlug(text) { .replace(/\s+/g, '-'); } -// Extract all heading slugs from a markdown string +/** + * Extracts all heading slugs from markdown text. + * + * @param {string} content + * @returns {Set} + */ function extractSlugs(content) { const slugs = new Set(); const re = /^#{1,6}\s+(.+)$/gm; @@ -53,8 +58,14 @@ function extractSlugs(content) { return slugs; } -// Cache of slug sets keyed by absolute path const slugCache = new Map(); + +/** + * Retrieves cached heading slugs for a given markdown file. + * + * @param {string} absPath + * @returns {Set} + */ function getSlugs(absPath) { if (!slugCache.has(absPath)) { const content = fs.readFileSync(absPath, 'utf8'); @@ -65,100 +76,110 @@ function getSlugs(absPath) { const LINK_RE = /\[([^\]]*)\]\(([^)\s]+)\)/g; -let broken = 0; -let checked = 0; -let skipped = 0; -const external = []; - -const showExternal = process.argv.includes('--external'); - -for (const relFile of FILES) { - const absFile = path.join(ROOT, relFile); - - if (!fs.existsSync(absFile)) { - diagnostics.error("error", `ERROR ${relFile}:0 — source file not found`); - broken++; - continue; - } - - const content = fs.readFileSync(absFile, 'utf8'); - const lines = content.split('\n'); - const fileDir = path.dirname(absFile); - - lines.forEach((line, idx) => { - const lineNum = idx + 1; - LINK_RE.lastIndex = 0; - let m; - while ((m = LINK_RE.exec(line)) !== null) { - const href = m[2]; - - // Skip external links - if (/^https?:\/\/|^mailto:/.test(href)) { - external.push({ file: relFile, line: lineNum, href }); - continue; - } - - // Split path from anchor - const hashIdx = href.indexOf('#'); - const filePart = hashIdx === -1 ? href : href.slice(0, hashIdx); - const anchor = hashIdx === -1 ? null : href.slice(hashIdx + 1); - - const allowKey = `${relFile}:${href}`; - if (ALLOWLIST.has(allowKey)) { - skipped++; +runCommand({ + name: "scripts.check-links", + description: "Check markdown links across documentation files", + options: { + external: { + type: "boolean", + short: "e", + default: false, + description: "List external links", + }, + }, + run(ctx) { + const rootDir = ctx.repoRoot; + const showExternal = Boolean(ctx.options.external); + + let broken = 0; + let checked = 0; + let skipped = 0; + const external = []; + + for (const relFile of FILES) { + const absFile = path.join(rootDir, relFile); + + if (!fs.existsSync(absFile)) { + diagnostics.error("error", `ERROR ${relFile}:0 — source file not found`); + broken++; continue; } - // Pure anchor link (#section) — check within same file - if (!filePart) { - checked++; - const ownSlugs = getSlugs(absFile); - if (!ownSlugs.has(anchor)) { - diagnostics.error("broken", `BROKEN ${relFile}:${lineNum} — anchor #${anchor} not found in same file`); - broken++; + const content = fs.readFileSync(absFile, 'utf8'); + const lines = content.split('\n'); + const fileDir = path.dirname(absFile); + + lines.forEach((line, idx) => { + const lineNum = idx + 1; + LINK_RE.lastIndex = 0; + let m; + while ((m = LINK_RE.exec(line)) !== null) { + const href = m[2]; + + if (/^https?:\/\/|^mailto:/.test(href)) { + external.push({ file: relFile, line: lineNum, href }); + continue; + } + + const hashIdx = href.indexOf('#'); + const filePart = hashIdx === -1 ? href : href.slice(0, hashIdx); + const anchor = hashIdx === -1 ? null : href.slice(hashIdx + 1); + + const allowKey = `${relFile}:${href}`; + if (ALLOWLIST.has(allowKey)) { + skipped++; + continue; + } + + if (!filePart) { + checked++; + const ownSlugs = getSlugs(absFile); + if (!ownSlugs.has(anchor)) { + diagnostics.error("broken", `BROKEN ${relFile}:${lineNum} — anchor #${anchor} not found in same file`); + broken++; + } + continue; + } + + const absTarget = path.resolve(fileDir, filePart); + + checked++; + + if (!fs.existsSync(absTarget)) { + const rel = path.relative(rootDir, absTarget); + diagnostics.error("broken-2", `BROKEN ${relFile}:${lineNum} — file not found: ${filePart} (→ ${rel})`); + broken++; + continue; + } + + if (anchor && /\.md$/i.test(absTarget)) { + const targetSlugs = getSlugs(absTarget); + if (!targetSlugs.has(anchor.toLowerCase())) { + diagnostics.error("broken-3", `BROKEN ${relFile}:${lineNum} — anchor #${anchor} not found in ${path.relative(rootDir, absTarget)}`); + broken++; + } + } } - continue; - } - - // Resolve file path - const absTarget = path.resolve(fileDir, filePart); - - checked++; - - if (!fs.existsSync(absTarget)) { - const rel = path.relative(ROOT, absTarget); - diagnostics.error("broken-2", `BROKEN ${relFile}:${lineNum} — file not found: ${filePart} (→ ${rel})`); - broken++; - continue; - } + }); + } - // Check anchor in target file (only for .md files) - if (anchor && /\.md$/i.test(absTarget)) { - const targetSlugs = getSlugs(absTarget); - if (!targetSlugs.has(anchor.toLowerCase())) { - diagnostics.error("broken-3", `BROKEN ${relFile}:${lineNum} — anchor #${anchor} not found in ${path.relative(ROOT, absTarget)}`); - broken++; + if (external.length > 0) { + if (showExternal) { + diagnostics.info("external-links", `\nExternal links (${external.length}, not validated):`); + for (const { file, line, href } of external) { + diagnostics.info("progress", ` ${file}:${line}: ${href}`); } + } else { + diagnostics.info("external-links-2", `External links: ${external.length} (pass --external to list)`); } } - }); -} -if (external.length > 0) { - if (showExternal) { - diagnostics.info("external-links", `\nExternal links (${external.length}, not validated):`); - for (const { file, line, href } of external) { - diagnostics.info("progress", ` ${file}:${line}: ${href}`); + if (broken === 0) { + diagnostics.info("ok", `OK ${checked} local link(s) checked, ${skipped} allowlisted`); + return 0; } - } else { - diagnostics.info("external-links-2", `External links: ${external.length} (pass --external to list)`); - } -} -if (broken === 0) { - diagnostics.info("ok", `OK ${checked} local link(s) checked, ${skipped} allowlisted`); - process.exit(0); -} else { - diagnostics.error("fail", `\nFAIL ${broken} broken link(s) — fix the paths above or add to ALLOWLIST in scripts/check-links.js`); - process.exit(1); -} + diagnostics.error("fail", `\nFAIL ${broken} broken link(s) — fix the paths above or add to ALLOWLIST in scripts/check-links.js`); + return 1; + }, +}); diff --git a/scripts/check-logging.mjs b/scripts/check-logging.mjs index 2fa24c65..95d1c704 100644 --- a/scripts/check-logging.mjs +++ b/scripts/check-logging.mjs @@ -21,8 +21,20 @@ export function scanTree(base=root) { for(const entry of ['packages','services','apps','scripts','.github'])walk(join(base,entry)); return hits; } +import { runCommand } from '@sub-rosa/command'; + if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){ - const logger=createLogger('scripts.check-logging'), violations=scanTree(); - if(violations.length){logger.error('direct-console-found','Direct console calls are forbidden',{violations});process.exitCode=1;} - else logger.info('logging-guard-passed','No direct console calls in runtime or operational scripts'); + runCommand({ + name: 'scripts.check-logging', + description: 'Check for direct console logging calls', + async run() { + const logger=createLogger('scripts.check-logging'), violations=scanTree(); + if(violations.length){ + logger.error('direct-console-found','Direct console calls are forbidden',{violations}); + return 1; + } + logger.info('logging-guard-passed','No direct console calls in runtime or operational scripts'); + return 0; + } + }); } diff --git a/scripts/check-round-errors.mjs b/scripts/check-round-errors.mjs index 3177d4cb..ea59f4a3 100644 --- a/scripts/check-round-errors.mjs +++ b/scripts/check-round-errors.mjs @@ -12,6 +12,7 @@ const diagnostics = createLogger("scripts.check-round-errors"); import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { runCommand } from "@sub-rosa/command"; const DEFAULT_TYPES = "contracts/round/src/types.rs"; const DEFAULT_DOC = "contracts/round/ERRORS.md"; @@ -96,55 +97,61 @@ export function diffVariants(left, right, leftLabel, rightLabel) { return failures; } -function loadFile(pathArg, fallback) { +function loadFile(pathArg, repoRoot, fallback) { const path = pathArg || fallback; - const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path); + const absolute = isAbsolute(path) ? path : resolve(repoRoot, path); if (!existsSync(absolute)) { return { content: null, path: absolute }; } return { content: readFileSync(absolute, "utf-8"), path: absolute }; } -function main() { - const typesPath = process.argv[2] || DEFAULT_TYPES; - const docPath = process.argv[3] || DEFAULT_DOC; - - const typesFile = loadFile(typesPath, DEFAULT_TYPES); - const docFile = loadFile(docPath, DEFAULT_DOC); - - if (typesFile.content === null) { - diagnostics.error("fail-types-file-not-found", `[FAIL] types file not found: ${typesFile.path}`); - process.exit(1); - } - if (docFile.content === null) { - diagnostics.error("fail-errors-md-not-found", `[FAIL] ERRORS.md not found: ${docFile.path}`); - process.exit(1); - } - - const fromTypes = parseTypesRs(typesFile.content); - const fromDoc = parseErrorsMd(docFile.content); - - diagnostics.info("round-contract-error-drift-check", `\nRound contract error drift check`); - diagnostics.info("progress", "=".repeat(72)); - diagnostics.info("types-rs", ` types.rs : ${fromTypes.length} variants`); - diagnostics.info("errors-md", ` ERRORS.md: ${fromDoc.length} rows`); - - const failures = diffVariants(fromTypes, fromDoc, "types.rs", "ERRORS.md"); - diagnostics.info("progress-2", "=".repeat(72)); - - if (failures.length === 0) { - diagnostics.info("pass-types-rs-and-errors-md-list-the-same-error-codes", "PASS types.rs and ERRORS.md list the same error codes."); - process.exit(0); - } - - diagnostics.error("fail", `FAIL ${failures.length} drift issue(s):`); - for (const failure of failures) { - diagnostics.error("progress-3", ` - ${failure}`); - } - diagnostics.error("update-contracts-round-src-types-rs-and-contracts-round", "\nUpdate contracts/round/src/types.rs and contracts/round/ERRORS.md together."); - process.exit(1); -} - if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main(); + runCommand({ + name: "scripts.check-round-errors", + description: "Verify contracts/round/ERRORS.md matches Error enum in types.rs", + positionals: [ + { name: "typesPath", description: "Path to types.rs", required: false }, + { name: "docPath", description: "Path to ERRORS.md", required: false }, + ], + run(ctx) { + const typesPath = ctx.positionals[0]; + const docPath = ctx.positionals[1]; + + const typesFile = loadFile(typesPath, ctx.repoRoot, DEFAULT_TYPES); + const docFile = loadFile(docPath, ctx.repoRoot, DEFAULT_DOC); + + if (typesFile.content === null) { + diagnostics.error("fail-types-file-not-found", `[FAIL] types file not found: ${typesFile.path}`); + return 1; + } + if (docFile.content === null) { + diagnostics.error("fail-errors-md-not-found", `[FAIL] ERRORS.md not found: ${docFile.path}`); + return 1; + } + + const fromTypes = parseTypesRs(typesFile.content); + const fromDoc = parseErrorsMd(docFile.content); + + diagnostics.info("round-contract-error-drift-check", `\nRound contract error drift check`); + diagnostics.info("progress", "=".repeat(72)); + diagnostics.info("types-rs", ` types.rs : ${fromTypes.length} variants`); + diagnostics.info("errors-md", ` ERRORS.md: ${fromDoc.length} rows`); + + const failures = diffVariants(fromTypes, fromDoc, "types.rs", "ERRORS.md"); + diagnostics.info("progress-2", "=".repeat(72)); + + if (failures.length === 0) { + diagnostics.info("pass-types-rs-and-errors-md-list-the-same-error-codes", "PASS types.rs and ERRORS.md list the same error codes."); + return 0; + } + + diagnostics.error("fail", `FAIL ${failures.length} drift issue(s):`); + for (const failure of failures) { + diagnostics.error("progress-3", ` - ${failure}`); + } + diagnostics.error("update-contracts-round-src-types-rs-and-contracts-round", "\nUpdate contracts/round/src/types.rs and contracts/round/ERRORS.md together."); + return 1; + }, + }); } diff --git a/scripts/check-snapshots.mjs b/scripts/check-snapshots.mjs index b0c680e1..379823bb 100644 --- a/scripts/check-snapshots.mjs +++ b/scripts/check-snapshots.mjs @@ -19,14 +19,8 @@ const diagnostics = createLogger("scripts.check-snapshots"); // 1 one or more categories missing import { readdirSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SNAPSHOT_DIR = resolve( - __dirname, - "../contracts/round/test_snapshots/test", -); +import { resolve } from "node:path"; +import { runCommand } from "@sub-rosa/command"; // --------------------------------------------------------------------------- // Required snapshot categories. @@ -103,44 +97,56 @@ const REQUIRED_CATEGORIES = [ "seeded_case_7_lowest_bid_reproducible", ]; -// --------------------------------------------------------------------------- -// Check -// --------------------------------------------------------------------------- +/** + * Validates the presence of required contract snapshot categories. + * + * @param {string} snapshotDir + * @returns {number} + */ +export function main(snapshotDir) { + let files; + try { + files = readdirSync(snapshotDir).filter((f) => f.endsWith(".json")); + } catch (err) { + diagnostics.error("cannot-read-snapshot-directory", `\n✗ Cannot read snapshot directory: ${snapshotDir}`); + diagnostics.error("progress", ` ${err.message}`); + return 1; + } -let files; -try { - files = readdirSync(SNAPSHOT_DIR).filter((f) => f.endsWith(".json")); -} catch (err) { - diagnostics.error("cannot-read-snapshot-directory", `\n✗ Cannot read snapshot directory: ${SNAPSHOT_DIR}`); - diagnostics.error("progress", ` ${err.message}`); - process.exit(1); -} + const presentCategories = new Set( + files.map((f) => f.replace(/\.\d+\.json$/, "")), + ); -// Strip the trailing ..json suffix to get the bare category name. -const presentCategories = new Set( - files.map((f) => f.replace(/\.\d+\.json$/, "")), -); + const missing = REQUIRED_CATEGORIES.filter( + (cat) => !presentCategories.has(cat), + ); -const missing = REQUIRED_CATEGORIES.filter( - (cat) => !presentCategories.has(cat), -); + const total = files.length; + const required = REQUIRED_CATEGORIES.length; -const total = files.length; -const required = REQUIRED_CATEGORIES.length; + diagnostics.info("contract-snapshot-inventory", `\nContract snapshot inventory`); + diagnostics.info("directory", ` Directory : ${snapshotDir}`); + diagnostics.info("files-found", ` Files found : ${total}`); + diagnostics.info("categories-checked", ` Categories checked : ${required}`); -diagnostics.info("contract-snapshot-inventory", `\nContract snapshot inventory`); -diagnostics.info("directory", ` Directory : ${SNAPSHOT_DIR}`); -diagnostics.info("files-found", ` Files found : ${total}`); -diagnostics.info("categories-checked", ` Categories checked : ${required}`); + if (missing.length === 0) { + diagnostics.info("all", `\n✓ All ${required} required snapshot categories are present.\n`); + return 0; + } -if (missing.length === 0) { - diagnostics.info("all", `\n✓ All ${required} required snapshot categories are present.\n`); - process.exit(0); -} else { diagnostics.error("progress-2", `\n✗ ${missing.length} required snapshot category/categories missing:\n`); for (const cat of missing) { diagnostics.error("progress-3", ` - ${cat}`); } diagnostics.error("regenerate-snapshots-with-cargo-test-p-sub-rosa-round", `\n Regenerate snapshots with: cargo test -p sub-rosa-round\n`); - process.exit(1); + return 1; } + +runCommand({ + name: "scripts.check-snapshots", + description: "Verify contract snapshot inventory", + run(ctx) { + const snapshotDir = ctx.resolvePath("contracts/round/test_snapshots/test"); + return main(snapshotDir); + }, +}); diff --git a/scripts/check-threat-model-anchors.mjs b/scripts/check-threat-model-anchors.mjs index 3afac523..013b1882 100644 --- a/scripts/check-threat-model-anchors.mjs +++ b/scripts/check-threat-model-anchors.mjs @@ -19,6 +19,7 @@ const diagnostics = createLogger("scripts.check-threat-model-anchors"); import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; +import { runCommand } from "@sub-rosa/command"; const DEFAULT_DOC = "docs/THREAT_MODEL.md"; @@ -82,8 +83,8 @@ const REQUIRED_ANCHORS = [ }, ]; -function loadDoc(docPath) { - const absolute = isAbsolute(docPath) ? docPath : resolve(process.cwd(), docPath); +function loadDoc(docPath, repoRoot) { + const absolute = isAbsolute(docPath) ? docPath : resolve(repoRoot, docPath); if (!existsSync(absolute)) { return { content: null, path: absolute }; } @@ -99,14 +100,20 @@ function checkAnchor(anchor, content) { return { matched: null }; } -function main() { - const docPath = process.argv[2] || DEFAULT_DOC; - const { content, path } = loadDoc(docPath); +/** + * Validates that required threat model anchors are present in the documentation. + * + * @param {string} docPath + * @param {string} repoRoot + * @returns {number} + */ +export function main(docPath, repoRoot) { + const { content, path } = loadDoc(docPath, repoRoot); if (content === null) { diagnostics.error("fail", `[FAIL] ${docPath} not found at ${path}`); diagnostics.error("threat-model-anchor-coverage-check-cannot-run-without-t", "Threat model anchor coverage check cannot run without the doc."); - process.exit(1); + return 1; } diagnostics.info("threat-model-anchor-coverage-for", `\nThreat model anchor coverage for ${docPath}`); @@ -139,14 +146,24 @@ function main() { if (allPassed) { diagnostics.info("all", `All ${REQUIRED_ANCHORS.length} required threat model anchors are present.`); - process.exit(0); - } else { - diagnostics.error("progress-5", `Missing ${failures.length} required threat model anchor(s): ` + - failures.map((a) => a.id).join(", ")); - diagnostics.error("either-add-coverage-under-docs-threat-model-md-or-exten", "Either add coverage under docs/THREAT_MODEL.md or extend REQUIRED_ANCHORS in"); - diagnostics.error("scripts-check-threat-model-anchors-mjs-so-the-inventory", "scripts/check-threat-model-anchors.mjs so the inventory stays in sync."); - process.exit(1); + return 0; } + + diagnostics.error("progress-5", `Missing ${failures.length} required threat model anchor(s): ` + + failures.map((a) => a.id).join(", ")); + diagnostics.error("either-add-coverage-under-docs-threat-model-md-or-exten", "Either add coverage under docs/THREAT_MODEL.md or extend REQUIRED_ANCHORS in"); + diagnostics.error("scripts-check-threat-model-anchors-mjs-so-the-inventory", "scripts/check-threat-model-anchors.mjs so the inventory stays in sync."); + return 1; } -main(); +runCommand({ + name: "scripts.check-threat-model-anchors", + description: "Check threat model anchor coverage in docs/THREAT_MODEL.md", + positionals: [ + { name: "docPath", description: "Path to THREAT_MODEL.md", required: false }, + ], + run(ctx) { + const docPath = ctx.positionals[0] || DEFAULT_DOC; + return main(docPath, ctx.repoRoot); + }, +}); diff --git a/scripts/run-ts-coverage.mjs b/scripts/run-ts-coverage.mjs index 6938d81d..4f310c3b 100644 --- a/scripts/run-ts-coverage.mjs +++ b/scripts/run-ts-coverage.mjs @@ -25,6 +25,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; +import { runCommand } from "@sub-rosa/command"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, ".."); @@ -267,8 +268,15 @@ function runWorkspaceCoverage(relPath, root = ROOT) { } } -function main() { - const { lineThresholdPercent, workspaces } = loadConfig(); +/** + * Runs test coverage verification across all configured workspaces. + * + * @param {string} [configPath] + * @param {string} [root] + * @returns {number} + */ +export function main(configPath, root) { + const { lineThresholdPercent, workspaces } = loadConfig(configPath); diagnostics.info("sub-rosa-typescript-coverage-packages-services", "Sub Rosa TypeScript coverage (packages/* + services/*)\n"); diagnostics.info("configured-minimum-weighted-line-coverage", `Configured minimum weighted line coverage: ${lineThresholdPercent}%\n`); @@ -278,7 +286,7 @@ function main() { const rows = []; for (const workspace of workspaces) { process.stdout.write(`Running coverage: ${workspace} ... `); - const totals = runWorkspaceCoverage(workspace); + const totals = runWorkspaceCoverage(workspace, root); rows.push({ workspace, ...totals }); diagnostics.info("progress", `${totals.percent.toFixed(2)}% lines (${totals.covered}/${totals.total})`); } @@ -296,12 +304,20 @@ function main() { if (aggregate.percent < lineThresholdPercent) { diagnostics.error("weighted-line-coverage", `\n❌ Weighted line coverage ${aggregate.percent.toFixed(2)}% is below threshold ${lineThresholdPercent}%.`); - process.exit(1); + return 1; } diagnostics.info("coverage-gate-passed", "\n✅ Coverage gate passed."); + return 0; } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - main(); + runCommand({ + name: "scripts.run-ts-coverage", + description: "Collect and verify TypeScript line coverage across packages and services", + run(ctx) { + const configPath = ctx.resolvePath("coverage.config.json"); + return main(configPath, ctx.repoRoot); + }, + }); } diff --git a/services/agent/package.json b/services/agent/package.json index 1832a3d3..40902266 100644 --- a/services/agent/package.json +++ b/services/agent/package.json @@ -21,6 +21,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@stellar/stellar-sdk": "^15.1.0", "@sub-rosa/appraisal-api": "workspace:*", diff --git a/services/agent/scripts/agents-e2e.ts b/services/agent/scripts/agents-e2e.ts index 5714bde7..7a433d9c 100644 --- a/services/agent/scripts/agents-e2e.ts +++ b/services/agent/scripts/agents-e2e.ts @@ -1,10 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.agent.scripts.agents-e2e"); -// Live canonical jury demo on testnet: -// agents (x402 + mandate + sealed commits) → keeper reveal → clear → settle → 0 -// -// Writes the full web demo trace to apps/web/src/demo/demo-trace.generated.ts. - +import { runCommand } from "@sub-rosa/command"; import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -41,6 +36,8 @@ import { } from "../src/index.js"; import { writeDemoTrace } from "../src/write-demo-trace.js"; +const diagnostics = createLogger("services.agent.scripts.agents-e2e"); + const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; @@ -49,18 +46,13 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const X402_NETWORK = process.env.X402_NETWORK ?? "stellar:testnet"; - const { clock, scheduler } = systemTime; const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -69,11 +61,11 @@ const fail = (m: string): never => { }; const bytesHex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); const usdc = (stroops: bigint) => Number(stroops) / 1e7; -const repoPath = (path: string) => - path.startsWith("/") ? path : resolve(process.cwd(), "../..", path); +const repoPath = (path: string, repoRoot: string) => + path.startsWith("/") ? path : resolve(repoRoot, path); -async function writeJson(path: string, value: unknown) { - const out = repoPath(path); +async function writeJson(path: string, repoRoot: string, value: unknown) { + const out = repoPath(path, repoRoot); await mkdir(dirname(out), { recursive: true }); await writeFile(out, `${JSON.stringify(value, null, 2)}\n`); diagnostics.info("trace", " ✔ trace:", { "out_0": out }); @@ -90,13 +82,14 @@ async function setupSessionWallet( sessionSecret: string, asset: Asset, usdcAmount: string, + network: string = Networks.TESTNET, ) { const principal = Keypair.fromSecret(principalSecret); const session = Keypair.fromSecret(sessionSecret); async function submit(source: Keypair, op: xdr.Operation) { const account = await server.loadAccount(source.publicKey()); - const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) + const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: network }) .addOperation(op) .setTimeout(120) .build(); @@ -161,79 +154,93 @@ function lifecycleDone(): DemoTracePayload["lifecycle"] { type DemoTracePayload = Parameters[1]; -async function main() { - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const principal1Secret = reqEnv("PRINCIPAL1_SECRET"); - const principal2Secret = reqEnv("PRINCIPAL2_SECRET"); - const keeperSecret = reqEnv("KEEPER_SECRET"); - const appraisalServerSecret = reqEnv("APPRAISAL_SERVER_SECRET"); - const facilitatorSecret = reqEnv("FACILITATOR_SECRET"); - const issuerSecret = reqEnv("ISSUER_SECRET"); - const wasmHash = reqEnv("WASM_HASH"); - const usdcSac = reqEnv("USDC_SAC"); - const appraisalPrice = Number(process.env.PRICE ?? "0.10"); - - const issuerKp = Keypair.fromSecret(issuerSecret); - const asset = new Asset("USDC", issuerKp.publicKey()); - const horizon = new Horizon.Server(HORIZON_URL); - const opKp = Keypair.fromSecret(operatorSecret); - const operatorPub = opKp.publicKey(); - - const rpcServer = new rpc.Server(RPC_URL); - const sac = new Contract(usdcSac); - const balanceOf = async (addr: string): Promise => { - const source = new Account(operatorPub, "0"); - const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: NETWORK }) - .addOperation(sac.call("balance", new Address(addr).toScVal())) - .setTimeout(30) - .build(); - const sim = await rpcServer.simulateTransaction(tx); - if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); - if (!sim.result) return 0n; - return scValToNative(sim.result.retval) as bigint; - }; +runCommand({ + name: "services.agent.agents-e2e", + description: "Live canonical jury demo on testnet", + options: { + price: { + type: "string", + description: "Appraisal price (defaults to PRICE env or 0.10)", + }, + }, + async run(ctx) { + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const horizonUrl = ctx.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; + const x402Network = ctx.env.X402_NETWORK ?? "stellar:testnet"; + + const operatorSecret = reqEnv("OPERATOR_SECRET", ctx.env); + const principal1Secret = reqEnv("PRINCIPAL1_SECRET", ctx.env); + const principal2Secret = reqEnv("PRINCIPAL2_SECRET", ctx.env); + const keeperSecret = reqEnv("KEEPER_SECRET", ctx.env); + const appraisalServerSecret = reqEnv("APPRAISAL_SERVER_SECRET", ctx.env); + const facilitatorSecret = reqEnv("FACILITATOR_SECRET", ctx.env); + const issuerSecret = reqEnv("ISSUER_SECRET", ctx.env); + const wasmHash = reqEnv("WASM_HASH", ctx.env); + const usdcSac = reqEnv("USDC_SAC", ctx.env); + const appraisalPrice = Number((ctx.args.price as string | undefined) ?? ctx.env.PRICE ?? "0.10"); + + const issuerKp = Keypair.fromSecret(issuerSecret); + const asset = new Asset("USDC", issuerKp.publicKey()); + const horizon = new Horizon.Server(horizonUrl); + const opKp = Keypair.fromSecret(operatorSecret); + const operatorPub = opKp.publicKey(); + + const rpcServer = new rpc.Server(rpcUrl); + const sac = new Contract(usdcSac); + const balanceOf = async (addr: string): Promise => { + const source = new Account(operatorPub, "0"); + const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: network }) + .addOperation(sac.call("balance", new Address(addr).toScVal())) + .setTimeout(30) + .build(); + const sim = await rpcServer.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); + if (!sim.result) return 0n; + return scValToNative(sim.result.retval) as bigint; + }; - diagnostics.info("operator", "· operator:", { "operatorPub_0": operatorPub }); - diagnostics.info("keeper", "· keeper: ", { "value1_0": Keypair.fromSecret(keeperSecret).publicKey() }); - diagnostics.info("usdc-sac", "· USDC SAC:", { "usdcSac_0": usdcSac }); + diagnostics.info("operator", "· operator:", { "operatorPub_0": operatorPub }); + diagnostics.info("keeper", "· keeper: ", { "value1_0": Keypair.fromSecret(keeperSecret).publicKey() }); + diagnostics.info("usdc-sac", "· USDC SAC:", { "usdcSac_0": usdcSac }); - const priceStroops = usdcToStroops(appraisalPrice); + const priceStroops = usdcToStroops(appraisalPrice); - diagnostics.info("1-7-deploying-round-createround", "\n[1/7] deploying Round + createRound…"); - const deployTx = await RoundContract.deploy( - { - drand_pubkey: hex(DRAND_PUBKEY_C1C0), - g2_neg_generator: hex(DRAND_NEGGEN_C1C0), - dst: Buffer.from(DST, "utf8"), - drand_genesis: BigInt(DRAND_GENESIS), - drand_period: BigInt(DRAND_PERIOD), - usdc: usdcSac, - }, - { - wasmHash, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - publicKey: operatorPub, - signTransaction: basicNodeSigner(opKp, NETWORK).signTransaction, - }, - ); - const contractId = (await deployTx.signAndSend()).result.options.contractId; - diagnostics.info("contract", " ✔ contract", { "contractId_0": contractId }); - - const itemRefStr = "sub-rosa://agents/spectrum-block-9"; - const now = clock.nowSeconds(); - const revealRound = Math.ceil((now + 180 - DRAND_GENESIS) / DRAND_PERIOD); - const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; - const commitDeadline = now + 90; - const revealDeadline = tReveal + 180; - const auditor = generateAuditorKeypair(); - - const operator = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: operatorSecret, - }); + diagnostics.info("1-7-deploying-round-createround", "\n[1/7] deploying Round + createRound…"); + const deployTx = await RoundContract.deploy( + { + drand_pubkey: hex(DRAND_PUBKEY_C1C0), + g2_neg_generator: hex(DRAND_NEGGEN_C1C0), + dst: Buffer.from(DST, "utf8"), + drand_genesis: BigInt(DRAND_GENESIS), + drand_period: BigInt(DRAND_PERIOD), + usdc: usdcSac, + }, + { + wasmHash, + rpcUrl, + networkPassphrase: network, + publicKey: operatorPub, + signTransaction: basicNodeSigner(opKp, network).signTransaction, + }, + ); + const contractId = (await deployTx.signAndSend()).result.options.contractId; + diagnostics.info("contract", " ✔ contract", { "contractId_0": contractId }); + + const itemRefStr = "sub-rosa://agents/spectrum-block-9"; + const now = clock.nowSeconds(); + const revealRound = Math.ceil((now + 180 - DRAND_GENESIS) / DRAND_PERIOD); + const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; + const commitDeadline = now + 90; + const revealDeadline = tReveal + 180; + const auditor = generateAuditorKeypair(); + + const operator = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: operatorSecret, + }); const roundId = await operator.createRound({ itemRef: sha256(itemRefStr), revealRound, @@ -244,114 +251,114 @@ async function main() { }); diagnostics.info("round", " ✔ round", { "value1_0": roundId.toString(), "value2_1": "R=", "revealRound_2": revealRound }); - diagnostics.info("2-7-starting-x402-appraisal-api", "\n[2/7] starting x402 appraisal API…"); - const appraisalServerPub = Keypair.fromSecret(appraisalServerSecret).publicKey(); - const api = await buildAppraisalServer({ - facilitatorSecret, - payTo: appraisalServerPub, - asset: usdcSac, - price: appraisalPrice, - network: X402_NETWORK as `${string}:${string}`, - rpcUrl: RPC_URL, - port: 0, - }); - await new Promise((resolve) => api.listen(0, "127.0.0.1", () => resolve())); - const appraisalUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}/appraise`; - diagnostics.info("progress", " ✔", { "appraisalUrl_0": appraisalUrl }); + diagnostics.info("2-7-starting-x402-appraisal-api", "\n[2/7] starting x402 appraisal API…"); + const appraisalServerPub = Keypair.fromSecret(appraisalServerSecret).publicKey(); + const api = await buildAppraisalServer({ + facilitatorSecret, + payTo: appraisalServerPub, + asset: usdcSac, + price: appraisalPrice, + network: x402Network as `${string}:${string}`, + rpcUrl, + port: 0, + }); + await new Promise((resolve) => api.listen(0, "127.0.0.1", () => resolve())); + const appraisalUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}/appraise`; + diagnostics.info("progress", " ✔", { "appraisalUrl_0": appraisalUrl }); - try { - const mandateCommon = { - contractId, - roundId, - itemRef: itemRefStr, - basePriceUsdc: 500, - category: "spectrum" as const, - maxBidStroops: usdcToStroops(700), - maxEscrowStroops: usdcToStroops(700), - maxAppraisalSpendStroops: usdcToStroops(1), - appraisalPriceStroops: priceStroops, - commitDeadline, - }; + try { + const mandateCommon = { + contractId, + roundId, + itemRef: itemRefStr, + basePriceUsdc: 500, + category: "spectrum" as const, + maxBidStroops: usdcToStroops(700), + maxEscrowStroops: usdcToStroops(700), + maxAppraisalSpendStroops: usdcToStroops(1), + appraisalPriceStroops: priceStroops, + commitDeadline, + }; + + const agentPlans = [ + { + name: "agent-alpha", + principalSecret: principal1Secret, + attributes: { quality: 88, demand: 82, scarcity: 92, risk: 12 }, + }, + { + name: "agent-beta", + principalSecret: principal2Secret, + attributes: { quality: 52, demand: 48, scarcity: 40, risk: 45 }, + }, + ] as const; + + diagnostics.info("3-7-two-autonomous-agents-mandate-x402-commit", "\n[3/7] two autonomous agents: mandate → x402 → commit…"); + const results: Array<{ + plan: (typeof agentPlans)[number]; + mandate: SessionMandate; + result: Awaited>; + }> = []; + + for (const plan of agentPlans) { + const { mandate, sessionSecret } = createSessionMandate({ + ...mandateCommon, + principalSecret: plan.principalSecret, + }); + await setupSessionWallet(horizon, plan.principalSecret, sessionSecret, asset, "800", network); + const log = (m: string) => diagnostics.info("progress-2", ` · [${plan.name}]`, { "m_0": m }); + const result = await runBidderAgent({ + mandate, + sessionSecret, + rpcUrl, + networkPassphrase: network, + appraisalUrl, + auditorPubkey: auditor.publicKey, + revealRound, + attributes: plan.attributes, + x402Network: x402Network as `${string}:${string}`, + log, + }); + results.push({ plan, mandate, result }); + } - const agentPlans = [ - { - name: "agent-alpha", - principalSecret: principal1Secret, - attributes: { quality: 88, demand: 82, scarcity: 92, risk: 12 }, - }, - { - name: "agent-beta", - principalSecret: principal2Secret, - attributes: { quality: 52, demand: 48, scarcity: 40, risk: 45 }, - }, - ] as const; - - diagnostics.info("3-7-two-autonomous-agents-mandate-x402-commit", "\n[3/7] two autonomous agents: mandate → x402 → commit…"); - const results: Array<{ - plan: (typeof agentPlans)[number]; - mandate: SessionMandate; - result: Awaited>; - }> = []; - - for (const plan of agentPlans) { - const { mandate, sessionSecret } = createSessionMandate({ - ...mandateCommon, - principalSecret: plan.principalSecret, - }); - await setupSessionWallet(horizon, plan.principalSecret, sessionSecret, asset, "800"); - const log = (m: string) => diagnostics.info("progress-2", ` · [${plan.name}]`, { "m_0": m }); - const result = await runBidderAgent({ - mandate, - sessionSecret, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - appraisalUrl, - auditorPubkey: auditor.publicKey, - revealRound, - attributes: plan.attributes, - x402Network: X402_NETWORK as `${string}:${string}`, - log, + const reader = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + publicKey: operatorPub, }); - results.push({ plan, mandate, result }); - } + const bidders = await reader.getBidders(roundId); + if (bidders.length !== 2) fail(`expected 2 bidders, got ${bidders.length}`); - const reader = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - publicKey: operatorPub, - }); - const bidders = await reader.getBidders(roundId); - if (bidders.length !== 2) fail(`expected 2 bidders, got ${bidders.length}`); - - for (const { plan, result } of results) { - if (!bidders.includes(result.bidder)) fail(`${plan.name} not in bidder index`); - if (!result.appraisalSettlement?.success) fail(`${plan.name} x402 not settled`); - diagnostics.info("progress-3", ` ✔ ${plan.name}: bid ${stroopsToUsdc(result.bidValue)} USDC, escrow ${stroopsToUsdc(result.escrow)}`); - } + for (const { plan, result } of results) { + if (!bidders.includes(result.bidder)) fail(`${plan.name} not in bidder index`); + if (!result.appraisalSettlement?.success) fail(`${plan.name} x402 not settled`); + diagnostics.info("progress-3", ` ✔ ${plan.name}: bid ${stroopsToUsdc(result.bidValue)} USDC, escrow ${stroopsToUsdc(result.escrow)}`); + } - const alpha = results[0]!; - const beta = results[1]!; - if (alpha.result.bidValue <= beta.result.bidValue) { - fail("agent-alpha must outbid agent-beta"); - } + const alpha = results[0]!; + const beta = results[1]!; + if (alpha.result.bidValue <= beta.result.bidValue) { + fail("agent-alpha must outbid agent-beta"); + } - const beforeOp = await balanceOf(operatorPub); - const beforeContract = await balanceOf(contractId); + const beforeOp = await balanceOf(operatorPub); + const beforeContract = await balanceOf(contractId); - diagnostics.info("4-7-keeper-wait-r-open-reveal-reveal-all", "\n[4/7] keeper: wait R → open_reveal → reveal all…"); - const drand = quicknet(); - const keeperSdk = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: keeperSecret, - }); - const log = (m: string) => diagnostics.info("progress-4", " ·", { "m_0": m }); - let rev = await keepRound( - { sdk: keeperSdk, drand, log, maxWaitSeconds: 300, pollMs: 5000 }, - roundId, - ); + diagnostics.info("4-7-keeper-wait-r-open-reveal-reveal-all", "\n[4/7] keeper: wait R → open_reveal → reveal all…"); + const drand = quicknet(); + const keeperSdk = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: keeperSecret, + }); + const log = (m: string) => diagnostics.info("progress-4", " ·", { "m_0": m }); + let rev = await keepRound( + { sdk: keeperSdk, drand, log, maxWaitSeconds: 300, pollMs: 5000 }, + roundId, + ); for (let i = 0; i < 5 && rev.finalStatus === "Open"; i++) { await sleep(5000); rev = await keepRound( @@ -460,23 +467,19 @@ async function main() { }, }; - await writeJson("artifacts/canonical-demo-trace.json", demoTrace); - if (process.env.SUB_ROSA_WRITE_WEB_TRACE !== "0") { - await writeDemoTrace( - process.env.SUB_ROSA_WEB_DEMO_TRACE_OUT ?? "apps/web/src/demo/demo-trace.generated.ts", - demoTrace, - ); - } - - diagnostics.info("canonical-agents-e2e-passed-commit-r-reveal-clear-settl", "\n✅ CANONICAL AGENTS E2E PASSED — commit → R → reveal → clear → settle → 0."); - diagnostics.info("contract-2", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString(), "value4_3": "winner:", "winner_4": close.winner }); - } finally { - await new Promise((resolve) => api.close(() => resolve())); - } -} + await writeJson("artifacts/canonical-demo-trace.json", ctx.repoRoot, demoTrace); + if (ctx.env.SUB_ROSA_WRITE_WEB_TRACE !== "0") { + await writeDemoTrace( + ctx.env.SUB_ROSA_WEB_DEMO_TRACE_OUT ?? resolve(ctx.repoRoot, "apps/web/src/demo/demo-trace.generated.ts"), + demoTrace, + ); + } -main().catch((err) => { - diagnostics.error("canonical-agents-e2e-failed", "\n❌ CANONICAL AGENTS E2E FAILED"); - diagnostics.error("progress-5", err); - process.exit(1); + diagnostics.info("canonical-agents-e2e-passed-commit-r-reveal-clear-settl", "\n✅ CANONICAL AGENTS E2E PASSED — commit → R → reveal → clear → settle → 0."); + diagnostics.info("contract-2", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString(), "value4_3": "winner:", "winner_4": close.winner }); + return 0; + } finally { + await new Promise((resolve) => api.close(() => resolve())); + } + }, }); diff --git a/services/agent/scripts/usdc-setup.ts b/services/agent/scripts/usdc-setup.ts index d0f081b5..f6eb1530 100644 --- a/services/agent/scripts/usdc-setup.ts +++ b/services/agent/scripts/usdc-setup.ts @@ -1,8 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.agent.scripts.usdc-setup"); -// USDC setup for multi-agent e2e: trustlines + mint for both principals and the -// appraisal resource server. - +import { runCommand } from "@sub-rosa/command"; import { Asset, BASE_FEE, @@ -14,56 +11,69 @@ import { xdr, } from "@stellar/stellar-sdk"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; +const diagnostics = createLogger("services.agent.scripts.usdc-setup"); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; -async function main() { - const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET")); - const p1 = Keypair.fromSecret(reqEnv("PRINCIPAL1_SECRET")); - const p2 = Keypair.fromSecret(reqEnv("PRINCIPAL2_SECRET")); - const appraisalServer = Keypair.fromSecret(reqEnv("APPRAISAL_SERVER_SECRET")); +runCommand({ + name: "services.agent.usdc-setup", + description: "USDC setup for multi-agent e2e: trustlines + mint", + options: { + asset: { + type: "string", + description: "Asset code (defaults to ASSET_CODE env or USDC)", + }, + amount: { + type: "string", + description: "Mint amount (defaults to MINT_AMOUNT env or 1000)", + }, + }, + async run(ctx) { + const horizonUrl = ctx.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; + const assetCode = (ctx.args.asset as string | undefined) ?? ctx.env.ASSET_CODE ?? "USDC"; + const mintAmount = (ctx.args.amount as string | undefined) ?? ctx.env.MINT_AMOUNT ?? "1000"; + + const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET", ctx.env)); + const p1 = Keypair.fromSecret(reqEnv("PRINCIPAL1_SECRET", ctx.env)); + const p2 = Keypair.fromSecret(reqEnv("PRINCIPAL2_SECRET", ctx.env)); + const appraisalServer = Keypair.fromSecret(reqEnv("APPRAISAL_SERVER_SECRET", ctx.env)); - const server = new Horizon.Server(HORIZON_URL); - const asset = new Asset(ASSET_CODE, issuerKp.publicKey()); + const server = new Horizon.Server(horizonUrl); + const asset = new Asset(assetCode, issuerKp.publicKey()); - async function submit(source: Keypair, op: xdr.Operation) { - const account = await server.loadAccount(source.publicKey()); - const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) - .addOperation(op) - .setTimeout(120) - .build(); - tx.sign(source); - await server.submitTransaction(tx); - } + async function submit(source: Keypair, op: xdr.Operation) { + const account = await server.loadAccount(source.publicKey()); + const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: network }) + .addOperation(op) + .setTimeout(120) + .build(); + tx.sign(source); + await server.submitTransaction(tx); + } - for (const kp of [p1, p2, appraisalServer]) { - await submit(kp, Operation.changeTrust({ asset })); - diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); - } - const operatorSecret = process.env.OPERATOR_SECRET; - if (operatorSecret) { - const operator = Keypair.fromSecret(operatorSecret); - await submit(operator, Operation.changeTrust({ asset })); - diagnostics.info("trustline-ok-2", `trustline OK: ${operator.publicKey()}`); - } - for (const kp of [p1, p2]) { - await submit( - issuerKp, - Operation.payment({ destination: kp.publicKey(), asset, amount: MINT_AMOUNT }), - ); - diagnostics.info("minted", `minted ${MINT_AMOUNT} ${ASSET_CODE} → ${kp.publicKey()}`); - } -} + for (const kp of [p1, p2, appraisalServer]) { + await submit(kp, Operation.changeTrust({ asset })); + diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); + } + const operatorSecret = ctx.env.OPERATOR_SECRET; + if (operatorSecret) { + const operator = Keypair.fromSecret(operatorSecret); + await submit(operator, Operation.changeTrust({ asset })); + diagnostics.info("trustline-ok-2", `trustline OK: ${operator.publicKey()}`); + } + for (const kp of [p1, p2]) { + await submit( + issuerKp, + Operation.payment({ destination: kp.publicKey(), asset, amount: mintAmount }), + ); + diagnostics.info("minted", `minted ${mintAmount} ${assetCode} → ${kp.publicKey()}`); + } -main().catch((err) => { - diagnostics.error("usdc-setup-failed", "usdc-setup failed:", { "value1_0": err?.response?.data ?? err }); - process.exit(1); + return 0; + }, }); diff --git a/services/appraisal-api/package.json b/services/appraisal-api/package.json index 2bb7e81b..9aba6ae2 100644 --- a/services/appraisal-api/package.json +++ b/services/appraisal-api/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@stellar/stellar-sdk": "^15.1.0", "@x402/core": "^2.14.0", diff --git a/services/appraisal-api/scripts/usdc-setup.ts b/services/appraisal-api/scripts/usdc-setup.ts index 16e025b6..173ba08b 100644 --- a/services/appraisal-api/scripts/usdc-setup.ts +++ b/services/appraisal-api/scripts/usdc-setup.ts @@ -1,9 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.appraisal-api.scripts.usdc-setup"); -// USDC asset provisioning for the x402 e2e (classic ops via Horizon). -// Trustlines for the payer (client) + resource server, and mint USDC to the -// payer. The facilitator needs XLM only, so it gets no trustline. - +import { runCommand } from "@sub-rosa/command"; import { Asset, BASE_FEE, @@ -15,47 +11,60 @@ import { xdr, } from "@stellar/stellar-sdk"; -const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; +const diagnostics = createLogger("services.appraisal-api.scripts.usdc-setup"); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; -async function main() { - const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET")); - const clientKp = Keypair.fromSecret(reqEnv("CLIENT_SECRET")); - const serverKp = Keypair.fromSecret(reqEnv("SERVER_SECRET")); - - const server = new Horizon.Server(HORIZON_URL); - const asset = new Asset(ASSET_CODE, issuerKp.publicKey()); - - async function submit(sourceKp: Keypair, op: xdr.Operation) { - const account = await server.loadAccount(sourceKp.publicKey()); - const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) - .addOperation(op) - .setTimeout(120) - .build(); - tx.sign(sourceKp); - await server.submitTransaction(tx); - } - - for (const kp of [clientKp, serverKp]) { - await submit(kp, Operation.changeTrust({ asset })); - diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); - } - await submit( - issuerKp, - Operation.payment({ destination: clientKp.publicKey(), asset, amount: MINT_AMOUNT }), - ); - diagnostics.info("minted", `minted ${MINT_AMOUNT} ${ASSET_CODE} → ${clientKp.publicKey()}`); -} - -main().catch((err) => { - diagnostics.error("usdc-setup-failed", "usdc-setup failed:", { "value1_0": err?.response?.data ?? err }); - process.exit(1); +runCommand({ + name: "services.appraisal-api.usdc-setup", + description: "USDC asset provisioning for the x402 e2e", + options: { + asset: { + type: "string", + description: "Asset code (defaults to ASSET_CODE env or USDC)", + }, + amount: { + type: "string", + description: "Mint amount (defaults to MINT_AMOUNT env or 1000)", + }, + }, + async run(ctx) { + const horizonUrl = ctx.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; + const assetCode = (ctx.args.asset as string | undefined) ?? ctx.env.ASSET_CODE ?? "USDC"; + const mintAmount = (ctx.args.amount as string | undefined) ?? ctx.env.MINT_AMOUNT ?? "1000"; + + const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET", ctx.env)); + const clientKp = Keypair.fromSecret(reqEnv("CLIENT_SECRET", ctx.env)); + const serverKp = Keypair.fromSecret(reqEnv("SERVER_SECRET", ctx.env)); + + const server = new Horizon.Server(horizonUrl); + const asset = new Asset(assetCode, issuerKp.publicKey()); + + async function submit(sourceKp: Keypair, op: xdr.Operation) { + const account = await server.loadAccount(sourceKp.publicKey()); + const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: network }) + .addOperation(op) + .setTimeout(120) + .build(); + tx.sign(sourceKp); + await server.submitTransaction(tx); + } + + for (const kp of [clientKp, serverKp]) { + await submit(kp, Operation.changeTrust({ asset })); + diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); + } + await submit( + issuerKp, + Operation.payment({ destination: clientKp.publicKey(), asset, amount: mintAmount }), + ); + diagnostics.info("minted", `minted ${mintAmount} ${assetCode} → ${clientKp.publicKey()}`); + + return 0; + }, }); diff --git a/services/appraisal-api/scripts/x402-e2e.ts b/services/appraisal-api/scripts/x402-e2e.ts index 00aeee93..debe6c33 100644 --- a/services/appraisal-api/scripts/x402-e2e.ts +++ b/services/appraisal-api/scripts/x402-e2e.ts @@ -1,14 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.appraisal-api.scripts.x402-e2e"); -// Live x402 e2e on testnet. -// -// Starts the appraisal API in-process (self-facilitating over real Soroban RPC), -// then an agent calls it: the first call returns HTTP 402, the agent signs a -// USDC (SEP-41) auth entry and retries, the server settles the transfer on-chain -// and returns the appraisal. We assert the agent was charged exactly the price, -// the resource server received exactly the price, the settlement carries a real -// transaction hash, and the appraisal equals the deterministic model output. - +import { runCommand } from "@sub-rosa/command"; import { AddressInfo } from "node:net"; import { @@ -25,12 +16,10 @@ import { appraise, parseAppraisalRequest } from "../src/appraisal.js"; import { buildAppraisalServer } from "../src/server.js"; import { createPaidFetch } from "../src/client.js"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; -const X402_NETWORK = process.env.X402_NETWORK ?? "stellar:testnet"; +const diagnostics = createLogger("services.appraisal-api.scripts.x402-e2e"); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -39,120 +28,123 @@ const fail = (m: string): never => { }; const usdc = (stroops: bigint) => (Number(stroops) / 1e7).toFixed(7); -async function main() { - const facilitatorSecret = reqEnv("FACILITATOR_SECRET"); - const clientSecret = reqEnv("CLIENT_SECRET"); - const serverSecret = reqEnv("SERVER_SECRET"); - const usdcSac = reqEnv("USDC_SAC"); - const price = Number(process.env.PRICE ?? "0.10"); - - const clientPub = Keypair.fromSecret(clientSecret).publicKey(); - const serverPub = Keypair.fromSecret(serverSecret).publicKey(); - diagnostics.info("payer-agent", "· payer (agent):", { "clientPub_0": clientPub }); - diagnostics.info("resource-server", "· resource server:", { "serverPub_0": serverPub }); - diagnostics.info("facilitator", "· facilitator:", { "value1_0": Keypair.fromSecret(facilitatorSecret).publicKey() }); - diagnostics.info("token", "· token:", { "usdcSac_0": usdcSac, "value2_1": "price:", "price_2": price, "value4_3": "USDC/call" }); - - // USDC balance reader (read-only SAC `balance(id)` simulation). - const server = new rpc.Server(RPC_URL); - const sac = new Contract(usdcSac); - const balanceOf = async (addr: string): Promise => { - const src = new Account(clientPub, "0"); - const tx = new TransactionBuilder(src, { fee: "100", networkPassphrase: NETWORK }) - .addOperation(sac.call("balance", new Address(addr).toScVal())) - .setTimeout(30) - .build(); - const sim = await server.simulateTransaction(tx); - if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); - return sim.result ? (scValToNative(sim.result.retval) as bigint) : 0n; - }; - - // ── 1. Start the x402-gated appraisal API in-process ─────────────────── - diagnostics.info("1-5-starting-appraisal-api-self-facilitating-on-testnet", "\n[1/5] starting appraisal API (self-facilitating on testnet)…"); - const api = await buildAppraisalServer({ - facilitatorSecret, - payTo: serverPub, - asset: usdcSac, - price, - network: X402_NETWORK as `${string}:${string}`, - rpcUrl: RPC_URL, - port: 0, - }); - await new Promise((resolve) => api.listen(0, "127.0.0.1", () => resolve())); - const port = (api.address() as AddressInfo).port; - const url = `http://127.0.0.1:${port}/appraise`; - diagnostics.info("listening-on", " ✔ listening on", { "url_0": url }); - - try { - const item = { - itemRef: "sub-rosa://rfp/spectrum-block-7", - basePrice: 500, - category: "spectrum", - attributes: { quality: 82, demand: 74, scarcity: 91, risk: 18 }, +runCommand({ + name: "services.appraisal-api.x402-e2e", + description: "Live x402 e2e on testnet", + options: { + price: { + type: "string", + description: "USDC price per call (defaults to PRICE env or 0.10)", + }, + }, + async run(ctx) { + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + const x402Network = ctx.env.X402_NETWORK ?? "stellar:testnet"; + + const facilitatorSecret = reqEnv("FACILITATOR_SECRET", ctx.env); + const clientSecret = reqEnv("CLIENT_SECRET", ctx.env); + const serverSecret = reqEnv("SERVER_SECRET", ctx.env); + const usdcSac = reqEnv("USDC_SAC", ctx.env); + const price = Number((ctx.args.price as string | undefined) ?? ctx.env.PRICE ?? "0.10"); + + const clientPub = Keypair.fromSecret(clientSecret).publicKey(); + const serverPub = Keypair.fromSecret(serverSecret).publicKey(); + diagnostics.info("payer-agent", "· payer (agent):", { "clientPub_0": clientPub }); + diagnostics.info("resource-server", "· resource server:", { "serverPub_0": serverPub }); + diagnostics.info("facilitator", "· facilitator:", { "value1_0": Keypair.fromSecret(facilitatorSecret).publicKey() }); + diagnostics.info("token", "· token:", { "usdcSac_0": usdcSac, "value2_1": "price:", "price_2": price, "value4_3": "USDC/call" }); + + const server = new rpc.Server(rpcUrl); + const sac = new Contract(usdcSac); + const balanceOf = async (addr: string): Promise => { + const src = new Account(clientPub, "0"); + const tx = new TransactionBuilder(src, { fee: "100", networkPassphrase: network }) + .addOperation(sac.call("balance", new Address(addr).toScVal())) + .setTimeout(30) + .build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); + return sim.result ? (scValToNative(sim.result.retval) as bigint) : 0n; }; - // ── 2. Unpaid call must be rejected with 402 ───────────────────────── - diagnostics.info("2-5-unpaid-call-expect-http-402", "\n[2/5] unpaid call → expect HTTP 402…"); - const unpaid = await fetch(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(item), + diagnostics.info("1-5-starting-appraisal-api-self-facilitating-on-testnet", "\n[1/5] starting appraisal API (self-facilitating on testnet)…"); + const api = await buildAppraisalServer({ + facilitatorSecret, + payTo: serverPub, + asset: usdcSac, + price, + network: x402Network as `${string}:${string}`, + rpcUrl, + port: 0, }); - if (unpaid.status !== 402) fail(`expected 402, got ${unpaid.status}`); - const offer = await unpaid.json(); - diagnostics.info("402-with-accepts", " ✔ 402 with accepts:", { "value1_0": JSON.stringify(offer.accepts ?? offer) }); - - // ── 3. Record balances, then pay ───────────────────────────────────── - const before = { client: await balanceOf(clientPub), server: await balanceOf(serverPub) }; - diagnostics.info("3-5-initial-usdc", "\n[3/5] initial USDC:", { "value1_0": { agent: usdc(before.client), server: usdc(before.server) } }); - - diagnostics.info("4-5-paid-call-sign-auth-entry-settle-on-chain-get-appra", "\n[4/5] paid call → sign auth entry, settle on-chain, get appraisal…"); - const paidFetch = createPaidFetch({ secret: clientSecret, network: X402_NETWORK as `${string}:${string}`, rpcUrl: RPC_URL }); - const result = await paidFetch<{ appraisal: ReturnType; payment: { transaction: string; payer: string } }>( - url, - { + await new Promise((resolve) => api.listen(0, "127.0.0.1", () => resolve())); + const port = (api.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/appraise`; + diagnostics.info("listening-on", " ✔ listening on", { "url_0": url }); + + try { + const item = { + itemRef: "sub-rosa://rfp/spectrum-block-7", + basePrice: 500, + category: "spectrum", + attributes: { quality: 82, demand: 74, scarcity: 91, risk: 18 }, + }; + + diagnostics.info("2-5-unpaid-call-expect-http-402", "\n[2/5] unpaid call → expect HTTP 402…"); + const unpaid = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(item), - }, - ); - if (result.status !== 200) fail(`paid call status ${result.status}`); - const settlement = result.settlement; - if (!settlement || !settlement.success) { - throw new Error(`x402 e2e: settlement not successful: ${JSON.stringify(settlement)}`); - } - if (!settlement.transaction) fail("settlement missing transaction hash"); - diagnostics.info("settled-on-chain-tx", " ✔ settled on-chain, tx:", { "transaction_0": settlement.transaction }); - diagnostics.info("payer", " ✔ payer:", { "payer_0": settlement.payer }); - - // Appraisal must equal the deterministic model output for these inputs. - const expected = appraise(parseAppraisalRequest(item)); - if (JSON.stringify(result.body.appraisal) !== JSON.stringify(expected)) { - fail(`appraisal mismatch:\n got ${JSON.stringify(result.body.appraisal)}\n exp ${JSON.stringify(expected)}`); - } - diagnostics.info("appraisal-matches-model-fairvalue", " ✔ appraisal matches model: fairValue", { "fairValue_0": expected.fairValue, "value2_1": "suggestedMaxBid", "suggestedMaxBid_2": expected.suggestedMaxBid }); - - // ── 5. Balance checks — exact price moved agent → server ───────────── - diagnostics.info("5-5-verifying-on-chain-transfer", "\n[5/5] verifying on-chain transfer…"); - const after = { client: await balanceOf(clientPub), server: await balanceOf(serverPub) }; - diagnostics.info("final-usdc", " final USDC:", { "value1_0": { agent: usdc(after.client), server: usdc(after.server) } }); - const priceStroops = BigInt(Math.round(price * 1e7)); - if (before.client - after.client !== priceStroops) { - fail(`agent debit ${before.client - after.client} != price ${priceStroops}`); - } - if (after.server - before.server !== priceStroops) { - fail(`server credit ${after.server - before.server} != price ${priceStroops}`); + }); + if (unpaid.status !== 402) fail(`expected 402, got ${unpaid.status}`); + const offer = await unpaid.json(); + diagnostics.info("402-with-accepts", " ✔ 402 with accepts:", { "value1_0": JSON.stringify(offer.accepts ?? offer) }); + + const before = { client: await balanceOf(clientPub), server: await balanceOf(serverPub) }; + diagnostics.info("3-5-initial-usdc", "\n[3/5] initial USDC:", { "value1_0": { agent: usdc(before.client), server: usdc(before.server) } }); + + diagnostics.info("4-5-paid-call-sign-auth-entry-settle-on-chain-get-appra", "\n[4/5] paid call → sign auth entry, settle on-chain, get appraisal…"); + const paidFetch = createPaidFetch({ secret: clientSecret, network: x402Network as `${string}:${string}`, rpcUrl }); + const result = await paidFetch<{ appraisal: ReturnType; payment: { transaction: string; payer: string } }>( + url, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(item), + }, + ); + if (result.status !== 200) fail(`paid call status ${result.status}`); + const settlement = result.settlement; + if (!settlement || !settlement.success) { + throw new Error(`x402 e2e: settlement not successful: ${JSON.stringify(settlement)}`); + } + if (!settlement.transaction) fail("settlement missing transaction hash"); + diagnostics.info("settled-on-chain-tx", " ✔ settled on-chain, tx:", { "transaction_0": settlement.transaction }); + diagnostics.info("payer", " ✔ payer:", { "payer_0": settlement.payer }); + + const expected = appraise(parseAppraisalRequest(item)); + if (JSON.stringify(result.body.appraisal) !== JSON.stringify(expected)) { + fail(`appraisal mismatch:\n got ${JSON.stringify(result.body.appraisal)}\n exp ${JSON.stringify(expected)}`); + } + diagnostics.info("appraisal-matches-model-fairvalue", " ✔ appraisal matches model: fairValue", { "fairValue_0": expected.fairValue, "value2_1": "suggestedMaxBid", "suggestedMaxBid_2": expected.suggestedMaxBid }); + + diagnostics.info("5-5-verifying-on-chain-transfer", "\n[5/5] verifying on-chain transfer…"); + const after = { client: await balanceOf(clientPub), server: await balanceOf(serverPub) }; + diagnostics.info("final-usdc", " final USDC:", { "value1_0": { agent: usdc(after.client), server: usdc(after.server) } }); + const priceStroops = BigInt(Math.round(price * 1e7)); + if (before.client - after.client !== priceStroops) { + fail(`agent debit ${before.client - after.client} != price ${priceStroops}`); + } + if (after.server - before.server !== priceStroops) { + fail(`server credit ${after.server - before.server} != price ${priceStroops}`); + } + diagnostics.info("exactly", ` ✔ exactly ${price} USDC moved agent → resource server on-chain`); + + diagnostics.info("x402-e2e-passed-402-signed-usdc-payment-on-chain-settle", "\n✅ x402 E2E PASSED — 402 → signed USDC payment → on-chain settle → appraisal."); + return 0; + } finally { + await new Promise((resolve) => api.close(() => resolve())); } - diagnostics.info("exactly", ` ✔ exactly ${price} USDC moved agent → resource server on-chain`); - - diagnostics.info("x402-e2e-passed-402-signed-usdc-payment-on-chain-settle", "\n✅ x402 E2E PASSED — 402 → signed USDC payment → on-chain settle → appraisal."); - } finally { - await new Promise((resolve) => api.close(() => resolve())); - } -} - -main().catch((err) => { - diagnostics.error("x402-e2e-failed", "\n❌ x402 E2E FAILED"); - diagnostics.error("progress", err); - process.exit(1); + }, }); diff --git a/services/auction-template/package.json b/services/auction-template/package.json index 4a4680a7..0b2394f4 100644 --- a/services/auction-template/package.json +++ b/services/auction-template/package.json @@ -16,6 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/keeper": "workspace:*", "@sub-rosa/sdk": "workspace:*", diff --git a/services/auction-template/sealed-auction.ts b/services/auction-template/sealed-auction.ts index 7314ccd9..633534a7 100644 --- a/services/auction-template/sealed-auction.ts +++ b/services/auction-template/sealed-auction.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.auction-template.sealed-auction"); +import { runCommand, type CommandContext } from "@sub-rosa/command"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; @@ -17,13 +17,13 @@ import { } from "@sub-rosa/tlock"; import { systemTime } from "@sub-rosa/time"; +const diagnostics = createLogger("services.auction-template.sealed-auction"); + const DRAND_GENESIS = 1_692_803_367; const DRAND_PERIOD = 3; const DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; const DRAND_PUBKEY_C1C0 = "03cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a01a714f2edb74119a2f2b0d5a7c75ba902d163700a61bc224ededd8e63aef7be1aaf8e93d7a9718b047ccddb3eb5d68b0e5db2b6bfbb01c867749cadffca88b36c24f3012ba09fc4d3022c5c37dce0f977d3adb5d183c7477c442b1f04515273"; const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; const VOID_GRACE = 3600; const { clock, scheduler } = systemTime; @@ -31,16 +31,16 @@ const { clock, scheduler } = systemTime; const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; const usdc = (s: bigint) => `${(Number(s) / 1e7).toFixed(2)} USDC`; const banner = (s: string) => diagnostics.info("progress", `\n═══ ${s} ═══`); -async function fixtureMain() { - const fixturePath = resolve(process.cwd(), "../receipt-cli/src/fixtures/golden.json"); +async function fixtureMain(repoRoot: string) { + const fixturePath = resolve(repoRoot, "services/receipt-cli/src/fixtures/golden.json"); const receipt: RoundReceipt = JSON.parse(await readFile(fixturePath, "utf8")); diagnostics.info("mode-fixture-offline", "mode: fixture (offline)"); @@ -117,21 +117,22 @@ async function fixtureMain() { diagnostics.info("fixture-passed-golden-receipt-verified-all-phases-docum", "\n✅ FIXTURE PASSED — golden receipt verified, all phases documented."); } -async function testnetMain() { - // Lazy imports: only needed for testnet mode; avoids triggering the - // @noble/hashes/sha256 export issue when running in FIXTURE=1 mode. +async function testnetMain(ctx: CommandContext) { const { Account, Address, Contract, Keypair, TransactionBuilder, rpc, scValToNative } = await import("@stellar/stellar-sdk"); const { basicNodeSigner } = await import("@stellar/stellar-sdk/contract"); const { closeRound, keepRound } = await import("@sub-rosa/keeper"); const { RoundContract, SubRosaClient } = await import("@sub-rosa/sdk"); - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const bidder1Secret = reqEnv("BIDDER1_SECRET"); - const bidder2Secret = reqEnv("BIDDER2_SECRET"); - const keeperSecret = reqEnv("KEEPER_SECRET"); - const wasmHash = reqEnv("WASM_HASH"); - const usdcSac = reqEnv("USDC_SAC"); + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + + const operatorSecret = reqEnv("OPERATOR_SECRET", ctx.env); + const bidder1Secret = reqEnv("BIDDER1_SECRET", ctx.env); + const bidder2Secret = reqEnv("BIDDER2_SECRET", ctx.env); + const keeperSecret = reqEnv("KEEPER_SECRET", ctx.env); + const wasmHash = reqEnv("WASM_HASH", ctx.env); + const usdcSac = reqEnv("USDC_SAC", ctx.env); const operatorKp = Keypair.fromSecret(operatorSecret); const bidder1Kp = Keypair.fromSecret(bidder1Secret); @@ -140,11 +141,11 @@ async function testnetMain() { const b1 = bidder1Kp.publicKey(); const b2 = bidder2Kp.publicKey(); - const server = new rpc.Server(RPC_URL); + const server = new rpc.Server(rpcUrl); const sac = new Contract(usdcSac); const balanceOf = async (addr: string): Promise => { const source = new Account(op, "0"); - const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: NETWORK }) + const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: network }) .addOperation(sac.call("balance", new Address(addr).toScVal())) .setTimeout(30) .build(); @@ -155,8 +156,8 @@ async function testnetMain() { }; diagnostics.info("mode-testnet", "mode: testnet"); - diagnostics.info("network", "network:", { "NETWORK_0": NETWORK }); - diagnostics.info("rpc", "rpc: ", { "RPC_URL_0": RPC_URL }); + diagnostics.info("network", "network:", { "NETWORK_0": network }); + diagnostics.info("rpc", "rpc: ", { "RPC_URL_0": rpcUrl }); diagnostics.info("operator", "operator:", { "op_0": op }); diagnostics.info("token", "token: ", { "usdcSac_0": usdcSac }); @@ -172,10 +173,10 @@ async function testnetMain() { }, { wasmHash, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, + rpcUrl, + networkPassphrase: network, publicKey: op, - signTransaction: basicNodeSigner(operatorKp, NETWORK).signTransaction, + signTransaction: basicNodeSigner(operatorKp, network).signTransaction, }, ); const contractId = (await deployTx.signAndSend()).result.options.contractId; @@ -188,7 +189,7 @@ async function testnetMain() { const revealDeadline = tReveal + 120; const auditor = generateAuditorKeypair(); - const operator = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: operatorSecret }); + const operator = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: operatorSecret }); const roundId = await operator.createRound({ itemRef: sha256("sub-rosa://auction-template/demo"), revealRound, @@ -218,7 +219,7 @@ async function testnetMain() { identity: new TextEncoder().encode(`bidder:${who}`), auditorPublicKey: auditor.publicKey, }); - const client = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: secret }); + const client = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: secret }); await client.commit({ roundId, sealed, escrow }); diagnostics.info("progress-4", ` ${who}: bid ${usdc(value)} / escrow ${usdc(escrow)}`); } @@ -231,7 +232,7 @@ async function testnetMain() { diagnostics.info("contract-locked", ` contract locked ${usdc(E1 + E2)}`); banner("Phase 3 — Keeper: Wait R → Open → Reveal All"); - const keeperSdk = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: keeperSecret }); + const keeperSdk = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: keeperSecret }); const log = (m: string) => diagnostics.info("progress-5", " ·", { "m_0": m }); let rev = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 240, pollMs: 5000 }, roundId); for (let i = 0; i < 3 && rev.finalStatus === "Open"; i++) { @@ -278,17 +279,22 @@ async function testnetMain() { diagnostics.info("testnet-passed-full-lifecycle-deploy-commit-r-reveal-cl", "\n✅ TESTNET PASSED — full lifecycle: deploy → commit → R → reveal → clear → settle → verify."); } -async function main() { - const isFixture = process.env.FIXTURE === "1"; - if (isFixture) { - await fixtureMain(); - } else { - await testnetMain(); - } -} - -main().catch((err) => { - diagnostics.error("sealed-auction-template-failed", "\n❌ SEALED AUCTION TEMPLATE FAILED"); - diagnostics.error("progress-7", err); - process.exit(1); +runCommand({ + name: "services.auction-template.sealed-auction", + description: "Runnable sealed-auction integration template for Sub Rosa", + options: { + fixture: { + type: "boolean", + description: "Run in offline fixture mode", + }, + }, + async run(ctx) { + const isFixture = Boolean(ctx.args.fixture) || ctx.env.FIXTURE === "1"; + if (isFixture) { + await fixtureMain(ctx.repoRoot); + } else { + await testnetMain(ctx); + } + return 0; + }, }); diff --git a/services/keeper/package.json b/services/keeper/package.json index f75c0f2b..236ffe62 100644 --- a/services/keeper/package.json +++ b/services/keeper/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/keeper/scripts/keeper-e2e.ts b/services/keeper/scripts/keeper-e2e.ts index 4e399d16..ee689d9c 100644 --- a/services/keeper/scripts/keeper-e2e.ts +++ b/services/keeper/scripts/keeper-e2e.ts @@ -1,16 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.keeper.scripts.keeper-e2e"); -// Live keeper end-to-end proof. -// -// Deploys a fresh Round with reveal round R a couple of minutes out, commits a -// real sealed bid, then runs the permissionless keeper from a THIRD account -// (not operator, not bidder) to prove: -// • it waits for R, opens the reveal with R's real Drand signature (on-chain -// BLS verified), decrypts the seal and reveals the bid, and -// • a second pass is idempotent — it skips the already-revealed bid. -// -// Real network, real Drand beacon, real on-chain verification. No mock. - +import { runCommand } from "@sub-rosa/command"; import { createHash } from "node:crypto"; import { Keypair } from "@stellar/stellar-sdk"; @@ -26,6 +15,8 @@ import { systemTime } from "@sub-rosa/time"; import { keepRound } from "../src/keeper.js"; +const diagnostics = createLogger("services.keeper.scripts.keeper-e2e"); + const { clock, scheduler } = systemTime; const DRAND_GENESIS = 1_692_803_367; @@ -36,12 +27,8 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - -function reqEnv(name: string): string { - const v = process.env[name]; +function reqEnv(name: string, env: Record = process.env): string { + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } @@ -51,126 +38,121 @@ const fail = (m: string): never => { throw new Error(`keeper e2e assertion failed: ${m}`); }; -async function main() { - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const bidderSecret = reqEnv("BIDDER_SECRET"); - const keeperSecret = reqEnv("KEEPER_SECRET"); - const wasmHash = reqEnv("WASM_HASH"); - const usdc = reqEnv("USDC_SAC"); - - const operatorKp = Keypair.fromSecret(operatorSecret); - const bidderKp = Keypair.fromSecret(bidderSecret); - const keeperKp = Keypair.fromSecret(keeperSecret); - diagnostics.info("operator", "· operator:", { "value1_0": operatorKp.publicKey() }); - diagnostics.info("bidder", "· bidder: ", { "value1_0": bidderKp.publicKey() }); - diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey(), "value2_1": "(permissionless 3rd party)" }); - - // 1. Deploy a fresh Round. - diagnostics.info("1-6-deploying-round", "\n[1/6] deploying Round…"); - const signer = basicNodeSigner(operatorKp, NETWORK); - const deployTx = await RoundContract.deploy( - { - drand_pubkey: hex(DRAND_PUBKEY_C1C0), - g2_neg_generator: hex(DRAND_NEGGEN_C1C0), - dst: Buffer.from(DST, "utf8"), - drand_genesis: BigInt(DRAND_GENESIS), - drand_period: BigInt(DRAND_PERIOD), - usdc, - }, - { - wasmHash, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - publicKey: operatorKp.publicKey(), - signTransaction: signer.signTransaction, - }, - ); - const contractId = (await deployTx.signAndSend()).result.options.contractId; - diagnostics.info("progress", " ✔", { "contractId_0": contractId }); - - // 2. createRound with R ~150s out. - const operator = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: operatorSecret }); - const now = clock.nowSeconds(); - const commitDeadline = now + 90; - const revealRound = Math.ceil((now + 150 - DRAND_GENESIS) / DRAND_PERIOD); - const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; - const revealDeadline = tReveal + 600; - const auditor = generateAuditorKeypair(); - - diagnostics.info("2-6-createround-r", `\n[2/6] createRound (R=${revealRound}, time(R)≈${tReveal - now}s out)…`); - const roundId = await operator.createRound({ - itemRef: sha256("sub-rosa://keeper-e2e/item"), - revealRound, - commitDeadline, - revealDeadline, - auditorPubkey: auditor.publicKey, - clearingRule: "HighestBid", - }); - diagnostics.info("round", " ✔ round", { "value1_0": roundId.toString() }); - - // 3. Seal a bid to R and commit. - diagnostics.info("3-6-sealing-committing-a-bid", "\n[3/6] sealing + committing a bid…"); - const drand = quicknet(); - const value = 10_000_000n; - const escrow = 50_000_000n; - const nonce = generateNonce(); - const sealed = await sealBid({ - value, - nonce, - round: revealRound, - client: drand, - identity: new TextEncoder().encode("bidder:keeper-e2e"), - auditorPublicKey: auditor.publicKey, - }); - const bidder = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: bidderSecret }); - await bidder.commit({ roundId, sealed, escrow }); - diagnostics.info("committed-escrow", " ✔ committed (escrow", { "value1_0": escrow.toString(), "value2_1": "stroops)" }); - - // 4. Run the keeper (3rd-party account). It waits for R, opens, reveals. - diagnostics.info("4-6-running-keeper-waits-for-r-opens-with-real-signatur", "\n[4/6] running keeper (waits for R, opens with real signature, reveals)…"); - const keeperSdk = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: keeperSecret }); - const log = (m: string) => diagnostics.info("progress-2", " ·", { "m_0": m }); - - let res = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 240, pollMs: 5000 }, roundId); - // Tolerate API replica lag at the R boundary with a couple of re-passes. - for (let i = 0; i < 3 && res.finalStatus === "Open"; i++) { - await scheduler.sleep(5000); - res = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 60, pollMs: 5000 }, roundId); - } - diagnostics.info("keeper-pass-1", " keeper pass #1:", { "value1_0": JSON.stringify(res, bigintReplacer) }); - - if (!res.openedReveal && res.finalStatus !== "Revealing") fail(`reveal not opened (status ${res.finalStatus})`); - if (!res.revealed.includes(bidderKp.publicKey())) fail("bidder was not revealed"); - - // 5. Verify on-chain that the bid is revealed and valid. - diagnostics.info("5-6-verifying-on-chain-reveal", "\n[5/6] verifying on-chain reveal…"); - const reader = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, publicKey: keeperKp.publicKey() }); - const st = await reader.getBidState(roundId, bidderKp.publicKey()); - if (st.revealed_value !== value) fail(`revealed_value ${st.revealed_value} != ${value}`); - if (st.valid !== true) fail("revealed bid not marked valid"); - diagnostics.info("revealed-value", " ✔ revealed_value =", { "value1_0": st.revealed_value?.toString(), "value2_1": "valid =", "valid_2": st.valid }); - - // 6. Idempotency: a second keeper pass must skip, not fail or double-act. - diagnostics.info("6-6-second-keeper-pass-idempotency", "\n[6/6] second keeper pass (idempotency)…"); - const res2 = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 0 }, roundId); - diagnostics.info("keeper-pass-2", " keeper pass #2:", { "value1_0": JSON.stringify(res2, bigintReplacer) }); - if (res2.openedReveal) fail("second pass re-opened reveal"); - if (res2.revealed.length !== 0) fail("second pass re-revealed"); - if (!res2.skipped.some((s) => s.bidder === bidderKp.publicKey() && s.reason.includes("already revealed"))) { - fail("second pass did not skip the already-revealed bid"); - } - diagnostics.info("idempotent-nothing-re-done-bid-skipped-as-already-revea", " ✔ idempotent: nothing re-done, bid skipped as already revealed"); - - diagnostics.info("keeper-e2e-passed-waited-for-r-opened-with-real-drand-s", "\n✅ KEEPER E2E PASSED — waited for R, opened with real Drand sig, revealed, idempotent."); - diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString() }); -} - function bigintReplacer(_k: string, v: unknown): unknown { return typeof v === "bigint" ? v.toString() : v; } -main().catch((err) => { - diagnostics.error("keeper-e2e-failed", "\n❌ KEEPER E2E FAILED"); - diagnostics.error("progress-3", err); - process.exit(1); +runCommand({ + name: "services.keeper.keeper-e2e", + description: "Live keeper end-to-end proof", + async run(ctx) { + const operatorSecret = reqEnv("OPERATOR_SECRET", ctx.env); + const bidderSecret = reqEnv("BIDDER_SECRET", ctx.env); + const keeperSecret = reqEnv("KEEPER_SECRET", ctx.env); + const wasmHash = reqEnv("WASM_HASH", ctx.env); + const usdc = reqEnv("USDC_SAC", ctx.env); + + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + + const operatorKp = Keypair.fromSecret(operatorSecret); + const bidderKp = Keypair.fromSecret(bidderSecret); + const keeperKp = Keypair.fromSecret(keeperSecret); + diagnostics.info("operator", "· operator:", { "value1_0": operatorKp.publicKey() }); + diagnostics.info("bidder", "· bidder: ", { "value1_0": bidderKp.publicKey() }); + diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey(), "value2_1": "(permissionless 3rd party)" }); + + diagnostics.info("1-6-deploying-round", "\n[1/6] deploying Round…"); + const signer = basicNodeSigner(operatorKp, network); + const deployTx = await RoundContract.deploy( + { + drand_pubkey: hex(DRAND_PUBKEY_C1C0), + g2_neg_generator: hex(DRAND_NEGGEN_C1C0), + dst: Buffer.from(DST, "utf8"), + drand_genesis: BigInt(DRAND_GENESIS), + drand_period: BigInt(DRAND_PERIOD), + usdc, + }, + { + wasmHash, + rpcUrl, + networkPassphrase: network, + publicKey: operatorKp.publicKey(), + signTransaction: signer.signTransaction, + }, + ); + const contractId = (await deployTx.signAndSend()).result.options.contractId; + diagnostics.info("progress", " ✔", { "contractId_0": contractId }); + + const operator = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: operatorSecret }); + const now = clock.nowSeconds(); + const commitDeadline = now + 90; + const revealRound = Math.ceil((now + 150 - DRAND_GENESIS) / DRAND_PERIOD); + const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; + const revealDeadline = tReveal + 600; + const auditor = generateAuditorKeypair(); + + diagnostics.info("2-6-createround-r", `\n[2/6] createRound (R=${revealRound}, time(R)≈${tReveal - now}s out)…`); + const roundId = await operator.createRound({ + itemRef: sha256("sub-rosa://keeper-e2e/item"), + revealRound, + commitDeadline, + revealDeadline, + auditorPubkey: auditor.publicKey, + clearingRule: "HighestBid", + }); + diagnostics.info("round", " ✔ round", { "value1_0": roundId.toString() }); + + diagnostics.info("3-6-sealing-committing-a-bid", "\n[3/6] sealing + committing a bid…"); + const drand = quicknet(); + const value = 10_000_000n; + const escrow = 50_000_000n; + const nonce = generateNonce(); + const sealed = await sealBid({ + value, + nonce, + round: revealRound, + client: drand, + identity: new TextEncoder().encode("bidder:keeper-e2e"), + auditorPublicKey: auditor.publicKey, + }); + const bidder = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: bidderSecret }); + await bidder.commit({ roundId, sealed, escrow }); + diagnostics.info("committed-escrow", " ✔ committed (escrow", { "value1_0": escrow.toString(), "value2_1": "stroops)" }); + + diagnostics.info("4-6-running-keeper-waits-for-r-opens-with-real-signatur", "\n[4/6] running keeper (waits for R, opens with real signature, reveals)…"); + const keeperSdk = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: keeperSecret }); + const log = (m: string) => diagnostics.info("progress-2", " ·", { "m_0": m }); + + let res = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 240, pollMs: 5000 }, roundId); + for (let i = 0; i < 3 && res.finalStatus === "Open"; i++) { + await scheduler.sleep(5000); + res = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 60, pollMs: 5000 }, roundId); + } + diagnostics.info("keeper-pass-1", " keeper pass #1:", { "value1_0": JSON.stringify(res, bigintReplacer) }); + + if (!res.openedReveal && res.finalStatus !== "Revealing") fail(`reveal not opened (status ${res.finalStatus})`); + if (!res.revealed.includes(bidderKp.publicKey())) fail("bidder was not revealed"); + + diagnostics.info("5-6-verifying-on-chain-reveal", "\n[5/6] verifying on-chain reveal…"); + const reader = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, publicKey: keeperKp.publicKey() }); + const st = await reader.getBidState(roundId, bidderKp.publicKey()); + if (st.revealed_value !== value) fail(`revealed_value ${st.revealed_value} != ${value}`); + if (st.valid !== true) fail("revealed bid not marked valid"); + diagnostics.info("revealed-value", " ✔ revealed_value =", { "value1_0": st.revealed_value?.toString(), "value2_1": "valid =", "valid_2": st.valid }); + + diagnostics.info("6-6-second-keeper-pass-idempotency", "\n[6/6] second keeper pass (idempotency)…"); + const res2 = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 0 }, roundId); + diagnostics.info("keeper-pass-2", " keeper pass #2:", { "value1_0": JSON.stringify(res2, bigintReplacer) }); + if (res2.openedReveal) fail("second pass re-opened reveal"); + if (res2.revealed.length !== 0) fail("second pass re-revealed"); + if (!res2.skipped.some((s) => s.bidder === bidderKp.publicKey() && s.reason.includes("already revealed"))) { + fail("second pass did not skip the already-revealed bid"); + } + diagnostics.info("idempotent-nothing-re-done-bid-skipped-as-already-revea", " ✔ idempotent: nothing re-done, bid skipped as already revealed"); + + diagnostics.info("keeper-e2e-passed-waited-for-r-opened-with-real-drand-s", "\n✅ KEEPER E2E PASSED — waited for R, opened with real Drand sig, revealed, idempotent."); + diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString() }); + return 0; + }, }); diff --git a/services/keeper/scripts/lifecycle-e2e.ts b/services/keeper/scripts/lifecycle-e2e.ts index 3e68445b..23f5a001 100644 --- a/services/keeper/scripts/lifecycle-e2e.ts +++ b/services/keeper/scripts/lifecycle-e2e.ts @@ -1,18 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.keeper.scripts.lifecycle-e2e"); -// Full live testnet lifecycle proof. -// -// commit×2 → wait R → openReveal → reveal all → clear → settle/refund → 0 -// -// Two bidders commit sealed bids in a real testnet round denominated in a -// (custom-issued) USDC Stellar Asset Contract. A permissionless 3rd-party keeper -// waits for Drand round R, opens the reveal with R's real signature (on-chain -// BLS verified), reveals both bids, clears the winner deterministically after -// the reveal deadline, and settles — paying the winner's bid to the operator and -// refunding the loser and the winner's surplus via real SAC transfers. We assert -// every balance and that the contract holds exactly zero at the end, then prove -// a second pass is idempotent (already cleared / already settled are skipped). - +import { runCommand } from "@sub-rosa/command"; import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -38,6 +25,8 @@ import { systemTime } from "@sub-rosa/time"; import { closeRound, keepRound } from "../src/keeper.js"; +const diagnostics = createLogger("services.keeper.scripts.lifecycle-e2e"); + const { clock, scheduler } = systemTime; const DRAND_GENESIS = 1_692_803_367; @@ -48,15 +37,11 @@ const DRAND_PUBKEY_C1C0 = const DRAND_NEGGEN_C1C0 = "13e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb813fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed0d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa"; -const RPC_URL = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NETWORK = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - const hex = (s: string) => Buffer.from(s, "hex"); const sha256 = (s: string) => createHash("sha256").update(s).digest(); const sleep = (ms: number) => scheduler.sleep(ms); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; @@ -65,206 +50,212 @@ const fail = (m: string): never => { }; const usdc = (stroops: bigint) => (Number(stroops) / 1e7).toFixed(2); const bytesHex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex"); -const repoPath = (path: string) => - path.startsWith("/") ? path : resolve(process.cwd(), "../..", path); +const repoPath = (path: string, repoRoot: string) => + path.startsWith("/") ? path : resolve(repoRoot, path); -async function writeAuditorTrace(path: string, trace: unknown) { - const out = repoPath(path); +async function writeAuditorTrace(path: string, trace: unknown, repoRoot: string) { + const out = repoPath(path, repoRoot); await mkdir(dirname(out), { recursive: true }); await writeFile(out, `${JSON.stringify(trace, null, 2)}\n`); diagnostics.info("auditor-trace", " ✔ auditor trace:", { "out_0": out }); } -async function main() { - const operatorSecret = reqEnv("OPERATOR_SECRET"); - const bidder1Secret = reqEnv("BIDDER1_SECRET"); - const bidder2Secret = reqEnv("BIDDER2_SECRET"); - const keeperSecret = reqEnv("KEEPER_SECRET"); - const wasmHash = reqEnv("WASM_HASH"); - const usdcSac = reqEnv("USDC_SAC"); +runCommand({ + name: "services.keeper.lifecycle-e2e", + description: "Full live testnet lifecycle proof", + async run(ctx) { + const operatorSecret = reqEnv("OPERATOR_SECRET", ctx.env); + const bidder1Secret = reqEnv("BIDDER1_SECRET", ctx.env); + const bidder2Secret = reqEnv("BIDDER2_SECRET", ctx.env); + const keeperSecret = reqEnv("KEEPER_SECRET", ctx.env); + const wasmHash = reqEnv("WASM_HASH", ctx.env); + const usdcSac = reqEnv("USDC_SAC", ctx.env); - const operatorKp = Keypair.fromSecret(operatorSecret); - const bidder1Kp = Keypair.fromSecret(bidder1Secret); - const bidder2Kp = Keypair.fromSecret(bidder2Secret); - const op = operatorKp.publicKey(); - const b1 = bidder1Kp.publicKey(); - const b2 = bidder2Kp.publicKey(); - diagnostics.info("operator", "· operator:", { "op_0": op }); - diagnostics.info("bidder1", "· bidder1: ", { "b1_0": b1 }); - diagnostics.info("bidder2", "· bidder2: ", { "b2_0": b2 }); - diagnostics.info("token", "· token: ", { "usdcSac_0": usdcSac, "value2_1": "(USDC SAC)" }); + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - // USDC balance reader — a read-only simulation of the SAC's `balance(id)`. - const server = new rpc.Server(RPC_URL); - const sac = new Contract(usdcSac); - const balanceOf = async (addr: string): Promise => { - const source = new Account(op, "0"); - const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: NETWORK }) - .addOperation(sac.call("balance", new Address(addr).toScVal())) - .setTimeout(30) - .build(); - const sim = await server.simulateTransaction(tx); - if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); - if (!sim.result) return 0n; - return scValToNative(sim.result.retval) as bigint; - }; + const operatorKp = Keypair.fromSecret(operatorSecret); + const bidder1Kp = Keypair.fromSecret(bidder1Secret); + const bidder2Kp = Keypair.fromSecret(bidder2Secret); + const op = operatorKp.publicKey(); + const b1 = bidder1Kp.publicKey(); + const b2 = bidder2Kp.publicKey(); + diagnostics.info("operator", "· operator:", { "op_0": op }); + diagnostics.info("bidder1", "· bidder1: ", { "b1_0": b1 }); + diagnostics.info("bidder2", "· bidder2: ", { "b2_0": b2 }); + diagnostics.info("token", "· token: ", { "usdcSac_0": usdcSac, "value2_1": "(USDC SAC)" }); - // ── 1. Deploy a fresh Round denominated in USDC ──────────────────────── - diagnostics.info("1-8-deploying-round-usdc-denominated", "\n[1/8] deploying Round (USDC-denominated)…"); - const signer = basicNodeSigner(operatorKp, NETWORK); - const deployTx = await RoundContract.deploy( - { - drand_pubkey: hex(DRAND_PUBKEY_C1C0), - g2_neg_generator: hex(DRAND_NEGGEN_C1C0), - dst: Buffer.from(DST, "utf8"), - drand_genesis: BigInt(DRAND_GENESIS), - drand_period: BigInt(DRAND_PERIOD), - usdc: usdcSac, - }, - { - wasmHash, - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - publicKey: op, - signTransaction: signer.signTransaction, - }, - ); - const contractId = (await deployTx.signAndSend()).result.options.contractId; - diagnostics.info("progress", " ✔", { "contractId_0": contractId }); + const server = new rpc.Server(rpcUrl); + const sac = new Contract(usdcSac); + const balanceOf = async (addr: string): Promise => { + const source = new Account(op, "0"); + const tx = new TransactionBuilder(source, { fee: "100", networkPassphrase: network }) + .addOperation(sac.call("balance", new Address(addr).toScVal())) + .setTimeout(30) + .build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) throw new Error(`balance sim failed: ${sim.error}`); + if (!sim.result) return 0n; + return scValToNative(sim.result.retval) as bigint; + }; - // ── 2. createRound (HighestBid) with a short, near-future R ──────────── - const operator = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: operatorSecret }); - const now = clock.nowSeconds(); - const commitDeadline = now + 75; - const revealRound = Math.ceil((now + 135 - DRAND_GENESIS) / DRAND_PERIOD); - const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; - const revealDeadline = tReveal + 75; - const auditor = generateAuditorKeypair(); + diagnostics.info("1-8-deploying-round-usdc-denominated", "\n[1/8] deploying Round (USDC-denominated)…"); + const signer = basicNodeSigner(operatorKp, network); + const deployTx = await RoundContract.deploy( + { + drand_pubkey: hex(DRAND_PUBKEY_C1C0), + g2_neg_generator: hex(DRAND_NEGGEN_C1C0), + dst: Buffer.from(DST, "utf8"), + drand_genesis: BigInt(DRAND_GENESIS), + drand_period: BigInt(DRAND_PERIOD), + usdc: usdcSac, + }, + { + wasmHash, + rpcUrl, + networkPassphrase: network, + publicKey: op, + signTransaction: signer.signTransaction, + }, + ); + const contractId = (await deployTx.signAndSend()).result.options.contractId; + diagnostics.info("progress", " ✔", { "contractId_0": contractId }); - diagnostics.info("2-8-createround-r", `\n[2/8] createRound (R=${revealRound}, time(R)≈${tReveal - now}s, reveal window 75s)…`); - const roundId = await operator.createRound({ - itemRef: sha256("sub-rosa://lifecycle/sealed-asset-auction"), - revealRound, - commitDeadline, - revealDeadline, - auditorPubkey: auditor.publicKey, - clearingRule: "HighestBid", - }); - diagnostics.info("round", " ✔ round", { "value1_0": roundId.toString() }); + const operator = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: operatorSecret }); + const now = clock.nowSeconds(); + const commitDeadline = now + 90; + const revealRound = Math.ceil((now + 120 - DRAND_GENESIS) / DRAND_PERIOD); + const tReveal = DRAND_GENESIS + DRAND_PERIOD * revealRound; + const revealDeadline = tReveal + 30; + const auditor = generateAuditorKeypair(); - // ── 3. Two bidders seal + commit ─────────────────────────────────────── - const V1 = 300_000_000n; // 30 USDC bid - const E1 = 500_000_000n; // 50 USDC escrow - const V2 = 700_000_000n; // 70 USDC bid → winner (HighestBid) - const E2 = 800_000_000n; // 80 USDC escrow - const drand = quicknet(); + diagnostics.info("2-8-createround-highestbid", "\n[2/8] createRound (HighestBid)…", { "value1_0": { + revealRound, + timeUntilR: `${tReveal - now}s`, + commitDeadline: `${commitDeadline - now}s`, + revealDeadline: `${revealDeadline - now}s`, + } }); + const roundId = await operator.createRound({ + itemRef: sha256("sub-rosa://lifecycle-e2e/test-asset"), + revealRound, + commitDeadline, + revealDeadline, + auditorPubkey: auditor.publicKey, + clearingRule: "HighestBid", + }); + diagnostics.info("round", " ✔ round", { "value1_0": roundId.toString() }); - const before = { - op: await balanceOf(op), - b1: await balanceOf(b1), - b2: await balanceOf(b2), - contract: await balanceOf(contractId), - }; - diagnostics.info("3-8-initial-usdc", "\n[3/8] initial USDC:", { "value1_0": { - operator: usdc(before.op), bidder1: usdc(before.b1), bidder2: usdc(before.b2), contract: usdc(before.contract), - } }); + const V1 = 300_000_000n; + const E1 = 500_000_000n; + const V2 = 700_000_000n; + const E2 = 800_000_000n; + const drand = quicknet(); - async function commitBid(secret: string, value: bigint, escrow: bigint, who: string) { - const nonce = generateNonce(); - const sealed = await sealBid({ - value, nonce, round: revealRound, client: drand, - identity: new TextEncoder().encode(`bidder:${who}`), - auditorPublicKey: auditor.publicKey, - }); - const client = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: secret }); - await client.commit({ roundId, sealed, escrow }); - diagnostics.info("progress-2", ` ✔ ${who} committed bid ${usdc(value)} / escrow ${usdc(escrow)} USDC`); - return { label: who, blobHex: bytesHex(sealed.auditorBlob) }; - } - diagnostics.info("3-8-sealing-committing-two-bids", "\n[3/8] sealing + committing two bids…"); - const auditorRows = [ - await commitBid(bidder1Secret, V1, E1, "bidder1"), - await commitBid(bidder2Secret, V2, E2, "bidder2"), - ]; - await writeAuditorTrace("artifacts/lifecycle-auditor-trace.json", { - source: "lifecycle:e2e", - generatedAt: clock.toISOString(), - network: "Stellar Testnet", - contractId, - roundId: Number(roundId), - revealRound, - secretHex: bytesHex(auditor.secretKey), - publicHex: bytesHex(auditor.publicKey), - blobs: Object.fromEntries(auditorRows.map((row) => [row.label, row.blobHex])), - }); + const before = { + op: await balanceOf(op), + b1: await balanceOf(b1), + b2: await balanceOf(b2), + contract: await balanceOf(contractId), + }; + diagnostics.info("3-8-initial-usdc", "\n[3/8] initial USDC:", { "value1_0": { + operator: usdc(before.op), bidder1: usdc(before.b1), bidder2: usdc(before.b2), contract: usdc(before.contract), + } }); - const lockedContract = await balanceOf(contractId); - if (lockedContract - before.contract !== E1 + E2) { - fail(`escrow locked ${lockedContract - before.contract} != ${E1 + E2}`); - } - diagnostics.info("contract-locked", ` ✔ contract locked ${usdc(E1 + E2)} USDC in escrow`); + async function commitBid(secret: string, value: bigint, escrow: bigint, who: string) { + const nonce = generateNonce(); + const sealed = await sealBid({ + value, nonce, round: revealRound, client: drand, + identity: new TextEncoder().encode(`bidder:${who}`), + auditorPublicKey: auditor.publicKey, + }); + const client = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: secret }); + await client.commit({ roundId, sealed, escrow }); + diagnostics.info("progress-2", ` ✔ ${who} committed bid ${usdc(value)} / escrow ${usdc(escrow)} USDC`); + return { label: who, blobHex: bytesHex(sealed.auditorBlob) }; + } + diagnostics.info("3-8-sealing-committing-two-bids", "\n[3/8] sealing + committing two bids…"); + const auditorRows = [ + await commitBid(bidder1Secret, V1, E1, "bidder1"), + await commitBid(bidder2Secret, V2, E2, "bidder2"), + ]; + await writeAuditorTrace("artifacts/lifecycle-auditor-trace.json", { + source: "lifecycle:e2e", + generatedAt: clock.toISOString(), + network: "Stellar Testnet", + contractId, + roundId: Number(roundId), + revealRound, + secretHex: bytesHex(auditor.secretKey), + publicHex: bytesHex(auditor.publicKey), + blobs: Object.fromEntries(auditorRows.map((row) => [row.label, row.blobHex])), + }, ctx.repoRoot); - // ── 4. Keeper: wait R, open reveal, reveal all ───────────────────────── - diagnostics.info("4-8-keeper-waits-for-r-opens-reveal-reveals-all", "\n[4/8] keeper waits for R, opens reveal, reveals all…"); - const keeperSdk = new SubRosaClient({ rpcUrl: RPC_URL, networkPassphrase: NETWORK, contractId, secretKey: keeperSecret }); - const log = (m: string) => diagnostics.info("progress-3", " ·", { "m_0": m }); - let rev = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 240, pollMs: 5000 }, roundId); - for (let i = 0; i < 3 && rev.finalStatus === "Open"; i++) { - await sleep(5000); - rev = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 60, pollMs: 5000 }, roundId); - } - if (![b1, b2].every((b) => rev.revealed.includes(b))) { - fail(`not all bids revealed: ${JSON.stringify(rev)}`); - } - diagnostics.info("both-bids-revealed", " ✔ both bids revealed"); + const locked = { + op: await balanceOf(op), + b1: await balanceOf(b1), + b2: await balanceOf(b2), + contract: await balanceOf(contractId), + }; + diagnostics.info("locked-usdc", " locked USDC:", { "value1_0": { + operator: usdc(locked.op), bidder1: usdc(locked.b1), bidder2: usdc(locked.b2), contract: usdc(locked.contract), + } }); + if (before.b1 - locked.b1 !== E1) fail(`bidder1 escrow mismatch: delta=${before.b1 - locked.b1} expected=${E1}`); + if (before.b2 - locked.b2 !== E2) fail(`bidder2 escrow mismatch: delta=${before.b2 - locked.b2} expected=${E2}`); + if (locked.contract - before.contract !== E1 + E2) fail("contract escrow != E1 + E2"); + diagnostics.info("both-escrows-locked-in-contract-total-e1-e2", " ✔ both escrows locked in contract (total = E1 + E2)"); - // ── 5. Wait out the reveal deadline ──────────────────────────────────── - diagnostics.info("5-8-waiting-for-the-reveal-deadline-to-pass", "\n[5/8] waiting for the reveal deadline to pass…"); - while (clock.nowSeconds() <= revealDeadline + 3) { - const remain = revealDeadline + 4 - clock.nowSeconds(); - if (remain > 0) { log(`~${remain}s until reveal deadline`); await sleep(Math.min(5000, remain * 1000)); } - } + diagnostics.info("4-8-keeper-waits-for-r-opens-reveal-with-real-signature", "\n[4/8] keeper waits for R, opens reveal with real signature, reveals both…"); + const keeperSdk = new SubRosaClient({ rpcUrl, networkPassphrase: network, contractId, secretKey: keeperSecret }); + const log = (m: string) => diagnostics.info("progress-3", " ·", { "m_0": m }); + let rev = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 240, pollMs: 5000 }, roundId); + for (let i = 0; i < 3 && rev.finalStatus === "Open"; i++) { + await sleep(5000); + rev = await keepRound({ sdk: keeperSdk, drand, log, maxWaitSeconds: 60, pollMs: 5000 }, roundId); + } + if (![b1, b2].every((b) => rev.revealed.includes(b))) { + fail(`not all bids revealed: ${JSON.stringify(rev)}`); + } + diagnostics.info("both-bids-revealed", " ✔ both bids revealed"); - // ── 6. Keeper: clear + settle ────────────────────────────────────────── - diagnostics.info("6-8-keeper-clears-settles", "\n[6/8] keeper clears + settles…"); - const close = await closeRound({ sdk: keeperSdk, drand, log }, roundId); - diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, (_k, v) => (typeof v === "bigint" ? v.toString() : v)) }); - if (!close.cleared) fail("round was not cleared"); - if (!close.settled) fail("round was not settled"); - if (close.winner !== b2) fail(`winner ${close.winner} != bidder2 ${b2}`); - diagnostics.info("deterministic-winner-bidder2-highest-bid-70-usdc", " ✔ deterministic winner = bidder2 (highest bid 70 USDC)"); + diagnostics.info("5-8-waiting-for-the-reveal-deadline-to-pass", "\n[5/8] waiting for the reveal deadline to pass…"); + while (clock.nowSeconds() <= revealDeadline + 3) { + const remain = revealDeadline + 4 - clock.nowSeconds(); + if (remain > 0) { log(`~${remain}s until reveal deadline`); await sleep(Math.min(5000, remain * 1000)); } + } - // ── 7. Balance checks — real SAC transfers, contract drains to zero ──── - diagnostics.info("7-8-verifying-balances", "\n[7/8] verifying balances…"); - const after = { - op: await balanceOf(op), - b1: await balanceOf(b1), - b2: await balanceOf(b2), - contract: await balanceOf(contractId), - }; - diagnostics.info("final-usdc", " final USDC:", { "value1_0": { - operator: usdc(after.op), bidder1: usdc(after.b1), bidder2: usdc(after.b2), contract: usdc(after.contract), - } }); - if (after.op - before.op !== V2) fail(`operator delta ${after.op - before.op} != winning bid ${V2}`); - if (after.b1 !== before.b1) fail(`bidder1 not made whole: ${before.b1} → ${after.b1}`); - if (before.b2 - after.b2 !== V2) fail(`bidder2 net ${before.b2 - after.b2} != bid ${V2} (surplus not refunded)`); - if (after.contract !== 0n) fail(`contract balance ${after.contract} != 0`); - diagnostics.info("operator-70-bidder1-whole-bidder2-net-70-surplus-refund", " ✔ operator +70, bidder1 whole, bidder2 net -70 (surplus refunded), contract = 0"); + diagnostics.info("6-8-keeper-clears-settles", "\n[6/8] keeper clears + settles…"); + const close = await closeRound({ sdk: keeperSdk, drand, log }, roundId); + diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, (_k, v) => (typeof v === "bigint" ? v.toString() : v)) }); + if (!close.cleared) fail("round was not cleared"); + if (!close.settled) fail("round was not settled"); + if (close.winner !== b2) fail(`winner ${close.winner} != bidder2 ${b2}`); + diagnostics.info("deterministic-winner-bidder2-highest-bid-70-usdc", " ✔ deterministic winner = bidder2 (highest bid 70 USDC)"); - // ── 8. Idempotency — second close must skip, not error ───────────────── - diagnostics.info("8-8-second-close-pass-idempotency", "\n[8/8] second close pass (idempotency)…"); - const close2 = await closeRound({ sdk: keeperSdk, drand, log }, roundId); - diagnostics.info("close-2", " close#2:", { "value1_0": JSON.stringify(close2, (_k, v) => (typeof v === "bigint" ? v.toString() : v)) }); - if (close2.cleared || close2.settled) fail("second pass re-cleared/re-settled"); - if (close2.finalStatus !== "Settled") fail(`unexpected final status ${close2.finalStatus}`); - diagnostics.info("idempotent-already-settled-round-skipped-cleanly", " ✔ idempotent: already-settled round skipped cleanly"); + diagnostics.info("7-8-verifying-balances", "\n[7/8] verifying balances…"); + const after = { + op: await balanceOf(op), + b1: await balanceOf(b1), + b2: await balanceOf(b2), + contract: await balanceOf(contractId), + }; + diagnostics.info("final-usdc", " final USDC:", { "value1_0": { + operator: usdc(after.op), bidder1: usdc(after.b1), bidder2: usdc(after.b2), contract: usdc(after.contract), + } }); + if (after.op - before.op !== V2) fail(`operator delta ${after.op - before.op} != winning bid ${V2}`); + if (after.b1 !== before.b1) fail(`bidder1 not made whole: ${before.b1} → ${after.b1}`); + if (before.b2 - after.b2 !== V2) fail(`bidder2 net ${before.b2 - after.b2} != bid ${V2} (surplus not refunded)`); + if (after.contract !== 0n) fail(`contract balance ${after.contract} != 0`); + diagnostics.info("operator-70-bidder1-whole-bidder2-net-70-surplus-refund", " ✔ operator +70, bidder1 whole, bidder2 net -70 (surplus refunded), contract = 0"); - diagnostics.info("full-lifecycle-passed-commit-2-r-open-reveal-clear-sett", "\n✅ FULL LIFECYCLE PASSED — commit×2 → R → open → reveal → clear → settle → 0."); - diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString(), "value4_3": "winner:", "winner_4": close.winner }); -} + diagnostics.info("8-8-second-close-pass-idempotency", "\n[8/8] second close pass (idempotency)…"); + const close2 = await closeRound({ sdk: keeperSdk, drand, log }, roundId); + diagnostics.info("close-2", " close#2:", { "value1_0": JSON.stringify(close2, (_k, v) => (typeof v === "bigint" ? v.toString() : v)) }); + if (close2.cleared || close2.settled) fail("second pass re-cleared/re-settled"); + if (close2.finalStatus !== "Settled") fail(`unexpected final status ${close2.finalStatus}`); + diagnostics.info("idempotent-already-settled-round-skipped-cleanly", " ✔ idempotent: already-settled round skipped cleanly"); -main().catch((err) => { - diagnostics.error("full-lifecycle-failed", "\n❌ FULL LIFECYCLE FAILED"); - diagnostics.error("progress-4", err); - process.exit(1); + diagnostics.info("full-lifecycle-passed-commit-2-r-open-reveal-clear-sett", "\n✅ FULL LIFECYCLE PASSED — commit×2 → R → open → reveal → clear → settle → 0."); + diagnostics.info("contract", " contract:", { "contractId_0": contractId, "value2_1": "round:", "value3_2": roundId.toString(), "value4_3": "winner:", "winner_4": close.winner }); + return 0; + }, }); diff --git a/services/keeper/scripts/mainnet-settle.ts b/services/keeper/scripts/mainnet-settle.ts index 5b16bd3b..f1762721 100644 --- a/services/keeper/scripts/mainnet-settle.ts +++ b/services/keeper/scripts/mainnet-settle.ts @@ -1,9 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.keeper.scripts.mainnet-settle"); -// Mainnet settlement — keepRound (wait R → open → reveal) + closeRound (clear → settle). -// Env: KEEPER_SECRET, ROUND_CONTRACT_ID, ROUND_ID (default 1) -// Requires MAINNET_CONFIRM=SUB_ROSA_MAINNET before submitting transactions. - +import { runCommand } from "@sub-rosa/command"; import { Keypair } from "@stellar/stellar-sdk"; import { assertMainnetConfirmed, @@ -19,15 +15,11 @@ import { systemTime } from "@sub-rosa/time"; import { closeRound, keepRound } from "../src/keeper.js"; +const diagnostics = createLogger("services.keeper.scripts.mainnet-settle"); const { clock, scheduler } = systemTime; -const RPC_URL = process.env.RPC_URL ?? "https://rpc.ankr.com/stellar_soroban"; -const NETWORK = - process.env.NETWORK_PASSPHRASE ?? - "Public Global Stellar Network ; September 2015"; - -function reqEnv(name: string): string { - const v = process.env[name]; +function reqEnv(name: string, env: Record = process.env): string { + const v = env[name]; if (!v) throw new Error(`missing required env var ${name}`); return v; } @@ -36,124 +28,128 @@ const sleep = (ms: number) => scheduler.sleep(ms); const bigintReplacer = (_k: string, v: unknown) => typeof v === "bigint" ? v.toString() : v; -async function main() { - assertMainnetConfirmed(); - - const keeperSecret = reqEnv("KEEPER_SECRET"); - const contractId = reqEnv("ROUND_CONTRACT_ID"); - const roundId = BigInt(process.env.ROUND_ID ?? "1"); - const keeperKp = Keypair.fromSecret(keeperSecret); - - const reader = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - publicKey: keeperKp.publicKey(), - }); - - const readiness = await runMainnetReadiness( - defaultMainnetReadinessInput({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, +runCommand({ + name: "services.keeper.mainnet-settle", + description: "Mainnet settlement — keepRound (wait R → open → reveal) + closeRound (clear → settle)", + options: { + round: { + type: "string", + description: "Round ID (defaults to ROUND_ID env or 1)", + }, + }, + async run(ctx) { + assertMainnetConfirmed(); + + const keeperSecret = reqEnv("KEEPER_SECRET", ctx.env); + const contractId = reqEnv("ROUND_CONTRACT_ID", ctx.env); + const roundId = BigInt((ctx.args.round as string | undefined) ?? ctx.env.ROUND_ID ?? "1"); + const rpcUrl = ctx.env.RPC_URL ?? "https://rpc.ankr.com/stellar_soroban"; + const network = ctx.env.NETWORK_PASSPHRASE ?? "Public Global Stellar Network ; September 2015"; + const keeperKp = Keypair.fromSecret(keeperSecret); + + const reader = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, contractId, - withBalances: false, - }), - { reader }, - ); - assertReadinessForExecute(readiness.checks); - - const sdk = new SubRosaClient({ - rpcUrl: RPC_URL, - networkPassphrase: NETWORK, - contractId, - secretKey: keeperSecret, - }); - const drand = quicknet(); - const log = (m: string) => diagnostics.info("progress", " ·", { "m_0": m }); - - diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); - diagnostics.info("round", "· round: ", { "value1_0": roundId.toString() }); - diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey() }); - - let round = await reader.getRound(roundId); - diagnostics.info("status", "\n[status] ", { "tag_0": round.status.tag, "value2_1": "R=", "value3_2": round.reveal_round.toString() }); - - // ── Phase 1: open + reveal ───────────────────────────────────────────── - if (round.status.tag === "Open" || round.status.tag === "Revealing") { - diagnostics.info("1-3-keeper-wait-r-open-reveal-reveal-all", "\n[1/3] keeper: wait R → open_reveal → reveal all…"); - let rev = await keepRound( - { sdk, drand, log, maxWaitSeconds: 600, pollMs: 5000 }, - roundId, + publicKey: keeperKp.publicKey(), + }); + + const readiness = await runMainnetReadiness( + defaultMainnetReadinessInput({ + rpcUrl, + networkPassphrase: network, + contractId, + withBalances: false, + }), + { reader }, ); - for (let i = 0; i < 5 && rev.finalStatus === "Open"; i++) { - await sleep(5000); - rev = await keepRound( - { sdk, drand, log, maxWaitSeconds: 120, pollMs: 5000 }, + assertReadinessForExecute(readiness.checks); + + const sdk = new SubRosaClient({ + rpcUrl, + networkPassphrase: network, + contractId, + secretKey: keeperSecret, + }); + const drand = quicknet(); + const log = (m: string) => diagnostics.info("progress", " ·", { "m_0": m }); + + diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); + diagnostics.info("round", "· round: ", { "value1_0": roundId.toString() }); + diagnostics.info("keeper", "· keeper: ", { "value1_0": keeperKp.publicKey() }); + + let round = await reader.getRound(roundId); + diagnostics.info("status", "\n[status] ", { "tag_0": round.status.tag, "value2_1": "R=", "value3_2": round.reveal_round.toString() }); + + if (round.status.tag === "Open" || round.status.tag === "Revealing") { + diagnostics.info("1-3-keeper-wait-r-open-reveal-reveal-all", "\n[1/3] keeper: wait R → open_reveal → reveal all…"); + let rev = await keepRound( + { sdk, drand, log, maxWaitSeconds: 600, pollMs: 5000 }, roundId, ); + for (let i = 0; i < 5 && rev.finalStatus === "Open"; i++) { + await sleep(5000); + rev = await keepRound( + { sdk, drand, log, maxWaitSeconds: 120, pollMs: 5000 }, + roundId, + ); + } + diagnostics.info("keep", " keep:", { "value1_0": JSON.stringify(rev, bigintReplacer) }); + if (rev.finalStatus === "Open") { + throw new Error("reveal not opened — Drand R not yet available"); + } + round = await reader.getRound(roundId); + } + + round = await reader.getRound(roundId); + const revealDeadline = Number(round.reveal_deadline); + diagnostics.info("2-3-waiting-for-reveal-deadline", "\n[2/3] waiting for reveal deadline…", { "revealDeadline_0": revealDeadline }); + while (clock.nowSeconds() <= revealDeadline + 3) { + const remain = revealDeadline + 4 - clock.nowSeconds(); + if (remain > 0) { + log(`~${remain}s until clear allowed`); + await sleep(Math.min(10_000, remain * 1000)); + } } - diagnostics.info("keep", " keep:", { "value1_0": JSON.stringify(rev, bigintReplacer) }); - if (rev.finalStatus === "Open") { - throw new Error("reveal not opened — Drand R not yet available"); + + diagnostics.info("3-3-clear-settle", "\n[3/3] clear + settle…"); + let close = await closeRound({ sdk, drand, log }, roundId); + if (!close.settled && close.finalStatus !== "Settled") { + await sleep(5000); + close = await closeRound({ sdk, drand, log }, roundId); } + diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, bigintReplacer) }); + round = await reader.getRound(roundId); - } - - // ── Phase 2: wait reveal deadline ────────────────────────────────────── - round = await reader.getRound(roundId); - const revealDeadline = Number(round.reveal_deadline); - diagnostics.info("2-3-waiting-for-reveal-deadline", "\n[2/3] waiting for reveal deadline…", { "revealDeadline_0": revealDeadline }); - while (clock.nowSeconds() <= revealDeadline + 3) { - const remain = revealDeadline + 4 - clock.nowSeconds(); - if (remain > 0) { - log(`~${remain}s until clear allowed`); - await sleep(Math.min(10_000, remain * 1000)); + if (round.status.tag !== "Settled") { + throw new Error(`expected Settled, got ${round.status.tag}`); } - } - - // ── Phase 3: clear + settle ──────────────────────────────────────────── - diagnostics.info("3-3-clear-settle", "\n[3/3] clear + settle…"); - let close = await closeRound({ sdk, drand, log }, roundId); - if (!close.settled && close.finalStatus !== "Settled") { - await sleep(5000); - close = await closeRound({ sdk, drand, log }, roundId); - } - diagnostics.info("close", " close:", { "value1_0": JSON.stringify(close, bigintReplacer) }); - - round = await reader.getRound(roundId); - if (round.status.tag !== "Settled") { - throw new Error(`expected Settled, got ${round.status.tag}`); - } - - const bidders = await reader.getBidders(roundId); - for (const b of bidders) { - const st = await reader.getBidState(roundId, b); - diagnostics.info("bid", ` bid ${b.slice(0, 8)}… value=${st.revealed_value?.toString()} valid=${st.valid} settled=${st.settled}`); - } - - const tokenSacId = nativeXlmSacId(NETWORK); - const balanceOf = createSacBalanceReader( - RPC_URL, - NETWORK, - tokenSacId, - keeperKp.publicKey(), - ); - const contractBalance = await balanceOf(contractId); - if (contractBalance !== 0n) { - throw new Error( - `contract escrow balance guardrail failed: expected 0, got ${contractBalance.toString()} stroops`, - ); - } - diagnostics.info("mainnet-settlement-complete", "\n✅ MAINNET SETTLEMENT COMPLETE"); - diagnostics.info("contract-2", " contract:", { "contractId_0": contractId }); - diagnostics.info("round-2", " round:", { "value1_0": roundId.toString() }); - diagnostics.info("winner", " winner:", { "value1_0": close.winner ?? round.winner }); - diagnostics.info("final-status", " final status:", { "tag_0": round.status.tag }); -} + const bidders = await reader.getBidders(roundId); + for (const b of bidders) { + const st = await reader.getBidState(roundId, b); + diagnostics.info("bid", ` bid ${b.slice(0, 8)}… value=${st.revealed_value?.toString()} valid=${st.valid} settled=${st.settled}`); + } + + const tokenSacId = nativeXlmSacId(network); + const balanceOf = createSacBalanceReader( + rpcUrl, + network, + tokenSacId, + keeperKp.publicKey(), + ); + const contractBalance = await balanceOf(contractId); + if (contractBalance !== 0n) { + throw new Error( + `contract escrow balance guardrail failed: expected 0, got ${contractBalance.toString()} stroops`, + ); + } -main().catch((err) => { - diagnostics.error("mainnet-settlement-failed", "\n❌ MAINNET SETTLEMENT FAILED"); - diagnostics.error("progress-2", err); - process.exit(1); + diagnostics.info("mainnet-settlement-complete", "\n✅ MAINNET SETTLEMENT COMPLETE"); + diagnostics.info("contract-2", " contract:", { "contractId_0": contractId }); + diagnostics.info("round-2", " round:", { "value1_0": roundId.toString() }); + diagnostics.info("winner", " winner:", { "value1_0": close.winner ?? round.winner }); + diagnostics.info("final-status", " final status:", { "tag_0": round.status.tag }); + return 0; + }, }); diff --git a/services/keeper/scripts/usdc-setup.ts b/services/keeper/scripts/usdc-setup.ts index 715a1169..84574126 100644 --- a/services/keeper/scripts/usdc-setup.ts +++ b/services/keeper/scripts/usdc-setup.ts @@ -1,12 +1,5 @@ import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.keeper.scripts.usdc-setup"); -// USDC asset provisioning (classic operations via Horizon). -// -// Establishes trustlines for the operator + both bidders to a custom-issued -// USDC asset and mints USDC to the bidders. Done in JS because stellar-cli -// `tx new change-trust` is unreliable on this version; the SAC deploy itself is -// still done with the CLI. Real assets, real trustlines — no mock. - +import { runCommand } from "@sub-rosa/command"; import { Asset, BASE_FEE, @@ -18,61 +11,71 @@ import { xdr, } from "@stellar/stellar-sdk"; -const HORIZON_URL = - process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; -const NETWORK = process.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; -const ASSET_CODE = process.env.ASSET_CODE ?? "USDC"; -const MINT_AMOUNT = process.env.MINT_AMOUNT ?? "1000"; // whole USDC per bidder +const diagnostics = createLogger("services.keeper.scripts.usdc-setup"); -const reqEnv = (n: string): string => { - const v = process.env[n]; +const reqEnv = (n: string, env: Record = process.env): string => { + const v = env[n]; if (!v) throw new Error(`missing required env var ${n}`); return v; }; -async function main() { - const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET")); - const operatorKp = Keypair.fromSecret(reqEnv("OPERATOR_SECRET")); - const bidder1Kp = Keypair.fromSecret(reqEnv("BIDDER1_SECRET")); - const bidder2Kp = Keypair.fromSecret(reqEnv("BIDDER2_SECRET")); +runCommand({ + name: "services.keeper.usdc-setup", + description: "USDC asset provisioning (classic operations via Horizon)", + options: { + asset: { + type: "string", + description: "Asset code (defaults to ASSET_CODE env or USDC)", + }, + amount: { + type: "string", + description: "Mint amount (defaults to MINT_AMOUNT env or 1000)", + }, + }, + async run(ctx) { + const horizonUrl = ctx.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + const network = ctx.env.NETWORK_PASSPHRASE ?? Networks.TESTNET; + const assetCode = (ctx.args.asset as string | undefined) ?? ctx.env.ASSET_CODE ?? "USDC"; + const mintAmount = (ctx.args.amount as string | undefined) ?? ctx.env.MINT_AMOUNT ?? "1000"; + + const issuerKp = Keypair.fromSecret(reqEnv("ISSUER_SECRET", ctx.env)); + const operatorKp = Keypair.fromSecret(reqEnv("OPERATOR_SECRET", ctx.env)); + const bidder1Kp = Keypair.fromSecret(reqEnv("BIDDER1_SECRET", ctx.env)); + const bidder2Kp = Keypair.fromSecret(reqEnv("BIDDER2_SECRET", ctx.env)); - const server = new Horizon.Server(HORIZON_URL); - const asset = new Asset(ASSET_CODE, issuerKp.publicKey()); + const server = new Horizon.Server(horizonUrl); + const asset = new Asset(assetCode, issuerKp.publicKey()); - async function submit(sourceKp: Keypair, op: xdr.Operation) { - const account = await server.loadAccount(sourceKp.publicKey()); - const tx = new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK, - }) - .addOperation(op) - .setTimeout(120) - .build(); - tx.sign(sourceKp); - await server.submitTransaction(tx); - } + async function submit(sourceKp: Keypair, op: xdr.Operation) { + const account = await server.loadAccount(sourceKp.publicKey()); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: network, + }) + .addOperation(op) + .setTimeout(120) + .build(); + tx.sign(sourceKp); + await server.submitTransaction(tx); + } - // Trustlines: operator (receives the winning bid) + both bidders. - for (const kp of [operatorKp, bidder1Kp, bidder2Kp]) { - await submit(kp, Operation.changeTrust({ asset })); - diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); - } + for (const kp of [operatorKp, bidder1Kp, bidder2Kp]) { + await submit(kp, Operation.changeTrust({ asset })); + diagnostics.info("trustline-ok", `trustline OK: ${kp.publicKey()}`); + } - // Mint USDC to the bidders so they can escrow real funds. - for (const kp of [bidder1Kp, bidder2Kp]) { - await submit( - issuerKp, - Operation.payment({ - destination: kp.publicKey(), - asset, - amount: MINT_AMOUNT, - }), - ); - diagnostics.info("minted", `minted ${MINT_AMOUNT} ${ASSET_CODE} → ${kp.publicKey()}`); - } -} + for (const kp of [bidder1Kp, bidder2Kp]) { + await submit( + issuerKp, + Operation.payment({ + destination: kp.publicKey(), + asset, + amount: mintAmount, + }), + ); + diagnostics.info("minted", `minted ${mintAmount} ${assetCode} → ${kp.publicKey()}`); + } -main().catch((err) => { - diagnostics.error("usdc-setup-failed", "usdc-setup failed:", { "value1_0": err?.response?.data ?? err }); - process.exit(1); + return 0; + }, }); diff --git a/services/keeper/src/queue.ts b/services/keeper/src/queue.ts index a913b447..0b2d95b6 100644 --- a/services/keeper/src/queue.ts +++ b/services/keeper/src/queue.ts @@ -1,71 +1,63 @@ // Copyright (c) 2026 Sub Rosa contributors import { createLogger } from '@sub-rosa/logging'; -const diagnostics = createLogger("services.keeper.src.queue"); +import { runCommand, UsageError } from '@sub-rosa/command'; import { KeeperStore, normalizeRoundId } from "./store.js"; -function usage() { - diagnostics.info("usage-npm-run-queue-command-args-commands-add-roundid-a", ` -Usage: npm run queue [args] - -Commands: - add Add a round to the watched queue - list List all watched rounds and their status - remove Remove a round from the queue -`); - process.exit(1); -} +const diagnostics = createLogger("services.keeper.src.queue"); -function main() { - const args = process.argv.slice(2); - if (args.length === 0) { - usage(); - } +runCommand({ + name: "services.keeper.queue", + description: "Manage the watched rounds queue for the keeper service", + usage: "npm run queue [roundId]", + async run(ctx) { + const [cmd, rawRoundId] = ctx.positionals; + if (!cmd) { + throw new UsageError("Missing command: add, list, or remove"); + } - const cmd = args[0]; - const store = new KeeperStore(); + const store = new KeeperStore(); - if (cmd === "add") { - const rawRoundId = args[1]; - if (!rawRoundId) { - diagnostics.error("error-missing-roundid", "Error: missing roundId"); - usage(); - } - const roundId = normalizeRoundId(rawRoundId); - const contractId = process.env.ROUND_CONTRACT_ID; - const network = process.env.NETWORK_PASSPHRASE; - store.addRound(roundId, { contractId, network }); - diagnostics.info("added-round", `Added round ${roundId} to the queue.`); - } else if (cmd === "list") { - const rounds = store.listRounds(); - if (rounds.length === 0) { - diagnostics.info("queue-is-empty", "Queue is empty."); - return; + if (cmd === "add") { + if (!rawRoundId) { + diagnostics.error("error-missing-roundid", "Error: missing roundId"); + throw new UsageError("missing roundId"); + } + const roundId = normalizeRoundId(rawRoundId); + const contractId = ctx.env.ROUND_CONTRACT_ID; + const network = ctx.env.NETWORK_PASSPHRASE; + store.addRound(roundId, { contractId, network }); + diagnostics.info("added-round", `Added round ${roundId} to the queue.`); + return 0; } - diagnostics.info("watching", `Watching ${rounds.length} rounds:\n`); - for (const r of rounds) { - const extra = r.lastAction ? ` (action: ${r.lastAction})` : ""; - const err = r.lastError ? ` (error: ${r.lastError})` : ""; - const contract = r.contractId ? ` [${r.contractId}]` : ""; - diagnostics.info("round", `- Round ${r.roundId}${contract}: ${r.lastStatus}${extra}${err} [retries: ${r.retryCount}]`); + + if (cmd === "list") { + const rounds = store.listRounds(); + if (rounds.length === 0) { + diagnostics.info("queue-is-empty", "Queue is empty."); + return 0; + } + diagnostics.info("watching", `Watching ${rounds.length} rounds:\n`); + for (const r of rounds) { + const extra = r.lastAction ? ` (action: ${r.lastAction})` : ""; + const err = r.lastError ? ` (error: ${r.lastError})` : ""; + const contract = r.contractId ? ` [${r.contractId}]` : ""; + diagnostics.info("round", `- Round ${r.roundId}${contract}: ${r.lastStatus}${extra}${err} [retries: ${r.retryCount}]`); + } + return 0; } - } else if (cmd === "remove") { - const rawRoundId = args[1]; - if (!rawRoundId) { - diagnostics.error("error-missing-roundid-2", "Error: missing roundId"); - usage(); + + if (cmd === "remove") { + if (!rawRoundId) { + diagnostics.error("error-missing-roundid-2", "Error: missing roundId"); + throw new UsageError("missing roundId"); + } + const roundId = normalizeRoundId(rawRoundId); + store.removeRound(roundId); + diagnostics.info("removed-round", `Removed round ${roundId} from the queue.`); + return 0; } - const roundId = normalizeRoundId(rawRoundId); - store.removeRound(roundId); - diagnostics.info("removed-round", `Removed round ${roundId} from the queue.`); - } else { - diagnostics.error("unknown-command", `Unknown command: ${cmd}`); - usage(); - } -} -try { - main(); -} catch (error) { - diagnostics.error("error", `Error: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); -} + diagnostics.error("unknown-command", `Unknown command: ${cmd}`); + throw new UsageError(`Unknown command: ${cmd}`); + }, +}); diff --git a/services/keeper/src/run.ts b/services/keeper/src/run.ts index 6f5551f5..3e866fce 100644 --- a/services/keeper/src/run.ts +++ b/services/keeper/src/run.ts @@ -15,6 +15,7 @@ const diagnostics = createLogger("services.keeper.src.run"); import { SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; +import { runCommand } from "@sub-rosa/command"; import { buildKeeperDryRunSummary, @@ -22,49 +23,49 @@ import { } from "./dry-run.js"; import { keepRound } from "./keeper.js"; -async function main() { - const config = parseKeeperRunConfig(); +function bigintReplacer(_key: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; +} - if (config.dryRun) { - const reader = new SubRosaClient({ +runCommand({ + name: "services.keeper.run", + description: "Keeper single-pass runner over a round", + async run() { + const config = parseKeeperRunConfig(); + + if (config.dryRun) { + const reader = new SubRosaClient({ + rpcUrl: config.rpcUrl, + networkPassphrase: config.networkPassphrase, + contractId: config.contractId, + }); + const summary = await buildKeeperDryRunSummary(reader, config.roundId); + diagnostics.info("keeper-dry-run-summary", "keeper dry-run summary:"); + diagnostics.info("progress", JSON.stringify(summary, bigintReplacer, 2)); + return 0; + } + + const sdk = new SubRosaClient({ rpcUrl: config.rpcUrl, networkPassphrase: config.networkPassphrase, contractId: config.contractId, + secretKey: config.keeperSecret!, }); - const summary = await buildKeeperDryRunSummary(reader, config.roundId); - diagnostics.info("keeper-dry-run-summary", "keeper dry-run summary:"); - diagnostics.info("progress", JSON.stringify(summary, bigintReplacer, 2)); - return; - } - - const sdk = new SubRosaClient({ - rpcUrl: config.rpcUrl, - networkPassphrase: config.networkPassphrase, - contractId: config.contractId, - secretKey: config.keeperSecret!, - }); - const result = await keepRound( - { - sdk, - drand: quicknet(), - log: (m) => diagnostics.info("progress-2", `· ${m}`), - maxWaitSeconds: config.maxWaitSeconds, - }, - config.roundId, - ); - - diagnostics.info("keeper-result", "\nkeeper result:", { "value1_0": JSON.stringify(result, bigintReplacer, 2) }); - if (result.finalStatus === "Open") { - diagnostics.info("round-still-open-r-not-yet-published-re-run-later", "round still Open (R not yet published) — re-run later."); - } -} - -function bigintReplacer(_key: string, value: unknown): unknown { - return typeof value === "bigint" ? value.toString() : value; -} + const result = await keepRound( + { + sdk, + drand: quicknet(), + log: (m) => diagnostics.info("progress-2", `· ${m}`), + maxWaitSeconds: config.maxWaitSeconds, + }, + config.roundId, + ); -main().catch((err) => { - diagnostics.error("keeper-failed", "keeper failed:", { "err_0": err }); - process.exit(1); + diagnostics.info("keeper-result", "\nkeeper result:", { "value1_0": JSON.stringify(result, bigintReplacer, 2) }); + if (result.finalStatus === "Open") { + diagnostics.info("round-still-open-r-not-yet-published-re-run-later", "round still Open (R not yet published) — re-run later."); + } + return 0; + }, }); diff --git a/services/keeper/src/serve.ts b/services/keeper/src/serve.ts index d7406c49..2de0273f 100644 --- a/services/keeper/src/serve.ts +++ b/services/keeper/src/serve.ts @@ -25,103 +25,105 @@ const diagnostics = createLogger("services.keeper.src.serve"); import { Keypair } from "@stellar/stellar-sdk"; import { SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; +import { runCommand, ConfigError } from "@sub-rosa/command"; import { createSettlementGuard } from "./settlement-guard.js"; import { createStatusServer, withGracefulShutdown } from "./status-server.js"; import { KeeperStore } from "./store.js"; import { runWatchLoop } from "./watch-loop.js"; -function reqEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`missing required env var ${name}`); +function reqEnv(ctxEnv: Record, name: string): string { + const v = ctxEnv[name]; + if (!v) throw new ConfigError(`missing required env var ${name}`); return v; } -async function main() { - const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); - const contractId = reqEnv("ROUND_CONTRACT_ID"); - const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; - const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - const keeperSecret = reqEnv("KEEPER_SECRET"); +runCommand({ + name: "services.keeper.serve", + description: "Sub Rosa keeper with status API", + async run(ctx) { + const pollMs = Number(ctx.env.WATCH_POLL_MS ?? "15000"); + const contractId = reqEnv(ctx.env, "ROUND_CONTRACT_ID"); + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const networkPassphrase = + ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + const keeperSecret = reqEnv(ctx.env, "KEEPER_SECRET"); - const sdk = new SubRosaClient({ - rpcUrl, - networkPassphrase, - contractId, - secretKey: keeperSecret, - }); - const reader = new SubRosaClient({ - rpcUrl, - networkPassphrase, - contractId, - publicKey: Keypair.fromSecret(keeperSecret).publicKey(), - }); - const drand = quicknet(); - const log = (m: string) => diagnostics.info("progress", `· ${m}`); - - const store = new KeeperStore(); - const settlementGuard = createSettlementGuard(); + const sdk = new SubRosaClient({ + rpcUrl, + networkPassphrase, + contractId, + secretKey: keeperSecret, + }); + const reader = new SubRosaClient({ + rpcUrl, + networkPassphrase, + contractId, + publicKey: Keypair.fromSecret(keeperSecret).publicKey(), + }); + const drand = quicknet(); + const log = (m: string) => diagnostics.info("progress", `· ${m}`); - let stopping = false; - process.on("SIGINT", () => { - diagnostics.info("serve-sigint-finishing-current-tick-then-exit", "\nserve: SIGINT — finishing current tick then exit"); - stopping = true; - }); - process.on("SIGTERM", () => { - stopping = true; - }); + let stopping = false; + ctx.signal.addEventListener("abort", () => { + stopping = true; + }); - const statusEnabled = (process.env.KEEPER_STATUS_ENABLE ?? "true").toLowerCase() !== "false"; - const statusHost = process.env.KEEPER_STATUS_HOST ?? "127.0.0.1"; - const statusPort = Number(process.env.KEEPER_STATUS_PORT ?? "8090"); + const store = new KeeperStore(); + const settlementGuard = createSettlementGuard(); - let statusHandle: ReturnType | undefined; - if (statusEnabled) { - const server = createStatusServer({ - host: statusHost, - port: statusPort, - contractId, - network: networkPassphrase, - reader, - drand, - storeRounds: () => store.listRounds(), - settleIndicator: (rid) => { - const entry = settlementGuard.getEntry(rid); - if (!entry) return "none"; - if (entry.status === "pending") return "pending"; - if (entry.status === "submitted") return "submitted"; - return "terminal"; - }, - }); - statusHandle = withGracefulShutdown(server); - diagnostics.info("status-api-http", `· status API: http://${statusHost}:${statusPort} (GET /status, /status/rounds/:id, /healthz, /status/health)`); - } else { - diagnostics.info("status-api-disabled-keeper-status-enable-false", "· status API disabled (KEEPER_STATUS_ENABLE=false)"); - } + const statusEnable = (ctx.env.KEEPER_STATUS_ENABLE ?? "true").toLowerCase() !== "false"; + const statusHost = ctx.env.KEEPER_STATUS_HOST ?? "127.0.0.1"; + const statusPort = Number(ctx.env.KEEPER_STATUS_PORT ?? "8090"); - diagnostics.info("sub-rosa-keeper-watch-status", "Sub Rosa keeper (watch + status)"); - diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); - diagnostics.info("poll", "· poll: ", { "pollMs_0": pollMs, "value2_1": "ms" }); - diagnostics.info("ctrl-c-to-stop", "· Ctrl+C to stop\n"); + let statusHandle: ReturnType | undefined; + if (statusEnable) { + const server = createStatusServer({ + host: statusHost, + port: statusPort, + contractId, + network: networkPassphrase, + reader, + drand, + storeRounds: () => store.listRounds(), + settleIndicator: (rid) => { + const entry = settlementGuard.getEntry(rid); + if (!entry) return "none"; + if (entry.status === "pending") return "pending"; + if (entry.status === "submitted") return "submitted"; + return "terminal"; + }, + }); + statusHandle = withGracefulShutdown(server); + diagnostics.info("status-api-http", `· status API: http://${statusHost}:${statusPort} (GET /status, /status/rounds/:id, /healthz, /status/health)`); + } else { + diagnostics.info("status-api-disabled-keeper-status-enable-false", "· status API disabled (KEEPER_STATUS_ENABLE=false)"); + } - await runWatchLoop({ - sdk, - drand, - log, - pollMs, - contractId, - network: networkPassphrase, - store, - settlementGuard, - isStopping: () => stopping, - }); + diagnostics.info("sub-rosa-keeper-watch-status", "Sub Rosa keeper (watch + status)"); + diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); + diagnostics.info("poll", "· poll: ", { "pollMs_0": pollMs, "value2_1": "ms" }); + diagnostics.info("ctrl-c-to-stop", "· Ctrl+C to stop\n"); - if (statusHandle) await statusHandle.close(); - diagnostics.info("serve-stopped", "serve: stopped"); -} + try { + await runWatchLoop({ + sdk, + drand, + log, + pollMs, + contractId, + network: networkPassphrase, + store, + settlementGuard, + isStopping: () => stopping || ctx.signal.aborted, + }); + } finally { + if (statusHandle) { + await statusHandle.close(); + } + } -main().catch((err) => { - diagnostics.error("keeper-serve-failed", "keeper serve failed:", { "err_0": err }); - process.exit(1); + diagnostics.info("serve-stopped", "serve: stopped"); + return 0; + }, }); diff --git a/services/keeper/src/watch.ts b/services/keeper/src/watch.ts index 12a7972e..96d18a9f 100644 --- a/services/keeper/src/watch.ts +++ b/services/keeper/src/watch.ts @@ -17,67 +17,64 @@ const diagnostics = createLogger("services.keeper.src.watch"); import { Keypair } from "@stellar/stellar-sdk"; import { SubRosaClient } from "@sub-rosa/sdk"; import { quicknet } from "@sub-rosa/tlock"; +import { runCommand, ConfigError } from "@sub-rosa/command"; import { createSettlementGuard } from "./settlement-guard.js"; import { KeeperStore } from "./store.js"; import { runWatchLoop } from "./watch-loop.js"; -function reqEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`missing required env var ${name}`); +function reqEnv(ctxEnv: Record, name: string): string { + const v = ctxEnv[name]; + if (!v) throw new ConfigError(`missing required env var ${name}`); return v; } -async function main() { - const pollMs = Number(process.env.WATCH_POLL_MS ?? "15000"); - const contractId = reqEnv("ROUND_CONTRACT_ID"); - const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; - const networkPassphrase = - process.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; - const keeperSecret = reqEnv("KEEPER_SECRET"); +runCommand({ + name: "services.keeper.watch", + description: "Watch-mode keeper daemon", + async run(ctx) { + const pollMs = Number(ctx.env.WATCH_POLL_MS ?? "15000"); + const contractId = reqEnv(ctx.env, "ROUND_CONTRACT_ID"); + const rpcUrl = ctx.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; + const networkPassphrase = + ctx.env.NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + const keeperSecret = reqEnv(ctx.env, "KEEPER_SECRET"); - const sdk = new SubRosaClient({ - rpcUrl, - networkPassphrase, - contractId, - secretKey: keeperSecret, - }); - const drand = quicknet(); - const log = (m: string) => diagnostics.info("progress", `· ${m}`); + const sdk = new SubRosaClient({ + rpcUrl, + networkPassphrase, + contractId, + secretKey: keeperSecret, + }); + const drand = quicknet(); + const log = (m: string) => diagnostics.info("progress", `· ${m}`); - let stopping = false; - process.on("SIGINT", () => { - diagnostics.info("watch-sigint-finishing-current-tick-then-exit", "\nwatch: SIGINT — finishing current tick then exit"); - stopping = true; - }); - process.on("SIGTERM", () => { - stopping = true; - }); + let stopping = false; + ctx.signal.addEventListener("abort", () => { + stopping = true; + }); - const store = new KeeperStore(); - const settlementGuard = createSettlementGuard(); + const store = new KeeperStore(); + const settlementGuard = createSettlementGuard(); - diagnostics.info("sub-rosa-watch-mode-keeper", "Sub Rosa watch-mode keeper"); - diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); - diagnostics.info("poll", "· poll: ", { "pollMs_0": pollMs, "value2_1": "ms" }); - diagnostics.info("ctrl-c-to-stop", "· Ctrl+C to stop\n"); + diagnostics.info("sub-rosa-watch-mode-keeper", "Sub Rosa watch-mode keeper"); + diagnostics.info("contract", "· contract:", { "contractId_0": contractId }); + diagnostics.info("poll", "· poll: ", { "pollMs_0": pollMs, "value2_1": "ms" }); + diagnostics.info("ctrl-c-to-stop", "· Ctrl+C to stop\n"); - await runWatchLoop({ - sdk, - drand, - log, - pollMs, - contractId, - network: networkPassphrase, - store, - settlementGuard, - isStopping: () => stopping, - }); + await runWatchLoop({ + sdk, + drand, + log, + pollMs, + contractId, + network: networkPassphrase, + store, + settlementGuard, + isStopping: () => stopping || ctx.signal.aborted, + }); - diagnostics.info("watch-stopped", "watch: stopped"); -} - -main().catch((err) => { - diagnostics.error("watch-keeper-failed", "watch keeper failed:", { "err_0": err }); - process.exit(1); + diagnostics.info("watch-stopped", "watch: stopped"); + return 0; + }, }); diff --git a/services/receipt-cli/package.json b/services/receipt-cli/package.json index 0edea34a..c90095af 100644 --- a/services/receipt-cli/package.json +++ b/services/receipt-cli/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@sub-rosa/command": "workspace:*", "@sub-rosa/logging": "workspace:*", "@sub-rosa/sdk": "workspace:*", "@sub-rosa/time": "workspace:*", diff --git a/services/receipt-cli/src/index.ts b/services/receipt-cli/src/index.ts index 034294f3..aa14c17b 100644 --- a/services/receipt-cli/src/index.ts +++ b/services/receipt-cli/src/index.ts @@ -7,6 +7,7 @@ const diagnostics = createLogger("services.receipt-cli.src.index"); import { readFileSync, writeFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { SubRosaClient, parseReceipt, serializeReceipt, verifyReceipt, redactReceipt } from "@sub-rosa/sdk"; +import { runCommand, CommandError } from "@sub-rosa/command"; import { buildJsonOutput } from "./json-output.js"; function usage(): never { @@ -26,10 +27,10 @@ Environment for "export": NETWORK_PASSPHRASE Network passphrase (default: Test SDF Network ; September 2015) CONTRACT_ID Round contract ID (C…) `); - process.exit(1); + throw new CommandError("Invalid receipt-cli invocation", 1); } -async function cmdExport(roundIdStr: string) { +async function cmdExport(roundIdStr: string): Promise { const roundId = BigInt(roundIdStr); const rpcUrl = process.env.RPC_URL ?? "https://soroban-testnet.stellar.org"; const networkPassphrase = @@ -37,7 +38,7 @@ async function cmdExport(roundIdStr: string) { const contractId = process.env.CONTRACT_ID; if (!contractId) { diagnostics.error("contract-id-env-var-is-required-for-export", "CONTRACT_ID env var is required for export"); - process.exit(1); + return 1; } const client = new SubRosaClient({ rpcUrl, networkPassphrase, contractId }); @@ -46,9 +47,10 @@ async function cmdExport(roundIdStr: string) { const filename = `round-${roundId}-receipt.json`; writeFileSync(filename, json, "utf-8"); diagnostics.info("wrote", `Wrote ${filename}`); + return 0; } -async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) { +async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string): Promise { let rawJson: string; try { rawJson = readFileSync(path, "utf-8"); @@ -58,7 +60,7 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) } else { diagnostics.error("cannot-read", `Cannot read ${path}: ${e}`); } - process.exit(1); + return 1; } let receipt; @@ -70,7 +72,7 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) } else { diagnostics.error("invalid-json", `Invalid JSON: ${e}`); } - process.exit(1); + return 1; } const result = verifyReceipt(receipt); @@ -94,7 +96,7 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) } else { diagnostics.error("error", `Error: ${message}`); } - process.exit(1); + return 1; } if (!receipt.artifactChecksum) { @@ -110,7 +112,7 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) } else { diagnostics.error("error-2", `Error: ${message}`); } - process.exit(1); + return 1; } if (receipt.artifactChecksum !== computedChecksum) { @@ -126,13 +128,13 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) } else { diagnostics.error("error-3", `Error: ${message}`); } - process.exit(1); + return 1; } } if (jsonMode) { writeData(JSON.stringify(buildJsonOutput(receipt, result, null), null, 2)); - process.exit(result.valid ? 0 : 1); + return result.valid ? 0 : 1; } const status = result.valid ? "PASS" : "FAIL"; @@ -148,16 +150,16 @@ async function cmdVerify(path: string, jsonMode: boolean, artifactPath?: string) diagnostics.info("progress-7", ` ${icon} [${issue.code}]${pathStr} ${issue.message}`); } - process.exit(result.valid ? 0 : 1); + return result.valid ? 0 : 1; } -async function cmdRedact(inputPath: string, outputPath?: string) { +async function cmdRedact(inputPath: string, outputPath?: string): Promise { let json: string; try { json = readFileSync(inputPath, "utf-8"); } catch (e) { diagnostics.error("cannot-read-2", `Cannot read ${inputPath}: ${e}`); - process.exit(1); + return 1; } let receipt; @@ -165,7 +167,7 @@ async function cmdRedact(inputPath: string, outputPath?: string) { receipt = parseReceipt(json); } catch (e) { diagnostics.error("invalid-json-2", `Invalid JSON: ${e}`); - process.exit(1); + return 1; } const redacted = redactReceipt(receipt); @@ -173,51 +175,45 @@ async function cmdRedact(inputPath: string, outputPath?: string) { const outPath = outputPath ?? inputPath.replace(/\.json$/, ".redacted.json"); writeFileSync(outPath, out, "utf-8"); diagnostics.info("wrote-redacted-receipt-to", `Wrote redacted receipt to ${outPath}`); + return 0; } -async function main() { - const cmd = process.argv[2]; - if (!cmd) usage(); - - switch (cmd) { - case "export": { - const arg = process.argv[3]; - if (!arg) usage(); - await cmdExport(arg); - break; +runCommand({ + name: "services.receipt-cli", + description: "Export a round receipt from RPC, verify a local file, or redact sensitive fields", + usage: "receipt-cli [options]", + options: { + json: { type: "boolean" }, + "verify-artifact-checksum": { type: "string" }, + }, + async run(ctx) { + const [cmd] = ctx.positionals; + if (!cmd) { + usage(); } - case "verify": { - const args = process.argv.slice(3); - const jsonMode = args.includes("--json"); - const verifyChecksumIdx = args.indexOf("--verify-artifact-checksum"); - let artifactPath: string | undefined = undefined; - let filteredArgs = [...args]; - if (verifyChecksumIdx !== -1) { - const nextArg = args[verifyChecksumIdx + 1]; - if (nextArg && !nextArg.startsWith("--")) { - artifactPath = nextArg; - filteredArgs.splice(verifyChecksumIdx, 2); - } else { - usage(); - } + + switch (cmd) { + case "export": { + const arg = ctx.positionals[1]; + if (!arg) usage(); + return await cmdExport(arg); } - const path = filteredArgs.find((a) => !a.startsWith("--")); - if (!path) usage(); - await cmdVerify(path, jsonMode, artifactPath); - break; - } - case "redact": { - const arg = process.argv[3]; - if (!arg) usage(); - await cmdRedact(arg, process.argv[4]); - break; + case "verify": { + const path = ctx.positionals[1]; + if (!path) usage(); + const jsonMode = Boolean(ctx.options.json); + const artifactPath = typeof ctx.options["verify-artifact-checksum"] === "string" + ? ctx.options["verify-artifact-checksum"] + : undefined; + return await cmdVerify(path, jsonMode, artifactPath); + } + case "redact": { + const arg = ctx.positionals[1]; + if (!arg) usage(); + return await cmdRedact(arg, ctx.positionals[2]); + } + default: + usage(); } - default: - usage(); - } -} - -main().catch((e) => { - diagnostics.error("progress-8", e); - process.exit(1); + }, });