routstrd 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -15
- package/SECURITY.md +0 -1
- package/SKILL.md +267 -52
- package/dist/daemon/index.js +93747 -70861
- package/dist/index.js +65958 -42561
- package/package.json +3 -2
- package/src/cli.ts +358 -23
- package/src/daemon/http/index.ts +323 -38
- package/src/daemon/http/request-body.ts +67 -0
- package/src/daemon/http/request-path.ts +39 -0
- package/src/daemon/index.ts +1 -1
- package/src/daemon/models.ts +46 -11
- package/src/daemon/wallet/auto-refill.ts +20 -8
- package/src/daemon/wallet/cleanup.ts +16 -2
- package/src/daemon/wallet/coco-client.ts +941 -115
- package/src/daemon/wallet/index.ts +168 -50
- package/src/daemon/wallet/mint-quote-recovery.ts +119 -0
- package/src/daemon/wallet/recovery-probe.ts +312 -0
- package/src/daemon/wallet/recovery-work.ts +45 -0
- package/src/daemon/wallet/testing/fake-mint.ts +347 -0
- package/src/daemon/wallet/trusted-mints.ts +87 -0
- package/src/daemon/wallet/wallet-client.ts +206 -0
- package/src/integrations/pi.ts +189 -34
- package/src/integrations/registry.ts +14 -0
- package/src/tui/usage/render.ts +161 -22
- package/src/utils/config.ts +3 -2
- package/src/utils/cooldowns.ts +134 -0
- package/src/utils/daemon-client.ts +53 -9
- package/src/utils/history.ts +9 -0
- package/src/utils/with-timeout.ts +21 -0
- package/src/TUI refactor.md +0 -113
- package/src/daemon/wallet/cocod-client.ts +0 -505
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint reachability probing and targeted (per-operation) wallet recovery.
|
|
3
|
+
*
|
|
4
|
+
* Coco's global recovery sweeps walk every non-terminal operation one at a
|
|
5
|
+
* time; each operation at an unreachable mint costs a full network timeout.
|
|
6
|
+
* This module probes every mint that has stuck operations once, in parallel,
|
|
7
|
+
* and drives recovery per operation only for mints that answer — so one dead
|
|
8
|
+
* mint costs a single short probe instead of N sequential timeouts, and its
|
|
9
|
+
* operations stay parked (exactly as coco's "will retry later" path leaves
|
|
10
|
+
* them) until an explicit mid-session recovery (see recoverStuckOperations
|
|
11
|
+
* in coco-client.ts) or a later startup finds the mint reachable.
|
|
12
|
+
*/
|
|
13
|
+
import { normalizeMintUrl, type Manager } from "@cashu/coco-core";
|
|
14
|
+
import { recoveryKey, trackRecovery, waitForRecoveryWork, RecoveryWaitTimeout, type RecoveryWork } from "./recovery-work";
|
|
15
|
+
import { logger } from "../../utils/logger";
|
|
16
|
+
|
|
17
|
+
/** Short probe: a mint that cannot answer /v1/info in 2s slows every op. */
|
|
18
|
+
export const MINT_PROBE_TIMEOUT_MS = 2_000;
|
|
19
|
+
|
|
20
|
+
type OpsApi = Manager["ops"];
|
|
21
|
+
|
|
22
|
+
/** Structural subset of the ops APIs used to enumerate and recover operations. */
|
|
23
|
+
export interface StuckOperationSource {
|
|
24
|
+
send: Pick<OpsApi["send"], "listInFlight" | "refresh" | "diagnostics" | "get">;
|
|
25
|
+
melt: Pick<OpsApi["melt"], "listInFlight" | "refresh" | "diagnostics">;
|
|
26
|
+
receive: Pick<OpsApi["receive"], "listInFlight" | "refresh" | "diagnostics">;
|
|
27
|
+
mint: Pick<OpsApi["mint"], "listInFlight" | "refresh" | "diagnostics">;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type StuckOperationKind = "send" | "melt" | "receive" | "mint";
|
|
31
|
+
|
|
32
|
+
export interface StuckOperation {
|
|
33
|
+
kind: StuckOperationKind;
|
|
34
|
+
id: string;
|
|
35
|
+
/** Normalized mint URL. */
|
|
36
|
+
mintUrl: string;
|
|
37
|
+
state: string;
|
|
38
|
+
/** The operation object as returned by the API (needed by service-level recovery). */
|
|
39
|
+
raw: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The per-operation send recovery entry points coco keeps private. Send is the
|
|
44
|
+
* only operation family whose public `refresh()` does not cover `executing`
|
|
45
|
+
* operations; routstrd already reaches into coco internals the same way for
|
|
46
|
+
* `mintOperationService` (see coco-client.ts).
|
|
47
|
+
*/
|
|
48
|
+
export interface SendRecoveryService {
|
|
49
|
+
recoverExecutingOperation(op: unknown): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* coco's per-operation lock. It is fail-fast: acquiring an id a live
|
|
52
|
+
* execute/finalize/recover already holds throws OperationInProgressError
|
|
53
|
+
* instead of waiting. Holding it across the state re-read and the drive
|
|
54
|
+
* makes executing-send recovery atomic against a live execute — the same
|
|
55
|
+
* pattern reopenFailedMintOperation uses for mint operations
|
|
56
|
+
* (see coco-client.ts).
|
|
57
|
+
*/
|
|
58
|
+
acquireOperationLock(operationId: string): Promise<() => void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RecoveryRunResult {
|
|
62
|
+
/** Operations for which recovery was attempted (not necessarily completed). */
|
|
63
|
+
attempted: number;
|
|
64
|
+
/** Timed-out waits, also included in attempted; underlying work remains tracked. */
|
|
65
|
+
timedOut: number;
|
|
66
|
+
/** Locked operations or unfinished work from another pass; retry later. */
|
|
67
|
+
busy: number;
|
|
68
|
+
/** Operations skipped for unreachable mints, shutdown, or pass budget exhaustion. */
|
|
69
|
+
skipped: number;
|
|
70
|
+
/** Attempts that threw a non-busy, non-timeout error. */
|
|
71
|
+
failed: number;
|
|
72
|
+
/** Unreachable mint URL -> number of operations skipped there. */
|
|
73
|
+
skippedMints: Map<string, number>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TargetedRecoveryOptions {
|
|
77
|
+
outstanding?: RecoveryWork;
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
deadlineMs?: number;
|
|
80
|
+
shouldStop?: () => boolean;
|
|
81
|
+
probeTimeoutMs?: number;
|
|
82
|
+
fetchImpl?: typeof fetch;
|
|
83
|
+
/** Operation families to recover. Defaults to all four. */
|
|
84
|
+
kinds?: StuckOperationKind[];
|
|
85
|
+
/** Pre-collected operations (e.g. from startup gating); default: enumerate now. */
|
|
86
|
+
stuckOperations?: StuckOperation[];
|
|
87
|
+
/** Pre-probed unreachable mints; default: probe now. */
|
|
88
|
+
unreachableMints?: Set<string>;
|
|
89
|
+
/** Called once per unreachable mint with the number of skipped operations. */
|
|
90
|
+
onSkippedMint?: (mintUrl: string, opCount: number) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function asStuckOperations(
|
|
94
|
+
kind: StuckOperationKind,
|
|
95
|
+
ops: Array<{ id: string; mintUrl: string; state: string }>,
|
|
96
|
+
): StuckOperation[] {
|
|
97
|
+
const stuck: StuckOperation[] = [];
|
|
98
|
+
for (const op of ops) {
|
|
99
|
+
try {
|
|
100
|
+
stuck.push({
|
|
101
|
+
kind,
|
|
102
|
+
id: op.id,
|
|
103
|
+
mintUrl: normalizeMintUrl(op.mintUrl),
|
|
104
|
+
state: op.state,
|
|
105
|
+
raw: op,
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
// Preserve malformed persisted URLs; the probe will classify them as
|
|
109
|
+
// unreachable rather than attempting recovery against an invalid URL.
|
|
110
|
+
stuck.push({ kind, id: op.id, mintUrl: op.mintUrl, state: op.state, raw: op });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return stuck;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Enumerate every non-terminal operation across all four operation families.
|
|
118
|
+
* Returns [] quickly when nothing is stuck, which is the common case.
|
|
119
|
+
*/
|
|
120
|
+
export async function collectStuckOperations(
|
|
121
|
+
source: StuckOperationSource,
|
|
122
|
+
): Promise<StuckOperation[]> {
|
|
123
|
+
const [sends, melts, receives, mints] = await Promise.all([
|
|
124
|
+
source.send.listInFlight(),
|
|
125
|
+
source.melt.listInFlight(),
|
|
126
|
+
source.receive.listInFlight(),
|
|
127
|
+
source.mint.listInFlight(),
|
|
128
|
+
]);
|
|
129
|
+
return [
|
|
130
|
+
...asStuckOperations("send", sends),
|
|
131
|
+
...asStuckOperations("melt", melts),
|
|
132
|
+
...asStuckOperations("receive", receives),
|
|
133
|
+
...asStuckOperations("mint", mints),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Probe each mint once, in parallel, and return the set of mint URLs that did
|
|
139
|
+
* not answer `GET /v1/info` in time. A mint that answers with an HTTP error is
|
|
140
|
+
* still "reachable" — its operations will fail with a real mint error instead
|
|
141
|
+
* of a network timeout, which is the information recovery needs.
|
|
142
|
+
*/
|
|
143
|
+
export async function probeMintReachability(
|
|
144
|
+
mintUrls: string[],
|
|
145
|
+
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
|
|
146
|
+
): Promise<Set<string>> {
|
|
147
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
148
|
+
const timeoutMs = options.timeoutMs ?? MINT_PROBE_TIMEOUT_MS;
|
|
149
|
+
const unreachable = new Set<string>();
|
|
150
|
+
|
|
151
|
+
await Promise.all(
|
|
152
|
+
mintUrls.map(async (mintUrl) => {
|
|
153
|
+
try {
|
|
154
|
+
await fetcher(`${normalizeMintUrl(mintUrl)}/v1/info`, {
|
|
155
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
logger.debug("Mint did not answer recovery probe", {
|
|
159
|
+
mintUrl,
|
|
160
|
+
error: error instanceof Error ? error.message : String(error),
|
|
161
|
+
});
|
|
162
|
+
unreachable.add(mintUrl);
|
|
163
|
+
}
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return unreachable;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function recoverStuckOperation(
|
|
171
|
+
source: StuckOperationSource,
|
|
172
|
+
sendService: SendRecoveryService,
|
|
173
|
+
op: StuckOperation,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
switch (op.kind) {
|
|
176
|
+
case "send":
|
|
177
|
+
if (op.state === "pending") {
|
|
178
|
+
// Public API: actively re-checks the proofs with the mint.
|
|
179
|
+
await source.send.refresh(op.id);
|
|
180
|
+
} else if (op.state === "executing") {
|
|
181
|
+
// Fail closed: the lock and the drive are private coco internals
|
|
182
|
+
// (written against coco-core 1.0.1), so a coco bump that renames them
|
|
183
|
+
// must fail this operation loudly instead of corrupting it.
|
|
184
|
+
if (
|
|
185
|
+
typeof sendService.acquireOperationLock !== "function" ||
|
|
186
|
+
typeof sendService.recoverExecutingOperation !== "function"
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"coco sendOperationService recovery internals are unavailable; refusing to recover an executing send",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// recoverExecutingOperation takes no lock and does no state re-read,
|
|
193
|
+
// so take coco's fail-fast per-operation lock first (throws
|
|
194
|
+
// OperationInProgressError when a live execute holds the operation —
|
|
195
|
+
// the driver counts that as busy, not failed) and re-read the state
|
|
196
|
+
// under it: the snapshot op must still be executing before we drive.
|
|
197
|
+
const release = await sendService.acquireOperationLock(op.id);
|
|
198
|
+
try {
|
|
199
|
+
const latest = await source.send.get(op.id);
|
|
200
|
+
if (latest?.state === "executing") {
|
|
201
|
+
await sendService.recoverExecutingOperation(latest);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
release();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// prepared / rolling_back: the global sweep only warns; nothing to do.
|
|
208
|
+
return;
|
|
209
|
+
case "melt":
|
|
210
|
+
// refresh() covers both pending and executing melt operations.
|
|
211
|
+
await source.melt.refresh(op.id);
|
|
212
|
+
return;
|
|
213
|
+
case "receive":
|
|
214
|
+
// refresh() actively recovers executing receive operations.
|
|
215
|
+
await source.receive.refresh(op.id);
|
|
216
|
+
return;
|
|
217
|
+
case "mint":
|
|
218
|
+
// refresh() covers both pending and executing mint operations.
|
|
219
|
+
await source.mint.refresh(op.id);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Recover every stuck operation whose mint answers a reachability probe,
|
|
226
|
+
* skipping operations at unreachable mints. Operations are recovered
|
|
227
|
+
* sequentially per mint (matching the global sweep's ordering guarantees);
|
|
228
|
+
* skipped operations are left untouched for a later pass, which is exactly
|
|
229
|
+
* what coco's own "Could not reach mint for recovery, will retry later" path
|
|
230
|
+
* does with them.
|
|
231
|
+
*/
|
|
232
|
+
export async function runTargetedRecovery(
|
|
233
|
+
source: StuckOperationSource,
|
|
234
|
+
sendService: SendRecoveryService,
|
|
235
|
+
options: TargetedRecoveryOptions = {},
|
|
236
|
+
): Promise<RecoveryRunResult> {
|
|
237
|
+
const result: RecoveryRunResult = {
|
|
238
|
+
attempted: 0,
|
|
239
|
+
timedOut: 0,
|
|
240
|
+
busy: 0,
|
|
241
|
+
skipped: 0,
|
|
242
|
+
failed: 0,
|
|
243
|
+
skippedMints: new Map(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
247
|
+
const deadlineMs = options.deadlineMs ?? 60_000;
|
|
248
|
+
if (![timeoutMs, deadlineMs].every(n => Number.isFinite(n) && n > 0)) {
|
|
249
|
+
throw new Error("Recovery budgets must be positive finite numbers");
|
|
250
|
+
}
|
|
251
|
+
const deadline = Date.now() + deadlineMs;
|
|
252
|
+
const outstanding = options.outstanding ?? new Map();
|
|
253
|
+
const kinds = options.kinds ?? ["send", "melt", "receive", "mint"];
|
|
254
|
+
const stuck = (options.stuckOperations ?? (await collectStuckOperations(source))).filter(
|
|
255
|
+
(op) => kinds.includes(op.kind),
|
|
256
|
+
);
|
|
257
|
+
if (stuck.length === 0) return result;
|
|
258
|
+
|
|
259
|
+
const unreachable =
|
|
260
|
+
options.unreachableMints ??
|
|
261
|
+
(await probeMintReachability([...new Set(stuck.map((op) => op.mintUrl))], {
|
|
262
|
+
timeoutMs: options.probeTimeoutMs,
|
|
263
|
+
fetchImpl: options.fetchImpl,
|
|
264
|
+
}));
|
|
265
|
+
|
|
266
|
+
for (const mintUrl of unreachable) {
|
|
267
|
+
const count = stuck.filter((op) => op.mintUrl === mintUrl).length;
|
|
268
|
+
result.skippedMints.set(mintUrl, count);
|
|
269
|
+
result.skipped += count;
|
|
270
|
+
options.onSkippedMint?.(mintUrl, count);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const op of stuck) {
|
|
274
|
+
if (unreachable.has(op.mintUrl)) continue;
|
|
275
|
+
if (options.shouldStop?.() || Date.now() >= deadline) { result.skipped++; continue; }
|
|
276
|
+
const key = recoveryKey(op.kind, op.id);
|
|
277
|
+
if (outstanding.has(key)) { result.busy++; continue; }
|
|
278
|
+
// Cheap pre-filter for live operations; the lock inside
|
|
279
|
+
// recoverStuckOperation is what actually makes the drive atomic.
|
|
280
|
+
if (source[op.kind].diagnostics.isLocked(op.id)) {
|
|
281
|
+
result.busy++;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (op.kind === "send" && !["pending", "executing"].includes(op.state)) continue;
|
|
285
|
+
result.attempted++;
|
|
286
|
+
try {
|
|
287
|
+
const work = trackRecovery(outstanding, key, recoverStuckOperation(source, sendService, op));
|
|
288
|
+
await waitForRecoveryWork(work, Math.min(timeoutMs, Math.max(1, deadline - Date.now())));
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error instanceof RecoveryWaitTimeout) { result.timedOut++; continue; }
|
|
291
|
+
// A live execute grabbed the operation between the isLocked pre-filter
|
|
292
|
+
// and the lock acquisition: busy, not failed — leave it for a later
|
|
293
|
+
// pass. Name-matched like runMintQuoteRecovery does, because the error
|
|
294
|
+
// crosses a package boundary.
|
|
295
|
+
if (error instanceof Error && error.name === "OperationInProgressError") {
|
|
296
|
+
result.busy++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// Same semantics as coco's tryRecover*: leave the operation for the
|
|
300
|
+
// next pass. A reachable mint can still reject a specific operation.
|
|
301
|
+
result.failed++;
|
|
302
|
+
logger.warn("Targeted operation recovery did not complete", {
|
|
303
|
+
kind: op.kind,
|
|
304
|
+
operationId: op.id,
|
|
305
|
+
mintUrl: op.mintUrl,
|
|
306
|
+
error: error instanceof Error ? error.message : String(error),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A timed-out wait is not cancellation: retain work until it actually settles. */
|
|
2
|
+
export type RecoveryWork = Map<string, Promise<unknown>>;
|
|
3
|
+
export const recoveryKey = (kind: string, id: string): string => `${kind}:${id}`;
|
|
4
|
+
|
|
5
|
+
export function trackRecovery<T>(work: RecoveryWork, key: string, promise: Promise<T>): Promise<T> {
|
|
6
|
+
work.set(key, promise);
|
|
7
|
+
const clear = () => { if (work.get(key) === promise) work.delete(key); };
|
|
8
|
+
void promise.then(clear, clear);
|
|
9
|
+
return promise;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class RecoveryWaitTimeout extends Error {
|
|
13
|
+
constructor() { super("Timed out waiting for recovery; underlying work is still tracked"); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function waitForRecoveryWork<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
|
20
|
+
timer = setTimeout(() => reject(new RecoveryWaitTimeout()), timeoutMs);
|
|
21
|
+
})]);
|
|
22
|
+
} finally {
|
|
23
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drain actual work, including work registered while an earlier task settles. */
|
|
28
|
+
export async function drainRecoveryWork(work: RecoveryWork): Promise<void> {
|
|
29
|
+
while (work.size) await Promise.allSettled([...work.values()]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Timeout reports incomplete disposal; actual cleanup continues safely. */
|
|
33
|
+
export function createRecoveryDisposer(
|
|
34
|
+
quiesce: () => void,
|
|
35
|
+
settle: () => Promise<void>,
|
|
36
|
+
close: () => Promise<void>,
|
|
37
|
+
timeoutMs = 30_000,
|
|
38
|
+
): () => Promise<void> {
|
|
39
|
+
let disposal: Promise<void> | undefined;
|
|
40
|
+
return async () => {
|
|
41
|
+
quiesce();
|
|
42
|
+
disposal ??= (async () => { await settle(); await close(); })();
|
|
43
|
+
await waitForRecoveryWork(disposal, timeoutMs);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process Cashu mint for integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Implements just enough of NUT-01/02/04/06/07/09 to drive a real coco
|
|
5
|
+
* `Manager` against real HTTP: keyset publication, bolt11 mint quotes, minting
|
|
6
|
+
* blinded outputs with a real secp256k1 blind signature, NUT-09 restore and
|
|
7
|
+
* NUT-07 proof states. No Lightning, no network, no NPC.
|
|
8
|
+
*
|
|
9
|
+
* The signing keys and signatures are genuine (`@cashu/cashu-ts` mint-side
|
|
10
|
+
* helpers), so a wallet that receives these signatures can unblind and verify
|
|
11
|
+
* them exactly as with a production mint.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
createBlindSignature,
|
|
15
|
+
createNewMintKeys,
|
|
16
|
+
pointFromHex,
|
|
17
|
+
} from "@cashu/cashu-ts";
|
|
18
|
+
|
|
19
|
+
/** NUT error codes used by the scenarios. */
|
|
20
|
+
export const QUOTE_EXPIRED = 20007;
|
|
21
|
+
export const ALREADY_ISSUED = 20002;
|
|
22
|
+
|
|
23
|
+
export type FakeQuoteState = "UNPAID" | "PAID" | "ISSUED";
|
|
24
|
+
|
|
25
|
+
export interface FakeMintQuote {
|
|
26
|
+
quote: string;
|
|
27
|
+
request: string;
|
|
28
|
+
amount: number;
|
|
29
|
+
unit: string;
|
|
30
|
+
state: FakeQuoteState;
|
|
31
|
+
/** Epoch seconds, or null for a quote that never expires. */
|
|
32
|
+
expiry: number | null;
|
|
33
|
+
amountPaid: number;
|
|
34
|
+
amountIssued: number;
|
|
35
|
+
pubkey: null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FakeMintRequest {
|
|
39
|
+
quote: string;
|
|
40
|
+
outputs: Array<{ amount: number; id: string; B_: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface StoredSignature {
|
|
44
|
+
amount: number;
|
|
45
|
+
id: string;
|
|
46
|
+
C_: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const toHex = (bytes: Uint8Array) => Buffer.from(bytes).toString("hex");
|
|
50
|
+
|
|
51
|
+
export class FakeMint {
|
|
52
|
+
readonly keysetId: string;
|
|
53
|
+
readonly keysByAmount: Record<string, string>;
|
|
54
|
+
readonly requests: FakeMintRequest[] = [];
|
|
55
|
+
/** Every output the mint has ever signed, keyed by B_. */
|
|
56
|
+
readonly signed = new Map<string, StoredSignature>();
|
|
57
|
+
|
|
58
|
+
/** When set, POST /v1/mint/bolt11 fails with this NUT error. */
|
|
59
|
+
mintError: { code: number; detail: string } | null = null;
|
|
60
|
+
/** When set, quote creation returns this expiry (epoch seconds). */
|
|
61
|
+
quoteExpiry: number | null = 3_600;
|
|
62
|
+
/**
|
|
63
|
+
* Awaited before responding to a mint request, so a test can hold minting
|
|
64
|
+
* open and interleave another recovery attempt.
|
|
65
|
+
*/
|
|
66
|
+
gate: Promise<void> | null = null;
|
|
67
|
+
/** Awaited before answering a quote-state check, to hold observe open. */
|
|
68
|
+
observeGate: Promise<void> | null = null;
|
|
69
|
+
/**
|
|
70
|
+
* When true, POST /v1/restore answers with NUT-09's spec-legal positional
|
|
71
|
+
* arrays, including `null` for outputs the mint never signed.
|
|
72
|
+
*
|
|
73
|
+
* This is a known interop gap, not a supported path: cashu-ts 3.7.1 (which
|
|
74
|
+
* coco depends on) dereferences every entry of `signatures` while normalising
|
|
75
|
+
* amounts, so a `null` makes the wallet throw instead of skipping it. The
|
|
76
|
+
* switch exists so tests keep that behaviour visible; see
|
|
77
|
+
* mint-quote-recovery.fake-mint.test.ts.
|
|
78
|
+
*/
|
|
79
|
+
restoreIncludesNulls = false;
|
|
80
|
+
|
|
81
|
+
private readonly quotes = new Map<string, FakeMintQuote>();
|
|
82
|
+
private counter = 0;
|
|
83
|
+
private server?: ReturnType<typeof Bun.serve>;
|
|
84
|
+
|
|
85
|
+
constructor() {
|
|
86
|
+
const pair = createNewMintKeys(20, new Uint8Array(32).fill(9));
|
|
87
|
+
this.keysetId = pair.keysetId;
|
|
88
|
+
this.keysByAmount = Object.fromEntries(
|
|
89
|
+
Object.entries(pair.pubKeys).map(([amount, key]) => [
|
|
90
|
+
amount,
|
|
91
|
+
typeof key === "string" ? key : toHex(key),
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
this.privKeys = Object.fromEntries(
|
|
95
|
+
Object.entries(pair.privKeys) as Array<[string, Uint8Array]>,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private readonly privKeys: Record<string, Uint8Array>;
|
|
100
|
+
|
|
101
|
+
get url(): string {
|
|
102
|
+
if (!this.server) throw new Error("fake mint not started");
|
|
103
|
+
return `http://127.0.0.1:${this.server.port}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
start(): void {
|
|
107
|
+
this.server = Bun.serve({
|
|
108
|
+
hostname: "127.0.0.1",
|
|
109
|
+
port: 0,
|
|
110
|
+
fetch: (request) => this.handle(request),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stop(): void {
|
|
115
|
+
this.server?.stop(true);
|
|
116
|
+
this.server = undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Test control: the mint sees the invoice as paid but has issued nothing. */
|
|
120
|
+
markPaid(quoteId: string): void {
|
|
121
|
+
const quote = this.quotes.get(quoteId);
|
|
122
|
+
if (!quote) throw new Error(`unknown fake quote ${quoteId}`);
|
|
123
|
+
quote.state = "PAID";
|
|
124
|
+
quote.amountPaid = quote.amount;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Test control: mark the quote issued without going through the mint
|
|
129
|
+
* endpoint, simulating a wallet that lost the signatures.
|
|
130
|
+
*/
|
|
131
|
+
markIssued(quoteId: string): void {
|
|
132
|
+
const quote = this.quotes.get(quoteId);
|
|
133
|
+
if (!quote) throw new Error(`unknown fake quote ${quoteId}`);
|
|
134
|
+
quote.state = "ISSUED";
|
|
135
|
+
quote.amountPaid = quote.amount;
|
|
136
|
+
quote.amountIssued = quote.amount;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Test control: sign outputs directly, as if another wallet had issued them. */
|
|
140
|
+
signFor(quoteId: string, outputs: FakeMintRequest["outputs"]): void {
|
|
141
|
+
for (const output of outputs) {
|
|
142
|
+
this.signOutput(output);
|
|
143
|
+
}
|
|
144
|
+
this.markIssued(quoteId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getQuote(quoteId: string): FakeMintQuote | undefined {
|
|
148
|
+
return this.quotes.get(quoteId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private quoteBody(quote: FakeMintQuote) {
|
|
152
|
+
return {
|
|
153
|
+
quote: quote.quote,
|
|
154
|
+
request: quote.request,
|
|
155
|
+
amount: quote.amount,
|
|
156
|
+
unit: quote.unit,
|
|
157
|
+
state: quote.state,
|
|
158
|
+
expiry: quote.expiry,
|
|
159
|
+
amount_paid: quote.amountPaid,
|
|
160
|
+
amount_issued: quote.amountIssued,
|
|
161
|
+
pubkey: quote.pubkey,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private signOutput(output: {
|
|
166
|
+
amount: number;
|
|
167
|
+
id: string;
|
|
168
|
+
B_: string;
|
|
169
|
+
}): StoredSignature {
|
|
170
|
+
const privKey = this.privKeys[String(output.amount)];
|
|
171
|
+
if (!privKey) {
|
|
172
|
+
throw new Error(`fake mint has no key for amount ${output.amount}`);
|
|
173
|
+
}
|
|
174
|
+
const signature = createBlindSignature(
|
|
175
|
+
pointFromHex(output.B_),
|
|
176
|
+
privKey,
|
|
177
|
+
this.keysetId,
|
|
178
|
+
);
|
|
179
|
+
const stored: StoredSignature = {
|
|
180
|
+
amount: output.amount,
|
|
181
|
+
id: this.keysetId,
|
|
182
|
+
C_: toHex(signature.C_.toBytes(false)),
|
|
183
|
+
};
|
|
184
|
+
this.signed.set(output.B_, stored);
|
|
185
|
+
return stored;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private json(body: unknown, status = 200): Response {
|
|
189
|
+
return new Response(JSON.stringify(body), {
|
|
190
|
+
status,
|
|
191
|
+
headers: { "content-type": "application/json" },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private error(code: number, detail: string, status = 400): Response {
|
|
196
|
+
return this.json({ code, detail }, status);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async handle(request: Request): Promise<Response> {
|
|
200
|
+
const { pathname } = new URL(request.url);
|
|
201
|
+
const body = async () => {
|
|
202
|
+
try {
|
|
203
|
+
return (await request.json()) as Record<string, unknown>;
|
|
204
|
+
} catch {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (pathname === "/v1/info") {
|
|
210
|
+
return this.json({
|
|
211
|
+
name: "fake-mint",
|
|
212
|
+
version: "0.0.1",
|
|
213
|
+
nuts: {
|
|
214
|
+
4: {
|
|
215
|
+
methods: [
|
|
216
|
+
{
|
|
217
|
+
method: "bolt11",
|
|
218
|
+
unit: "sat",
|
|
219
|
+
min_amount: 1,
|
|
220
|
+
max_amount: 1_000_000,
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
5: {
|
|
225
|
+
methods: [
|
|
226
|
+
{
|
|
227
|
+
method: "bolt11",
|
|
228
|
+
unit: "sat",
|
|
229
|
+
min_amount: 1,
|
|
230
|
+
max_amount: 1_000_000,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
7: { supported: true },
|
|
235
|
+
9: { supported: true },
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (pathname === "/v1/keys" || pathname.startsWith("/v1/keys/")) {
|
|
241
|
+
return this.json({
|
|
242
|
+
keysets: [
|
|
243
|
+
{ id: this.keysetId, unit: "sat", keys: this.keysByAmount },
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (pathname === "/v1/keysets") {
|
|
249
|
+
return this.json({
|
|
250
|
+
keysets: [{ id: this.keysetId, unit: "sat", active: true }],
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (pathname === "/v1/mint/quote/bolt11" && request.method === "POST") {
|
|
255
|
+
const input = await body();
|
|
256
|
+
const amount = Number(input.amount);
|
|
257
|
+
const unit = typeof input.unit === "string" ? input.unit : "sat";
|
|
258
|
+
const quote: FakeMintQuote = {
|
|
259
|
+
quote: `fake-quote-${++this.counter}`,
|
|
260
|
+
request: `lnbcfake${this.counter}`,
|
|
261
|
+
amount,
|
|
262
|
+
unit,
|
|
263
|
+
state: "UNPAID",
|
|
264
|
+
expiry:
|
|
265
|
+
this.quoteExpiry === null
|
|
266
|
+
? null
|
|
267
|
+
: Math.floor(Date.now() / 1000) + this.quoteExpiry,
|
|
268
|
+
amountPaid: 0,
|
|
269
|
+
amountIssued: 0,
|
|
270
|
+
pubkey: null,
|
|
271
|
+
};
|
|
272
|
+
this.quotes.set(quote.quote, quote);
|
|
273
|
+
return this.json(this.quoteBody(quote));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const quoteMatch = pathname.match(/^\/v1\/mint\/quote\/bolt11\/(.+)$/);
|
|
277
|
+
if (quoteMatch?.[1] && request.method === "GET") {
|
|
278
|
+
if (this.observeGate) await this.observeGate;
|
|
279
|
+
const quote = this.quotes.get(decodeURIComponent(quoteMatch[1]));
|
|
280
|
+
if (!quote) return this.error(50000, "Unknown quote");
|
|
281
|
+
return this.json(this.quoteBody(quote));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (pathname === "/v1/mint/bolt11" && request.method === "POST") {
|
|
285
|
+
const input = await body();
|
|
286
|
+
const quoteId = String(input.quote);
|
|
287
|
+
const outputs = (input.outputs ?? []) as FakeMintRequest["outputs"];
|
|
288
|
+
this.requests.push({ quote: quoteId, outputs });
|
|
289
|
+
if (this.gate) await this.gate;
|
|
290
|
+
if (this.mintError) {
|
|
291
|
+
return this.error(this.mintError.code, this.mintError.detail);
|
|
292
|
+
}
|
|
293
|
+
const quote = this.quotes.get(quoteId);
|
|
294
|
+
if (!quote) return this.error(50000, "Unknown quote");
|
|
295
|
+
if (quote.state === "ISSUED" || quote.amountIssued > 0) {
|
|
296
|
+
return this.error(ALREADY_ISSUED, "Quote already issued");
|
|
297
|
+
}
|
|
298
|
+
if (quote.state !== "PAID") {
|
|
299
|
+
return this.error(20001, "Quote is not paid");
|
|
300
|
+
}
|
|
301
|
+
const total = outputs.reduce((sum, o) => sum + Number(o.amount), 0);
|
|
302
|
+
if (total > quote.amountPaid - quote.amountIssued) {
|
|
303
|
+
return this.error(10002, "Outputs exceed the paid amount");
|
|
304
|
+
}
|
|
305
|
+
const signatures = outputs.map((output) => ({
|
|
306
|
+
amount: output.amount,
|
|
307
|
+
id: this.keysetId,
|
|
308
|
+
C_: this.signOutput(output).C_,
|
|
309
|
+
}));
|
|
310
|
+
quote.state = "ISSUED";
|
|
311
|
+
quote.amountIssued += total;
|
|
312
|
+
return this.json({ signatures });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (pathname === "/v1/restore" && request.method === "POST") {
|
|
316
|
+
const input = await body();
|
|
317
|
+
const outputs = (input.outputs ?? []) as FakeMintRequest["outputs"];
|
|
318
|
+
if (this.restoreIncludesNulls) {
|
|
319
|
+
// Spec-legal NUT-09 shape, including nulls. Kept behind a switch
|
|
320
|
+
// because it is currently unusable with coco's cashu-ts version.
|
|
321
|
+
return this.json({
|
|
322
|
+
outputs,
|
|
323
|
+
signatures: outputs.map(
|
|
324
|
+
(output) => this.signed.get(output.B_) ?? null,
|
|
325
|
+
),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// Return only the signed outputs; coco matches them by B_ and treats the
|
|
329
|
+
// rest as "nothing to restore".
|
|
330
|
+
const signed = outputs.filter((output) => this.signed.has(output.B_));
|
|
331
|
+
return this.json({
|
|
332
|
+
outputs: signed,
|
|
333
|
+
signatures: signed.map((output) => this.signed.get(output.B_)),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (pathname === "/v1/checkstate" && request.method === "POST") {
|
|
338
|
+
const input = await body();
|
|
339
|
+
const ys = (input.Ys ?? []) as string[];
|
|
340
|
+
return this.json({
|
|
341
|
+
states: ys.map((Y) => ({ Y, state: "UNSPENT", witness: null })),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return this.error(404, `fake mint has no route for ${pathname}`, 404);
|
|
346
|
+
}
|
|
347
|
+
}
|