Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/cashu-fault-lab.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: cashu-fault-lab

on:
workflow_dispatch:
pull_request:
paths:
- 'test/fault-lab/**'
- '.github/workflows/cashu-fault-lab.yml'
Comment on lines +5 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the recovery lane when core recovery code changes

For pull requests that modify only packages/core/**, including MintOperationService or the NUT-09 recovery handlers this scenario is intended to validate, this workflow is skipped because its path filter includes only the Fault Lab files and its own YAML. Consequently, the external response-loss regression test does not protect the production recovery changes it targets; include the relevant core and storage package paths in this trigger.

Useful? React with 👍 / 👎.


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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 28 additions & 1 deletion packages/core/test/unit/WsConnectionManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<(event: any) => void>> = new Map();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {});
Expand Down
33 changes: 33 additions & 0 deletions test/fault-lab/README.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 88 additions & 0 deletions test/fault-lab/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
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();
}
Loading
Loading