From 9d703f62b4e8c037da1882af2a2021f443c011ab Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 5 Aug 2026 19:26:09 +0000 Subject: [PATCH 1/3] test: add Cashu Fault Lab mint recovery lane --- .github/workflows/cashu-fault-lab.yml | 45 ++ package.json | 3 + test/fault-lab/README.md | 33 ++ test/fault-lab/adapter.test.ts | 88 ++++ test/fault-lab/adapter.ts | 599 +++++++++++++++++++++++ test/fault-lab/compose.yml | 50 ++ test/fault-lab/run-mint-response-lost.sh | 120 +++++ test/fault-lab/tsconfig.json | 27 + 8 files changed, 965 insertions(+) create mode 100644 .github/workflows/cashu-fault-lab.yml create mode 100644 test/fault-lab/README.md create mode 100644 test/fault-lab/adapter.test.ts create mode 100644 test/fault-lab/adapter.ts create mode 100644 test/fault-lab/compose.yml create mode 100755 test/fault-lab/run-mint-response-lost.sh create mode 100644 test/fault-lab/tsconfig.json diff --git a/.github/workflows/cashu-fault-lab.yml b/.github/workflows/cashu-fault-lab.yml new file mode 100644 index 000000000..869830d18 --- /dev/null +++ b/.github/workflows/cashu-fault-lab.yml @@ -0,0 +1,45 @@ +name: cashu-fault-lab + +on: + workflow_dispatch: + pull_request: + paths: + - 'test/fault-lab/**' + - '.github/workflows/cashu-fault-lab.yml' + +jobs: + mint-response-lost: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Setup Node.js for Cashu Fault Lab + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test lifecycle adapter contract + run: bun run test:fault-lab:adapter + + - name: Run mint response-loss scenario + run: bun run test:fault-lab:mint-response-lost + env: + COCO_FAULT_LAB_REPORT: artifacts/fault-lab/mint-response-lost.json + + - name: Upload redacted Fault Lab report + if: always() + uses: actions/upload-artifact@v4 + with: + name: cashu-fault-lab-mint-response-lost + path: artifacts/fault-lab/mint-response-lost.json + if-no-files-found: ignore diff --git a/package.json b/package.json index 2d44006f6..c976451fd 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,11 @@ }, "scripts": { "build": "bun scripts/build.ts build", + "build:fault-lab": "bun run --filter='@cashu/coco-core' build && bun run --filter='@cashu/coco-sql-storage' build && bun run --filter='@cashu/coco-sqlite-bun' build", "typecheck": "bun scripts/build.ts typecheck", "test:coverage:core": "bun test packages/core/test/unit --coverage --coverage-reporter=lcov --coverage-dir=coverage/core", + "test:fault-lab:adapter": "bun run build:fault-lab && bun test test/fault-lab/adapter.test.ts", + "test:fault-lab:mint-response-lost": "./test/fault-lab/run-mint-response-lost.sh", "docs:dev": "vitepress dev packages/docs", "docs:build": "vitepress build packages/docs", "docs:preview": "vitepress preview packages/docs", diff --git a/test/fault-lab/README.md b/test/fault-lab/README.md new file mode 100644 index 000000000..b26bdb117 --- /dev/null +++ b/test/fault-lab/README.md @@ -0,0 +1,33 @@ +# Cashu Fault Lab integration + +This optional integration runs Coco against the experimental +[`cashu-fault-lab`](https://github.com/GautamBytes/cashu-fault-lab) wallet lifecycle suite. It is +test tooling, not a runtime dependency or release certification gate. + +The first lane covers `mint-response-lost`: mintd commits a NUT-04 issuance, the official Fault Lab +gateway drops that response, and Coco must converge on one successful operation with the original +output plan and a 64 sat wallet credit. + +## Run + +Prerequisites: + +- Bun and the repository dependencies (`bun install --frozen-lockfile`) +- Node.js 24 (required by `cashu-fault-lab@0.2.0`) +- Docker with Compose + +```bash +bun run test:fault-lab:mint-response-lost +``` + +The script starts pinned mintd and Fault Lab containers, launches the test-only Coco lifecycle +adapter on `127.0.0.1:4103`, runs the published Fault Lab CLI, and removes its containers and +temporary SQLite database afterward. Set `COCO_FAULT_LAB_REPORT` to retain the redacted JSON report: + +```bash +COCO_FAULT_LAB_REPORT=artifacts/fault-lab/mint-response-lost.json \ + bun run test:fault-lab:mint-response-lost +``` + +The adapter currently advertises only `mint` and process durability. Restart scenarios require a +generic external-adapter restart hook in Fault Lab or a Coco-specific driver. diff --git a/test/fault-lab/adapter.test.ts b/test/fault-lab/adapter.test.ts new file mode 100644 index 000000000..66f937671 --- /dev/null +++ b/test/fault-lab/adapter.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startCocoLifecycleAdapter, type RunningCocoLifecycleAdapter } from './adapter.ts'; + +const CONTROL_TOKEN = 'coco-fault-lab-test-token'; +const MINT_URL = 'http://127.0.0.1:4300'; + +describe('Coco Fault Lab lifecycle HTTP adapter', () => { + let adapter: RunningCocoLifecycleAdapter | undefined; + let temporaryDirectory: string | undefined; + + afterEach(async () => { + await adapter?.stop(); + if (temporaryDirectory !== undefined) { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + }); + + it('exposes an authenticated, resettable mint lifecycle wallet', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'coco-fault-lab-')); + adapter = await startCocoLifecycleAdapter({ + controlToken: CONTROL_TOKEN, + databasePath: join(temporaryDirectory, 'coco.sqlite'), + host: '127.0.0.1', + mintId: 'mintd-local', + mintUrl: MINT_URL, + port: 0, + unit: 'sat', + }); + + const capabilities = await request(adapter.url, '/v1/lifecycle/capabilities'); + expect(capabilities).toMatchObject({ + schemaVersion: 1, + implementation: { id: 'coco', language: 'typescript' }, + operations: ['mint'], + durability: 'process', + recovery: ['quote_state', 'nut09_restore'], + mints: [{ id: 'mintd-local', implementation: 'mintd' }], + }); + + expect( + await request(adapter.url, '/v1/lifecycle/reset', { + method: 'POST', + body: JSON.stringify({ seed: 'wallet-lifecycle-v1:mint-response-lost' }), + }), + ).toEqual({ ok: true }); + + expect(await request(adapter.url, '/v1/lifecycle/wallet')).toEqual({ + walletId: 'coco', + mint: MINT_URL, + unit: 'sat', + balances: { available: 0, reserved: 0, recoverable: 0 }, + proofs: [], + }); + }); + + it('rejects lifecycle requests without the control token', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'coco-fault-lab-')); + adapter = await startCocoLifecycleAdapter({ + controlToken: CONTROL_TOKEN, + databasePath: join(temporaryDirectory, 'coco.sqlite'), + host: '127.0.0.1', + mintId: 'mintd-local', + mintUrl: MINT_URL, + port: 0, + unit: 'sat', + }); + + const response = await fetch(`${adapter.url}/v1/lifecycle/capabilities`); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ + code: 'UNAUTHORIZED', + message: 'A valid adapter control token is required', + }); + }); +}); + +async function request(origin: string, path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('authorization', `Bearer ${CONTROL_TOKEN}`); + if (init.body !== undefined) headers.set('content-type', 'application/json'); + const response = await fetch(`${origin}${path}`, { ...init, headers }); + expect(response.status).toBe(200); + return response.json(); +} diff --git a/test/fault-lab/adapter.ts b/test/fault-lab/adapter.ts new file mode 100644 index 000000000..9365019be --- /dev/null +++ b/test/fault-lab/adapter.ts @@ -0,0 +1,599 @@ +import { Amount, initializeCoco, type Manager, type MintOperation } from '@cashu/coco-core'; +import { SqliteRepositories } from '@cashu/coco-sqlite-bun'; +import { Database } from 'bun:sqlite'; +import { createHash } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +type LifecyclePhase = + | 'created' + | 'prepared' + | 'submitted' + | 'ambiguous' + | 'reconciling' + | 'succeeded' + | 'failed_definitive' + | 'recovery_blocked'; + +interface MintLifecycleInput { + readonly operationId: string; + readonly kind: 'mint'; + readonly mint: string; + readonly unit: string; + readonly amount: number; + readonly method: 'bolt11'; +} + +interface LifecycleOperationView { + readonly operationId: string; + readonly kind: 'mint'; + readonly mint: string; + readonly unit: string; + readonly intentHash: string; + readonly phase: LifecyclePhase; + readonly evidenceCode?: string; + readonly amount: number; + readonly requestHash: string; + readonly quoteHash: string; + readonly outputPlanHash: string; +} + +interface StoredOperationRow { + readonly operation_id: string; + readonly input_json: string; + readonly coco_operation_id: string; + readonly intent_hash: string; + readonly phase: LifecyclePhase; + readonly evidence_code: string | null; + readonly request_hash: string; + readonly quote_hash: string; + readonly output_plan_hash: string; +} + +export interface CocoLifecycleAdapterOptions { + readonly controlToken: string; + readonly databasePath: string; + readonly host?: string; + readonly implementationVersion?: string; + readonly mintId: string; + readonly mintImplementation?: string; + readonly mintVersion?: string; + readonly mintUrl: string; + readonly port?: number; + readonly unit: string; +} + +export interface RunningCocoLifecycleAdapter { + readonly url: string; + stop(): Promise; +} + +class LifecycleHttpError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +class CocoLifecycleAdapter { + readonly #options: CocoLifecycleAdapterOptions; + #database: Database | undefined; + #manager: Manager | undefined; + + constructor(options: CocoLifecycleAdapterOptions) { + if (options.controlToken.length === 0 || /[\r\n]/u.test(options.controlToken)) { + throw new Error('Fault Lab adapter control token is invalid'); + } + if (options.databasePath !== ':memory:' && !options.databasePath.endsWith('.sqlite')) { + throw new Error('Fault Lab adapter database path must end in .sqlite'); + } + if (!isCanonicalMintUrl(options.mintUrl)) { + throw new Error('Fault Lab adapter mint URL must be canonical'); + } + if (!/^[a-z0-9][a-z0-9_-]{0,15}$/u.test(options.unit)) { + throw new Error('Fault Lab adapter unit is invalid'); + } + this.#options = options; + } + + async fetch(request: Request): Promise { + try { + this.#authorize(request); + return await this.#route(request); + } catch (error) { + if (error instanceof LifecycleHttpError) { + return json({ code: error.code, message: error.message }, error.status); + } + console.error('Coco Fault Lab adapter request failed', error); + return json({ code: 'INTERNAL_ERROR', message: 'Internal server error' }, 500); + } + } + + async stop(): Promise { + await this.#closeSession(); + } + + #authorize(request: Request): void { + if (request.headers.get('authorization') !== `Bearer ${this.#options.controlToken}`) { + throw new LifecycleHttpError( + 401, + 'UNAUTHORIZED', + 'A valid adapter control token is required', + ); + } + } + + async #route(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname; + + if (request.method === 'GET' && path === '/v1/lifecycle/capabilities') { + return json(this.#capabilities()); + } + if (request.method === 'POST' && path === '/v1/lifecycle/reset') { + const body = await requestJson(request); + if (!isRecord(body) || typeof body.seed !== 'string' || body.seed.length === 0) { + throw new LifecycleHttpError(422, 'SCHEMA_VALIDATION', 'A non-empty seed is required'); + } + await this.#reset(body.seed); + return json({ ok: true }); + } + if (request.method === 'POST' && path === '/v1/lifecycle/operations') { + return json(await this.#start(parseMintInput(await requestJson(request)))); + } + + const operationRoute = path.match( + /^\/v1\/lifecycle\/operations\/([A-Za-z0-9_-]{22})(\/resume)?$/u, + ); + if (operationRoute !== null) { + const operationId = operationRoute[1]!; + if (request.method === 'POST' && operationRoute[2] === '/resume') { + return json(await this.#resume(operationId)); + } + if (request.method === 'GET' && operationRoute[2] === undefined) { + return json(await this.#operation(operationId)); + } + } + + if (request.method === 'GET' && path === '/v1/lifecycle/wallet') { + return json(await this.#wallet()); + } + if (request.method === 'GET' && path === '/v1/lifecycle/evidence') { + return json([]); + } + + throw new LifecycleHttpError(404, 'NOT_FOUND', 'Lifecycle route was not found'); + } + + #capabilities() { + const version = this.#options.implementationVersion ?? 'workspace'; + return { + schemaVersion: 1, + implementation: { + id: 'coco', + version, + language: 'typescript', + runtime: `bun-${Bun.version}`, + sourceDigest: `sha256:${digest('cashu-fault-lab/coco/source/v1', version)}`, + buildDigest: `sha256:${digest('cashu-fault-lab/coco/adapter/v1', 'mint')}`, + }, + operations: ['mint'], + nuts: [4, 9, 13], + durability: 'process', + recovery: ['quote_state', 'nut09_restore'], + mints: [ + { + id: this.#options.mintId, + implementation: this.#options.mintImplementation ?? 'mintd', + ...(this.#options.mintVersion === undefined + ? {} + : { version: this.#options.mintVersion }), + }, + ], + } as const; + } + + async #reset(seed: string): Promise { + await this.#closeSession(); + await resetDatabaseFiles(this.#options.databasePath); + + if (this.#options.databasePath !== ':memory:') { + await mkdir(dirname(this.#options.databasePath), { recursive: true }); + } + const database = new Database(this.#options.databasePath, { create: true, strict: true }); + database.exec('PRAGMA journal_mode = WAL;'); + const repositories = new SqliteRepositories({ database }); + await repositories.init(); + database.exec(` + CREATE TABLE IF NOT EXISTS coco_fault_lab_operations ( + operation_id TEXT PRIMARY KEY, + input_json TEXT NOT NULL, + coco_operation_id TEXT NOT NULL UNIQUE, + intent_hash TEXT NOT NULL, + phase TEXT NOT NULL, + evidence_code TEXT, + request_hash TEXT NOT NULL, + quote_hash TEXT NOT NULL, + output_plan_hash TEXT NOT NULL + ); + `); + + try { + this.#manager = await initializeCoco({ + repo: repositories, + seedGetter: async () => + createHash('sha512').update('cashu-fault-lab/coco/seed/v1\0').update(seed).digest(), + watchers: { + mintOperationWatcher: { disabled: true }, + proofStateWatcher: { disabled: true }, + meltQuoteWatcher: { disabled: true }, + }, + processors: { + mintOperationProcessor: { disabled: true }, + meltSettlementProcessor: { disabled: true }, + }, + }); + this.#database = database; + } catch (error) { + database.close(); + throw error; + } + } + + async #start(input: MintLifecycleInput): Promise { + const manager = this.#requiredManager(); + if (input.mint !== this.#options.mintUrl || input.unit !== this.#options.unit) { + throw new LifecycleHttpError( + 422, + 'LIFECYCLE_WALLET_IDENTITY_MISMATCH', + 'Lifecycle operation does not match the configured wallet', + ); + } + const intentHash = digest('cashu-fault-lab/coco/intent/v1', canonicalJson(input)); + const existing = this.#loadOperation(input.operationId); + if (existing !== undefined) { + if (existing.intent_hash !== intentHash) { + throw new LifecycleHttpError( + 409, + 'LIFECYCLE_OPERATION_ID_CONFLICT', + 'Lifecycle operation identity conflicts', + ); + } + return rowToView(existing); + } + + await manager.mint.addMint(input.mint, { trusted: true }); + const quote = await manager.quotes.mint.create({ + mintUrl: input.mint, + amount: Amount.from(input.amount), + method: 'bolt11', + unit: input.unit, + }); + const operation = await manager.ops.mint.prepare({ + quote: { mintUrl: quote.mintUrl, method: 'bolt11', quoteId: quote.quoteId }, + amount: Amount.from(input.amount), + }); + const requestHash = digest( + 'cashu-fault-lab/coco/mint-request/v1', + canonicalJson({ outputData: operation.outputData, quoteId: operation.quoteId }), + ); + const outputPlanHash = digest( + 'cashu-fault-lab/coco/output-plan/v1', + canonicalJson(operation.outputData), + ); + const quoteHash = digest('cashu-fault-lab/coco/quote/v1', operation.quoteId); + const row: StoredOperationRow = { + operation_id: input.operationId, + input_json: JSON.stringify(input), + coco_operation_id: operation.id, + intent_hash: intentHash, + phase: 'prepared', + evidence_code: null, + request_hash: requestHash, + quote_hash: quoteHash, + output_plan_hash: outputPlanHash, + }; + this.#insertOperation(row); + + return this.#execute(row, 'submitted', 'ambiguous'); + } + + async #resume(operationId: string): Promise { + const row = this.#requiredOperation(operationId); + if (isTerminalPhase(row.phase)) return rowToView(row); + return this.#execute(row, 'reconciling', 'reconciling'); + } + + async #execute( + row: StoredOperationRow, + activePhase: 'submitted' | 'reconciling', + fallbackPhase: 'ambiguous' | 'reconciling', + ): Promise { + const manager = this.#requiredManager(); + this.#updatePhase(row.operation_id, activePhase); + try { + const operation = await manager.ops.mint.execute(row.coco_operation_id); + return this.#recordCocoOutcome(row.operation_id, operation, fallbackPhase); + } catch { + const operation = await manager.ops.mint.get(row.coco_operation_id); + return this.#recordCocoOutcome(row.operation_id, operation, fallbackPhase); + } + } + + #recordCocoOutcome( + operationId: string, + operation: MintOperation | null, + fallbackPhase: 'ambiguous' | 'reconciling', + ): LifecycleOperationView { + if (operation?.state === 'finalized') { + this.#updatePhase(operationId, 'succeeded'); + } else if (operation?.state === 'failed') { + this.#updatePhase(operationId, 'failed_definitive', 'coco_mint_failed'); + } else { + this.#updatePhase(operationId, fallbackPhase); + } + return rowToView(this.#requiredOperation(operationId)); + } + + async #operation(operationId: string): Promise { + return rowToView(this.#requiredOperation(operationId)); + } + + async #wallet() { + const manager = this.#requiredManager(); + const balances = await manager.wallet.balances.byMintAndUnit({ + mintUrls: [this.#options.mintUrl], + units: [this.#options.unit], + }); + const balance = balances[this.#options.mintUrl]?.[this.#options.unit]; + return { + walletId: 'coco', + mint: this.#options.mintUrl, + unit: this.#options.unit, + balances: { + available: balance?.spendable.toNumber() ?? 0, + reserved: balance?.reserved.toNumber() ?? 0, + recoverable: 0, + }, + proofs: [], + } as const; + } + + #requiredManager(): Manager { + if (this.#manager === undefined) { + throw new LifecycleHttpError(409, 'RESET_REQUIRED', 'Reset the lifecycle adapter first'); + } + return this.#manager; + } + + #requiredDatabase(): Database { + if (this.#database === undefined) { + throw new LifecycleHttpError(409, 'RESET_REQUIRED', 'Reset the lifecycle adapter first'); + } + return this.#database; + } + + #loadOperation(operationId: string): StoredOperationRow | undefined { + const value = this.#requiredDatabase() + .query('SELECT * FROM coco_fault_lab_operations WHERE operation_id = ?') + .get(operationId); + return value === null ? undefined : (value as StoredOperationRow); + } + + #requiredOperation(operationId: string): StoredOperationRow { + const operation = this.#loadOperation(operationId); + if (operation === undefined) { + throw new LifecycleHttpError( + 404, + 'LIFECYCLE_OPERATION_NOT_FOUND', + 'Lifecycle operation was not found', + ); + } + return operation; + } + + #insertOperation(row: StoredOperationRow): void { + this.#requiredDatabase() + .query( + `INSERT INTO coco_fault_lab_operations + (operation_id, input_json, coco_operation_id, intent_hash, phase, evidence_code, + request_hash, quote_hash, output_plan_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + row.operation_id, + row.input_json, + row.coco_operation_id, + row.intent_hash, + row.phase, + row.evidence_code, + row.request_hash, + row.quote_hash, + row.output_plan_hash, + ); + } + + #updatePhase(operationId: string, phase: LifecyclePhase, evidenceCode?: string): void { + this.#requiredDatabase() + .query( + `UPDATE coco_fault_lab_operations + SET phase = ?, evidence_code = ? + WHERE operation_id = ?`, + ) + .run(phase, evidenceCode ?? null, operationId); + } + + async #closeSession(): Promise { + const manager = this.#manager; + const database = this.#database; + this.#manager = undefined; + this.#database = undefined; + try { + await manager?.dispose(); + } finally { + database?.close(); + } + } +} + +export async function startCocoLifecycleAdapter( + options: CocoLifecycleAdapterOptions, +): Promise { + const lifecycle = new CocoLifecycleAdapter(options); + const server = Bun.serve({ + hostname: options.host ?? '127.0.0.1', + port: options.port ?? 4103, + fetch: (request) => lifecycle.fetch(request), + }); + const publicHost = options.host === '0.0.0.0' ? '127.0.0.1' : (options.host ?? '127.0.0.1'); + return { + url: `http://${publicHost}:${server.port}`, + async stop() { + await server.stop(true); + await lifecycle.stop(); + }, + }; +} + +function parseMintInput(value: unknown): MintLifecycleInput { + if ( + !isRecord(value) || + typeof value.operationId !== 'string' || + !/^[A-Za-z0-9_-]{21}[AQgw]$/u.test(value.operationId) || + value.kind !== 'mint' || + typeof value.mint !== 'string' || + typeof value.unit !== 'string' || + !Number.isSafeInteger(value.amount) || + (value.amount as number) < 1 || + value.method !== 'bolt11' + ) { + throw new LifecycleHttpError(422, 'SCHEMA_VALIDATION', 'Mint operation input is invalid'); + } + return value as unknown as MintLifecycleInput; +} + +function rowToView(row: StoredOperationRow): LifecycleOperationView { + const input = JSON.parse(row.input_json) as MintLifecycleInput; + return { + operationId: input.operationId, + kind: input.kind, + mint: input.mint, + unit: input.unit, + intentHash: row.intent_hash, + phase: row.phase, + ...(row.evidence_code === null ? {} : { evidenceCode: row.evidence_code }), + amount: input.amount, + requestHash: row.request_hash, + quoteHash: row.quote_hash, + outputPlanHash: row.output_plan_hash, + }; +} + +function isTerminalPhase(phase: LifecyclePhase): boolean { + return phase === 'succeeded' || phase === 'failed_definitive' || phase === 'recovery_blocked'; +} + +function digest(domain: string, value: string): string { + return createHash('sha256').update(domain).update('\0').update(value).digest('hex'); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonical(value)); +} + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, canonical(entry)]), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isCanonicalMintUrl(value: string): boolean { + try { + const url = new URL(value); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username !== '' || + url.password !== '' || + url.search !== '' || + url.hash !== '' + ) { + return false; + } + const path = url.pathname === '/' ? '' : url.pathname.replace(/\/$/u, ''); + return `${url.protocol}//${url.host}${path}` === value; + } catch { + return false; + } +} + +async function requestJson(request: Request): Promise { + try { + return await request.json(); + } catch { + throw new LifecycleHttpError(400, 'INVALID_JSON', 'Request body must be valid JSON'); + } +} + +function json(value: unknown, status = 200): Response { + return Response.json(value, { status }); +} + +async function resetDatabaseFiles(path: string): Promise { + if (path === ':memory:') return; + await Promise.all([ + rm(path, { force: true }), + rm(`${path}-shm`, { force: true }), + rm(`${path}-wal`, { force: true }), + ]); +} + +function requiredEnvironment(name: string): string { + const value = process.env[name]; + if (value === undefined || value.length === 0) throw new Error(`${name} is required`); + return value; +} + +function environmentPort(value: string | undefined): number { + const parsed = value === undefined ? 4103 : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error('COCO_FAULT_LAB_PORT must be an integer from 1 to 65,535'); + } + return parsed; +} + +if (import.meta.main) { + const running = await startCocoLifecycleAdapter({ + controlToken: requiredEnvironment('COCO_FAULT_LAB_CONTROL_TOKEN'), + databasePath: requiredEnvironment('COCO_FAULT_LAB_DATABASE'), + host: process.env.COCO_FAULT_LAB_HOST ?? '127.0.0.1', + implementationVersion: process.env.COCO_FAULT_LAB_VERSION, + mintId: process.env.COCO_FAULT_LAB_MINT_ID ?? 'mintd-local', + mintImplementation: process.env.COCO_FAULT_LAB_MINT_IMPLEMENTATION ?? 'mintd', + mintVersion: process.env.COCO_FAULT_LAB_MINT_VERSION, + mintUrl: requiredEnvironment('COCO_FAULT_LAB_MINT_URL'), + port: environmentPort(process.env.COCO_FAULT_LAB_PORT), + unit: process.env.COCO_FAULT_LAB_UNIT ?? 'sat', + }); + console.log(`Coco Fault Lab lifecycle adapter listening on ${running.url}`); + + let closing = false; + const close = async () => { + if (closing) return; + closing = true; + await running.stop(); + process.exit(0); + }; + process.once('SIGINT', () => void close()); + process.once('SIGTERM', () => void close()); +} diff --git a/test/fault-lab/compose.yml b/test/fault-lab/compose.yml new file mode 100644 index 000000000..2562204da --- /dev/null +++ b/test/fault-lab/compose.yml @@ -0,0 +1,50 @@ +name: coco-cashu-fault-lab + +services: + mintd: + image: cashubtc/mintd:0.17.3@sha256:39da6a421d0a53a5e7541ea1c7abca3d46265c7dc7980f7fb4c637f089e0b547 + command: ['cdk-mintd', '--work-dir', '/var/lib/cdk-mintd'] + environment: + CDK_MINTD_CACHE_BACKEND: memory + CDK_MINTD_DATABASE: sqlite + CDK_MINTD_LISTEN_HOST: 0.0.0.0 + CDK_MINTD_LISTEN_PORT: 8085 + CDK_MINTD_LN_BACKEND: fakewallet + CDK_MINTD_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + CDK_MINTD_URL: http://mintd:8085 + healthcheck: + test: + ['CMD', 'wget', '--quiet', '--output-document=/dev/null', 'http://127.0.0.1:8085/v1/info'] + interval: 2s + timeout: 2s + retries: 60 + volumes: + - mintd-state:/var/lib/cdk-mintd + + fault-gateway: + image: ghcr.io/gautambytes/cashu-fault-lab-node-wallets:0.2.0@sha256:fd973eef705c6d99c683dc1f6c303e121fcfa222106cdbcb47e7fcb29dce66ea + command: ['node', 'apps/http-fault-gateway/dist/bin.js'] + environment: + CFL_HTTP_FAULT_GATEWAY_CONTROL_TOKEN: ${CFL_HTTP_FAULT_GATEWAY_TOKEN:?set CFL_HTTP_FAULT_GATEWAY_TOKEN} + CFL_HTTP_FAULT_GATEWAY_DOWNSTREAM: http://mintd:8085 + CFL_HTTP_FAULT_GATEWAY_HOST: 0.0.0.0 + CFL_HTTP_FAULT_GATEWAY_PORT: 4300 + depends_on: + mintd: + condition: service_healthy + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:4300/__faults/v1/evidence',{headers:{authorization:'Bearer '+process.env.CFL_HTTP_FAULT_GATEWAY_CONTROL_TOKEN}}).then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 60 + ports: + - '127.0.0.1:${COCO_FAULT_LAB_GATEWAY_PORT:-4300}:4300' + +volumes: + mintd-state: diff --git a/test/fault-lab/run-mint-response-lost.sh b/test/fault-lab/run-mint-response-lost.sh new file mode 100755 index 000000000..e15739de7 --- /dev/null +++ b/test/fault-lab/run-mint-response-lost.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/compose.yml" +COMPOSE_PROJECT="${COCO_FAULT_LAB_COMPOSE_PROJECT:-coco-cashu-fault-lab}" +ADAPTER_PORT="${COCO_FAULT_LAB_ADAPTER_PORT:-4103}" +GATEWAY_PORT="${COCO_FAULT_LAB_GATEWAY_PORT:-4300}" +ADAPTER_URL="http://127.0.0.1:${ADAPTER_PORT}" +GATEWAY_URL="http://127.0.0.1:${GATEWAY_PORT}" +ADAPTER_TOKEN="${COCO_FAULT_LAB_CONTROL_TOKEN:-coco-fault-lab-local-adapter}" +GATEWAY_TOKEN="${CFL_HTTP_FAULT_GATEWAY_TOKEN:-coco-fault-lab-local-gateway}" +FAULT_LAB_VERSION="0.2.0" +SEED="wallet-lifecycle-v1:mint-response-lost" +TEMPORARY_DIRECTORY="$(mktemp -d)" +DATABASE_PATH="$TEMPORARY_DIRECTORY/coco.sqlite" +ADAPTER_LOG="$TEMPORARY_DIRECTORY/adapter.log" +REPORT_PATH="${COCO_FAULT_LAB_REPORT:-$TEMPORARY_DIRECTORY/mint-response-lost.json}" +ADAPTER_PID='' + +compose() { + CFL_HTTP_FAULT_GATEWAY_TOKEN="$GATEWAY_TOKEN" \ + COCO_FAULT_LAB_GATEWAY_PORT="$GATEWAY_PORT" \ + docker compose --project-name "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" "$@" +} + +cleanup() { + local result=$? + trap - EXIT INT TERM + if [ -n "$ADAPTER_PID" ]; then + kill "$ADAPTER_PID" 2>/dev/null || true + wait "$ADAPTER_PID" 2>/dev/null || true + fi + if [ "$result" -ne 0 ]; then + if [ -f "$ADAPTER_LOG" ]; then + echo 'Coco Fault Lab adapter log:' >&2 + sed -n '1,240p' "$ADAPTER_LOG" >&2 + fi + compose logs --no-color >&2 2>/dev/null || true + fi + compose down --volumes --remove-orphans >/dev/null 2>&1 || true + rm -rf "$TEMPORARY_DIRECTORY" + exit "$result" +} + +trap cleanup EXIT INT TERM + +for command in bun curl docker node npx; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "Required command is unavailable: $command" >&2 + exit 1 + fi +done + +NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])')" +if [ "$NODE_MAJOR" -ne 24 ]; then + echo "cashu-fault-lab@${FAULT_LAB_VERSION} requires Node.js 24; found $(node --version)" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo 'Docker is installed but its daemon is unavailable' >&2 + exit 1 +fi + +mkdir -p "$(dirname "$REPORT_PATH")" + +cd "$PROJECT_ROOT" +bun run build:fault-lab + +compose up --detach --wait + +COCO_FAULT_LAB_CONTROL_TOKEN="$ADAPTER_TOKEN" \ +COCO_FAULT_LAB_DATABASE="$DATABASE_PATH" \ +COCO_FAULT_LAB_HOST='127.0.0.1' \ +COCO_FAULT_LAB_MINT_ID='mintd-local' \ +COCO_FAULT_LAB_MINT_IMPLEMENTATION='mintd' \ +COCO_FAULT_LAB_MINT_VERSION='0.17.3' \ +COCO_FAULT_LAB_MINT_URL="$GATEWAY_URL" \ +COCO_FAULT_LAB_PORT="$ADAPTER_PORT" \ +COCO_FAULT_LAB_UNIT='sat' \ +bun run test/fault-lab/adapter.ts >"$ADAPTER_LOG" 2>&1 & +ADAPTER_PID=$! + +for attempt in $(seq 1 30); do + if curl --fail --silent \ + --header "Authorization: Bearer $ADAPTER_TOKEN" \ + "$ADAPTER_URL/v1/lifecycle/capabilities" >/dev/null; then + break + fi + if ! kill -0 "$ADAPTER_PID" 2>/dev/null; then + echo 'Coco Fault Lab adapter exited before becoming ready' >&2 + exit 1 + fi + if [ "$attempt" -eq 30 ]; then + echo 'Coco Fault Lab adapter did not become ready' >&2 + exit 1 + fi + sleep 1 +done + +CFL_HTTP_FAULT_GATEWAY_TOKEN="$GATEWAY_TOKEN" \ +CFL_HTTP_FAULT_GATEWAY_URL="$GATEWAY_URL" \ +CFL_LIFECYCLE_COCO_TOKEN="$ADAPTER_TOKEN" \ +CFL_LIFECYCLE_COCO_URL="$ADAPTER_URL" \ +npx --yes "cashu-fault-lab@${FAULT_LAB_VERSION}" lifecycle run mint-response-lost \ + --adapter coco \ + --mint mintd-local \ + --mint-url "$GATEWAY_URL" \ + --seed "$SEED" \ + --format json \ + --output "$REPORT_PATH" + +if [ -n "${COCO_FAULT_LAB_REPORT:-}" ]; then + echo "Cashu Fault Lab mint-response-lost passed. Report: $REPORT_PATH" +else + echo 'Cashu Fault Lab mint-response-lost passed.' +fi diff --git a/test/fault-lab/tsconfig.json b/test/fault-lab/tsconfig.json new file mode 100644 index 000000000..516174f44 --- /dev/null +++ b/test/fault-lab/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "types": ["bun"], + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "baseUrl": "../..", + "paths": { + "@cashu/coco-core": ["packages/core/index.ts"], + "@cashu/coco-core/adapter": ["packages/core/adapter.ts"], + "@cashu/coco-sql-storage": ["packages/sql-storage/src/index.ts"], + "@cashu/coco-sqlite-bun": ["packages/sqlite-bun/src/index.ts"], + "@core/*": ["packages/core/*"] + } + }, + "include": ["./**/*.ts"] +} From c9acc894df68e4f8d9326715a90fe0115143e62e Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 5 Aug 2026 19:29:22 +0000 Subject: [PATCH 2/3] test: wait for funded mint quote in fault lane --- test/fault-lab/adapter.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/test/fault-lab/adapter.ts b/test/fault-lab/adapter.ts index 9365019be..b6639cf1e 100644 --- a/test/fault-lab/adapter.ts +++ b/test/fault-lab/adapter.ts @@ -272,6 +272,7 @@ class CocoLifecycleAdapter { method: 'bolt11', unit: input.unit, }); + await this.#waitForMintQuotePayment(quote.mintUrl, quote.quoteId, input.amount); const operation = await manager.ops.mint.prepare({ quote: { mintUrl: quote.mintUrl, method: 'bolt11', quoteId: quote.quoteId }, amount: Amount.from(input.amount), @@ -317,12 +318,27 @@ class CocoLifecycleAdapter { try { const operation = await manager.ops.mint.execute(row.coco_operation_id); return this.#recordCocoOutcome(row.operation_id, operation, fallbackPhase); - } catch { + } catch (error) { const operation = await manager.ops.mint.get(row.coco_operation_id); + console.warn('Coco Fault Lab mint execution requires recovery', { + operationId: row.operation_id, + state: operation?.state ?? 'missing', + error: error instanceof Error ? error.message : String(error), + }); return this.#recordCocoOutcome(row.operation_id, operation, fallbackPhase); } } + async #waitForMintQuotePayment(mintUrl: string, quoteId: string, amount: number): Promise { + const required = Amount.from(amount); + for (let attempt = 0; attempt < 40; attempt += 1) { + const quote = await this.#requiredManager().quotes.mint.refresh({ mintUrl, quoteId }); + if (quote.amountPaid.greaterThanOrEqual(required)) return; + await Bun.sleep(250); + } + throw new Error(`Mint quote ${quoteId} was not paid by the test fixture`); + } + #recordCocoOutcome( operationId: string, operation: MintOperation | null, From 637dd7ca9419b1bbacddf36062bbbe15291b1092 Mon Sep 17 00:00:00 2001 From: Egge Date: Wed, 5 Aug 2026 19:40:57 +0000 Subject: [PATCH 3/3] test(core): cover websocket error logging --- .../test/unit/WsConnectionManager.test.ts | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/core/test/unit/WsConnectionManager.test.ts b/packages/core/test/unit/WsConnectionManager.test.ts index 992b3f450..699de3d1b 100644 --- a/packages/core/test/unit/WsConnectionManager.test.ts +++ b/packages/core/test/unit/WsConnectionManager.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, mock, beforeEach } from 'bun:test'; import { WsConnectionManager, type WebSocketLike } from '../../infra/WsConnectionManager'; -import { NullLogger } from '../../logging'; +import { NullLogger, type Logger } from '../../logging'; class MockWebSocket implements WebSocketLike { private listeners: Map void>> = new Map(); @@ -61,6 +61,15 @@ class MockWebSocket implements WebSocketLike { } } } + + triggerError(error: Error): void { + const errorListeners = this.listeners.get('error'); + if (errorListeners) { + for (const listener of errorListeners) { + listener(error); + } + } + } } describe('WsConnectionManager pause/resume', () => { @@ -88,6 +97,24 @@ describe('WsConnectionManager pause/resume', () => { expect(mockSocket.closeReason).toBe('Paused'); }); + it('should log socket errors with mint context', () => { + const error = mock(() => {}); + const logger: Logger = { + error, + warn: () => {}, + info: () => {}, + debug: () => {}, + }; + const mintUrl = 'https://mint.example.com'; + const socketError = new Error('connection failed'); + wsManager = new WsConnectionManager(wsFactory, logger); + + wsManager.on(mintUrl, 'open', () => {}); + mockSocket.triggerError(socketError); + + expect(error).toHaveBeenCalledWith('WS error', { mintUrl, err: socketError }); + }); + it('should clear reconnect timers when paused', async () => { const mintUrl = 'https://mint.example.com'; wsManager.on(mintUrl, 'open', () => {});