routstrd 0.4.0 → 0.4.1

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.
@@ -84491,7 +84491,7 @@ var require_public_api = __commonJS((exports) => {
84491
84491
 
84492
84492
  // src/daemon/index.ts
84493
84493
  import { createServer } from "http";
84494
- import { existsSync as existsSync11, unlinkSync as unlinkSync2 } from "fs";
84494
+ import { existsSync as existsSync13, unlinkSync as unlinkSync2 } from "fs";
84495
84495
 
84496
84496
  // node_modules/applesauce-relay/dist/group.js
84497
84497
  init_event_store2();
@@ -113595,11 +113595,11 @@ function startAutoRefillLoop(cocod, getWallet, getConfig, intervalMs = 5000) {
113595
113595
  if (now4 - lastRefillAt < config2.cooldownMs) {
113596
113596
  return;
113597
113597
  }
113598
- checkInProgress = true;
113599
113598
  const backoffInterval = Math.min(intervalMs * Math.pow(2, consecutiveFailures), 5 * 60 * 1000);
113600
113599
  if (now4 - lastAttemptAt < backoffInterval) {
113601
113600
  return;
113602
113601
  }
113602
+ checkInProgress = true;
113603
113603
  try {
113604
113604
  const balances = await cocod.getBalances();
113605
113605
  const totalBalance = Object.values(balances).reduce((sum, b2) => sum + (typeof b2 === "number" ? b2 : 0), 0);
@@ -113607,10 +113607,9 @@ function startAutoRefillLoop(cocod, getWallet, getConfig, intervalMs = 5000) {
113607
113607
  return;
113608
113608
  }
113609
113609
  logger5.log(`[auto-refill] Balance ${totalBalance} sats < threshold ${config2.threshold}. Refilling ${config2.amount} sats...`);
113610
- const mints = await cocod.listMints();
113611
- const mintUrl = mints[0];
113610
+ const mintUrl = await cocod.getDefaultMint();
113612
113611
  if (!mintUrl) {
113613
- logger5.error("[auto-refill] No active mint configured");
113612
+ logger5.error("[auto-refill] No default mint configured");
113614
113613
  return;
113615
113614
  }
113616
113615
  logger5.log(`[auto-refill] Creating BOLT-11 invoice for ${config2.amount} sats via ${mintUrl}...`);
@@ -128194,26 +128193,6 @@ var PluginHost = class {
128194
128193
  };
128195
128194
  }
128196
128195
  };
128197
- async function initializeCoco(config2) {
128198
- await config2.repo.init();
128199
- const coco = new Manager(config2.repo, config2.seedGetter, config2.logger, config2.webSocketFactory, config2.plugins, config2.watchers, config2.processors, config2.subscriptions);
128200
- await coco.initPlugins();
128201
- await coco.reconcileLegacyMintQuotes();
128202
- const mintOperationWatcherConfig = config2.watchers?.mintOperationWatcher;
128203
- if (!mintOperationWatcherConfig?.disabled)
128204
- await coco.enableMintOperationWatcher(mintOperationWatcherConfig);
128205
- const proofStateWatcherConfig = config2.watchers?.proofStateWatcher;
128206
- if (!proofStateWatcherConfig?.disabled)
128207
- await coco.enableProofStateWatcher(proofStateWatcherConfig);
128208
- const mintOperationProcessorConfig = config2.processors?.mintOperationProcessor;
128209
- if (!mintOperationProcessorConfig?.disabled)
128210
- await coco.enableMintOperationProcessor(mintOperationProcessorConfig);
128211
- await coco.ops.send.recovery.run();
128212
- await coco.ops.melt.recovery.run();
128213
- await coco.ops.receive.recovery.run();
128214
- await coco.recoverPendingMintOperations();
128215
- return coco;
128216
- }
128217
128196
  var Manager = class {
128218
128197
  mint;
128219
128198
  wallet;
@@ -136926,6 +136905,8 @@ function getWalletStateMessage(state) {
136926
136905
  return "Wallet is locked. Unlock it before performing wallet operations.";
136927
136906
  case "UNINITIALIZED":
136928
136907
  return "Wallet is not initialized. Run 'routstrd onboard' first.";
136908
+ case "RECOVERING":
136909
+ return "Wallet is recovering from a previous run. Balance and send/receive operations will be available once recovery completes.";
136929
136910
  case "ERROR":
136930
136911
  return "Wallet is in an error state.";
136931
136912
  default:
@@ -136982,6 +136963,15 @@ async function buildStatusOutput(deps) {
136982
136963
  const mode = getCurrentMode(deps);
136983
136964
  try {
136984
136965
  const walletState = await deps.walletClient.getStatus();
136966
+ if (walletState === "RECOVERING") {
136967
+ return {
136968
+ daemon: "running",
136969
+ wallet: "recovering",
136970
+ walletState,
136971
+ mode,
136972
+ error: getWalletStateMessage(walletState)
136973
+ };
136974
+ }
136985
136975
  if (walletState !== "UNLOCKED") {
136986
136976
  return {
136987
136977
  daemon: "running",
@@ -137011,8 +137001,9 @@ async function buildStatusOutput(deps) {
137011
137001
  }
137012
137002
  async function buildWalletDetails(deps) {
137013
137003
  const state = await deps.walletClient.getStatus();
137004
+ const recovery = await deps.walletClient.getRecoveryProgress?.();
137014
137005
  if (state !== "UNLOCKED") {
137015
- return { state, ready: false };
137006
+ return { state, ready: false, ...recovery ? { recovery } : {} };
137016
137007
  }
137017
137008
  const [balances, defaultMint] = await Promise.all([
137018
137009
  deps.walletAdapter.getBalances(),
@@ -137024,7 +137015,8 @@ async function buildWalletDetails(deps) {
137024
137015
  balances,
137025
137016
  unit: "sat",
137026
137017
  activeMint: deps.walletAdapter.getActiveMintUrl(),
137027
- defaultMint
137018
+ defaultMint,
137019
+ ...recovery ? { recovery } : {}
137028
137020
  };
137029
137021
  }
137030
137022
  function makeSdkLogger(prefix) {
@@ -137086,6 +137078,21 @@ function createDaemonRequestHandler(deps) {
137086
137078
  });
137087
137079
  return;
137088
137080
  }
137081
+ if (req.method === "POST" && url5.pathname === "/wallet/cleanup") {
137082
+ await respond(res, async () => {
137083
+ if (!deps.walletClient.cleanupStuckOperations) {
137084
+ throw new CocodHttpError(501, "Wallet cleanup is not supported by this wallet client.");
137085
+ }
137086
+ const body = await readJsonBody(req);
137087
+ const result = await deps.walletClient.cleanupStuckOperations({
137088
+ mintUrl: optionalStringField(body, "mintUrl"),
137089
+ minAgeMs: typeof body.minAgeMs === "number" && Number.isFinite(body.minAgeMs) ? body.minAgeMs : undefined,
137090
+ dryRun: body.dryRun === true
137091
+ });
137092
+ return { output: result };
137093
+ });
137094
+ return;
137095
+ }
137089
137096
  if (req.method === "POST" && url5.pathname === "/wallet/receive/cashu") {
137090
137097
  await respond(res, async () => {
137091
137098
  const body = await readJsonBody(req);
@@ -137550,11 +137557,7 @@ function createDaemonRequestHandler(deps) {
137550
137557
  }
137551
137558
  if (!mintUrl) {
137552
137559
  try {
137553
- const walletBalances = await deps.walletAdapter.getBalances();
137554
- const mintUrls = Object.keys(walletBalances);
137555
- if (mintUrls.length > 0) {
137556
- mintUrl = mintUrls[0];
137557
- }
137560
+ mintUrl = deps.walletAdapter.getActiveMintUrl() ?? Object.keys(await deps.walletAdapter.getBalances())[0];
137558
137561
  } catch {}
137559
137562
  }
137560
137563
  let refunded = false;
@@ -157221,6 +157224,216 @@ function mnemonicToSeedSync2(mnemonic, passphrase = "") {
157221
157224
  });
157222
157225
  }
157223
157226
 
157227
+ // src/daemon/wallet/cleanup.ts
157228
+ function selectCleanupOperations(options) {
157229
+ const { mints, sends, melts, nowMs, minAgeMs } = options;
157230
+ const mintsToFail = mints.filter((op) => op.state === "pending" && op.expiry > 0 && op.expiry * 1000 <= nowMs && op.lastObservedRemoteState !== "PAID" && op.lastObservedRemoteState !== "ISSUED");
157231
+ const sendsToReclaim = sends.filter((op) => op.state === "pending" && nowMs - op.updatedAt >= minAgeMs);
157232
+ const meltsToCancel = melts.filter((op) => op.state === "prepared" && nowMs - op.updatedAt >= minAgeMs);
157233
+ return { mintsToFail, sendsToReclaim, meltsToCancel };
157234
+ }
157235
+
157236
+ // src/daemon/wallet/receive-dedup.ts
157237
+ import { createHash as createHash3 } from "crypto";
157238
+ var TOKEN_TABLE = "routstrd_receive_tokens";
157239
+ var MAINTENANCE_TABLE = "routstrd_wallet_maintenance";
157240
+ var RECONCILE_BACKUP_KEY = "receive_reconcile_v1_backup";
157241
+ function hashReceiveToken(token) {
157242
+ return createHash3("sha256").update(token).digest("hex");
157243
+ }
157244
+ function initReceiveDedupSchema(database) {
157245
+ database.exec(`
157246
+ CREATE TABLE IF NOT EXISTS ${TOKEN_TABLE} (
157247
+ tokenHash TEXT PRIMARY KEY,
157248
+ state TEXT NOT NULL CHECK (state IN ('processing', 'succeeded', 'failed')),
157249
+ operationId TEXT,
157250
+ error TEXT,
157251
+ createdAt INTEGER NOT NULL,
157252
+ updatedAt INTEGER NOT NULL
157253
+ );
157254
+ CREATE TABLE IF NOT EXISTS ${MAINTENANCE_TABLE} (
157255
+ key TEXT PRIMARY KEY,
157256
+ value TEXT NOT NULL,
157257
+ updatedAt INTEGER NOT NULL
157258
+ );
157259
+ `);
157260
+ }
157261
+ function listProcessingReceiveTokens(database) {
157262
+ return database.query(`SELECT tokenHash, state, operationId, error
157263
+ FROM ${TOKEN_TABLE} WHERE state = 'processing'`).all();
157264
+ }
157265
+ function deleteReceiveTokenReservation(database, tokenHash) {
157266
+ database.query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ?`).run(tokenHash);
157267
+ }
157268
+ function clearInterruptedReceiveReservations(database) {
157269
+ const result = database.query(`DELETE FROM ${TOKEN_TABLE}
157270
+ WHERE state = 'processing' AND operationId IS NULL`).run();
157271
+ return result.changes;
157272
+ }
157273
+ function reserveReceiveToken(database, token) {
157274
+ const tokenHash = hashReceiveToken(token);
157275
+ const now5 = Math.floor(Date.now() / 1000);
157276
+ const result = database.query(`INSERT OR IGNORE INTO ${TOKEN_TABLE}
157277
+ (tokenHash, state, operationId, error, createdAt, updatedAt)
157278
+ VALUES (?, 'processing', NULL, NULL, ?, ?)`).run(tokenHash, now5, now5);
157279
+ if (result.changes > 0)
157280
+ return { acquired: true, tokenHash };
157281
+ const existing = database.query(`SELECT tokenHash, state, operationId, error
157282
+ FROM ${TOKEN_TABLE} WHERE tokenHash = ?`).get(tokenHash);
157283
+ return { acquired: false, tokenHash, ...existing ? { existing } : {} };
157284
+ }
157285
+ function releaseReceiveToken(database, tokenHash) {
157286
+ database.query(`DELETE FROM ${TOKEN_TABLE} WHERE tokenHash = ? AND operationId IS NULL`).run(tokenHash);
157287
+ }
157288
+ function updateReceiveToken(database, tokenHash, update) {
157289
+ database.query(`UPDATE ${TOKEN_TABLE}
157290
+ SET state = ?, operationId = COALESCE(?, operationId), error = ?, updatedAt = ?
157291
+ WHERE tokenHash = ?`).run(update.state, update.operationId ?? null, update.error ?? null, Math.floor(Date.now() / 1000), tokenHash);
157292
+ }
157293
+ function receiveInputFingerprint(operation) {
157294
+ const proofs = operation.inputProofs.map((proof) => ({
157295
+ amount: proof.amount,
157296
+ id: proof.id,
157297
+ secret: proof.secret,
157298
+ C: proof.C,
157299
+ witness: proof.witness ?? null
157300
+ })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
157301
+ return createHash3("sha256").update(JSON.stringify([operation.mintUrl, operation.unit, proofs])).digest("hex");
157302
+ }
157303
+ function groupExecutingReceives(operations) {
157304
+ const groups = new Map;
157305
+ for (const operation of operations) {
157306
+ if (operation.state !== "executing")
157307
+ continue;
157308
+ const key = receiveInputFingerprint(operation);
157309
+ const group2 = groups.get(key) ?? [];
157310
+ group2.push(operation);
157311
+ groups.set(key, group2);
157312
+ }
157313
+ return [...groups.values()].map((group2) => group2.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)));
157314
+ }
157315
+ function outputData(operation) {
157316
+ const value = operation.outputData;
157317
+ if (!value || typeof value !== "object")
157318
+ return null;
157319
+ const candidate = value;
157320
+ if (!Array.isArray(candidate.keep) || !Array.isArray(candidate.send))
157321
+ return null;
157322
+ return { keep: candidate.keep, send: candidate.send };
157323
+ }
157324
+ function outputBlindedMessages(operation) {
157325
+ const data = outputData(operation);
157326
+ if (!data)
157327
+ return [];
157328
+ return [...data.keep, ...data.send].map((entry) => entry?.blindedMessage).filter((message) => Boolean(message && typeof message.B_ === "string" && message.B_ && typeof message.id === "string" && Number.isFinite(message.amount)));
157329
+ }
157330
+ async function rollBackOthers(source, group2, retainedIds, reason) {
157331
+ let count = 0;
157332
+ for (const operation of group2) {
157333
+ if (retainedIds.has(operation.id))
157334
+ continue;
157335
+ await source.rollBack(operation, reason);
157336
+ count++;
157337
+ }
157338
+ return count;
157339
+ }
157340
+ async function reconcileExecutingReceives(source) {
157341
+ const executing = (await source.listExecuting()).filter((operation) => operation.state === "executing");
157342
+ const groups = groupExecutingReceives(executing);
157343
+ const result = {
157344
+ executing: executing.length,
157345
+ uniqueGroups: groups.length,
157346
+ rolledBack: 0,
157347
+ retained: 0,
157348
+ unresolved: 0,
157349
+ recoveryOperationIds: []
157350
+ };
157351
+ for (const group2 of groups) {
157352
+ try {
157353
+ const savedOwners = new Set;
157354
+ for (const operation of group2) {
157355
+ if (await source.hasSavedOutputs(operation))
157356
+ savedOwners.add(operation.id);
157357
+ }
157358
+ if (savedOwners.size > 0) {
157359
+ result.rolledBack += await rollBackOthers(source, group2, savedOwners, "Duplicate receive: outputs already persisted by another operation");
157360
+ result.retained += savedOwners.size;
157361
+ result.recoveryOperationIds.push(...savedOwners);
157362
+ continue;
157363
+ }
157364
+ const states = await source.checkProofStates(group2[0]);
157365
+ const completeStates = states.length === group2[0].inputProofs.length;
157366
+ const allUnspent = completeStates && states.every((state) => state.state === "UNSPENT");
157367
+ const allSpent = completeStates && states.every((state) => state.state === "SPENT");
157368
+ if (allUnspent) {
157369
+ const canonical = group2.find((operation) => outputBlindedMessages(operation).length > 0);
157370
+ if (!canonical) {
157371
+ result.unresolved += group2.length;
157372
+ continue;
157373
+ }
157374
+ result.rolledBack += await rollBackOthers(source, group2, new Set([canonical.id]), "Duplicate receive: identical inputs retained by canonical operation");
157375
+ result.retained++;
157376
+ result.recoveryOperationIds.push(canonical.id);
157377
+ continue;
157378
+ }
157379
+ if (!allSpent) {
157380
+ result.unresolved += group2.length;
157381
+ continue;
157382
+ }
157383
+ const outputOwners = new Map;
157384
+ let malformed = false;
157385
+ for (const operation of group2) {
157386
+ const outputs = outputBlindedMessages(operation);
157387
+ if (outputs.length === 0) {
157388
+ malformed = true;
157389
+ continue;
157390
+ }
157391
+ for (const output12 of outputs) {
157392
+ const entry = outputOwners.get(output12.B_) ?? {
157393
+ message: output12,
157394
+ owners: new Set
157395
+ };
157396
+ entry.owners.add(operation.id);
157397
+ outputOwners.set(output12.B_, entry);
157398
+ }
157399
+ }
157400
+ if (malformed || outputOwners.size === 0) {
157401
+ result.unresolved += group2.length;
157402
+ continue;
157403
+ }
157404
+ const restored = await source.restoreOutputs(group2[0].mintUrl, [...outputOwners.values()].map((entry) => entry.message));
157405
+ const restoredOwners = new Set;
157406
+ for (const output12 of restored) {
157407
+ for (const owner of outputOwners.get(output12.B_)?.owners ?? []) {
157408
+ restoredOwners.add(owner);
157409
+ }
157410
+ }
157411
+ if (restoredOwners.size === 0) {
157412
+ for (const operation of group2) {
157413
+ await source.rollBack(operation, "Recovered: input proofs spent without recoverable outputs");
157414
+ result.rolledBack++;
157415
+ }
157416
+ continue;
157417
+ }
157418
+ result.rolledBack += await rollBackOthers(source, group2, restoredOwners, "Duplicate receive: recoverable outputs belong to another operation");
157419
+ result.retained += restoredOwners.size;
157420
+ result.recoveryOperationIds.push(...restoredOwners);
157421
+ } catch {
157422
+ result.unresolved += group2.length;
157423
+ }
157424
+ }
157425
+ return result;
157426
+ }
157427
+ function getReceiveReconcileBackup(database) {
157428
+ const row = database.query(`SELECT value FROM ${MAINTENANCE_TABLE} WHERE key = ?`).get(RECONCILE_BACKUP_KEY);
157429
+ return row?.value ?? null;
157430
+ }
157431
+ function setReceiveReconcileBackup(database, path2) {
157432
+ database.query(`INSERT INTO ${MAINTENANCE_TABLE} (key, value, updatedAt)
157433
+ VALUES (?, ?, ?)
157434
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`).run(RECONCILE_BACKUP_KEY, path2, Math.floor(Date.now() / 1000));
157435
+ }
157436
+
157224
157437
  // src/daemon/wallet/paths.ts
157225
157438
  import { join as join10 } from "path";
157226
157439
  function homeDir() {
@@ -157258,6 +157471,12 @@ function startupProgress(message) {
157258
157471
  logger5.info(message);
157259
157472
  console.log(`${STARTUP_LOG_PREFIX} ${message}`);
157260
157473
  }
157474
+ var surfacingRecoveryProgress = false;
157475
+ var RECOVERY_STALL_MARKERS = new Map([
157476
+ ["SendOperationService\x00Could not reach mint for recovery, will retry later", "Send recovery: mint unreachable"],
157477
+ ["ReceiveOperationService\x00Could not reach mint for receive recovery, will retry later", "Receive recovery: mint unreachable"],
157478
+ ["MintOperationService\x00Failed to reconcile stale pending mint operation", "Mint recovery: pending operation check failed"]
157479
+ ]);
157261
157480
  var SAFE_COCO_LOG_FIELDS = new Set([
157262
157481
  "module",
157263
157482
  "mintUrl",
@@ -157289,6 +157508,16 @@ function safeCocoMetadata(values2) {
157289
157508
  function createCocoLogger(bindings = {}) {
157290
157509
  const write = (level, message, meta3) => {
157291
157510
  const metadata = safeCocoMetadata([bindings, ...meta3]);
157511
+ if (surfacingRecoveryProgress) {
157512
+ const module = typeof bindings.module === "string" ? bindings.module : undefined;
157513
+ if (module) {
157514
+ const stall = RECOVERY_STALL_MARKERS.get(`${module}\x00${message}`);
157515
+ if (stall) {
157516
+ const mintUrl = typeof metadata.mintUrl === "string" ? metadata.mintUrl : undefined;
157517
+ startupProgress(mintUrl ? `${stall} (${mintUrl})` : stall);
157518
+ }
157519
+ }
157520
+ }
157292
157521
  cocoLogger[level](`[coco] ${message}`, ...Object.keys(metadata).length > 0 ? [metadata] : []);
157293
157522
  };
157294
157523
  return {
@@ -157325,13 +157554,22 @@ function saveConfig(config2, configFile) {
157325
157554
  throw error53;
157326
157555
  }
157327
157556
  }
157557
+ function isZombieProcess(pid, readFile7 = (path2) => readFileSync2(path2, "utf-8")) {
157558
+ try {
157559
+ const stat2 = readFile7(`/proc/${pid}/stat`);
157560
+ const commandEnd = stat2.lastIndexOf(")");
157561
+ return commandEnd >= 0 && stat2.charAt(commandEnd + 2) === "Z";
157562
+ } catch {
157563
+ return false;
157564
+ }
157565
+ }
157328
157566
  function defaultIsProcessRunning(pid) {
157329
157567
  try {
157330
157568
  process.kill(pid, 0);
157331
- return true;
157332
157569
  } catch (error53) {
157333
157570
  return error53.code === "EPERM";
157334
157571
  }
157572
+ return !isZombieProcess(pid);
157335
157573
  }
157336
157574
  function hasErrorCode(error53, codes) {
157337
157575
  let current = error53;
@@ -157348,24 +157586,7 @@ function hasErrorCode(error53, codes) {
157348
157586
  }
157349
157587
  async function assertLegacyCocodNotRunning(options = {}) {
157350
157588
  const socketPath = options.socketPath || legacyCocodSocketPath();
157351
- const pidFilePath = options.pidFilePath || legacyCocodPidPath();
157352
157589
  const pathExists = options.pathExists || existsSync9;
157353
- const readFile7 = options.readFile || ((path2) => readFileSync2(path2, "utf-8"));
157354
- const isProcessRunning2 = options.isProcessRunning || defaultIsProcessRunning;
157355
- const getRunningLegacyPid = () => {
157356
- if (!pathExists(pidFilePath))
157357
- return null;
157358
- try {
157359
- const pid = Number.parseInt(readFile7(pidFilePath).trim(), 10);
157360
- return Number.isInteger(pid) && pid > 0 && pid !== options.ignorePid && isProcessRunning2(pid) ? pid : null;
157361
- } catch {
157362
- return null;
157363
- }
157364
- };
157365
- const runningPid = getRunningLegacyPid();
157366
- if (runningPid !== null) {
157367
- throw new Error(`Legacy cocod daemon is still running with PID ${runningPid}. ` + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + `Run 'cocod stop' or 'kill ${runningPid}' and try again.`);
157368
- }
157369
157590
  if (!pathExists(socketPath))
157370
157591
  return;
157371
157592
  const fetchImpl = options.fetchImpl || fetch;
@@ -157378,12 +157599,8 @@ async function assertLegacyCocodNotRunning(options = {}) {
157378
157599
  await response3.body?.cancel();
157379
157600
  } catch (error53) {
157380
157601
  if (hasErrorCode(error53, STALE_SOCKET_ERROR_CODES)) {
157381
- const newlyRunningPid = getRunningLegacyPid();
157382
- if (newlyRunningPid === null) {
157383
- logger5.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
157384
- return;
157385
- }
157386
- throw new Error(`Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + `Run 'cocod stop' or 'kill ${newlyRunningPid}' and try again.`, { cause: error53 });
157602
+ logger5.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
157603
+ return;
157387
157604
  }
157388
157605
  throw new Error(`Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` + "Refusing to open the wallet database to prevent concurrent access. " + "Run 'cocod stop', verify the daemon has exited, and try again.", { cause: error53 });
157389
157606
  }
@@ -157447,12 +157664,14 @@ async function stopLegacyCocod(options = {}) {
157447
157664
  function claimLegacyCocodPidFile(options = {}) {
157448
157665
  return claimPidFile({
157449
157666
  ...options,
157450
- pidFilePath: options.pidFilePath || legacyCocodPidPath()
157667
+ pidFilePath: options.pidFilePath || legacyCocodPidPath(),
157668
+ label: options.label || "legacy cocod exclusion lock"
157451
157669
  });
157452
157670
  }
157453
157671
  function claimPidFile(options) {
157454
157672
  const pidFilePath = options.pidFilePath;
157455
157673
  const pid = options.pid ?? process.pid;
157674
+ const label = options.label || "wallet process lock";
157456
157675
  const openExclusive = options.openExclusive || ((path2) => openSync(path2, "wx", 384));
157457
157676
  const writePid = options.writePid || ((fd2, ownerPid) => writeFileSync(fd2, String(ownerPid)));
157458
157677
  const closeFile = options.closeFile || closeSync;
@@ -157469,16 +157688,17 @@ function claimPidFile(options) {
157469
157688
  try {
157470
157689
  stalePid = Number.parseInt(readFile7(pidFilePath).trim(), 10);
157471
157690
  } catch {
157472
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error53 });
157691
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error53 });
157473
157692
  }
157474
157693
  if (!Number.isInteger(stalePid) || stalePid <= 0 || isProcessRunning2(stalePid)) {
157475
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error53 });
157694
+ const ownerMessage = Number.isInteger(stalePid) && stalePid > 0 ? `PID ${stalePid} is still running and holds it. ` + `Stop that process first ('routstrd stop' or 'kill ${stalePid}').` : "Another cocod or routstrd process may be starting. Stop it and try again.";
157695
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}: ${ownerMessage}`, { cause: error53 });
157476
157696
  }
157477
157697
  try {
157478
157698
  removeFile(pidFilePath);
157479
157699
  fd = openExclusive(pidFilePath);
157480
157700
  } catch (retryError) {
157481
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: retryError });
157701
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: retryError });
157482
157702
  }
157483
157703
  }
157484
157704
  try {
@@ -157492,20 +157712,180 @@ function claimPidFile(options) {
157492
157712
  closeFile(fd);
157493
157713
  }
157494
157714
  let released = false;
157495
- return () => {
157715
+ const release = () => {
157496
157716
  if (released)
157497
157717
  return;
157498
157718
  released = true;
157719
+ process.removeListener("exit", release);
157499
157720
  try {
157500
157721
  if (readFile7(pidFilePath).trim() === String(pid)) {
157501
157722
  removeFile(pidFilePath);
157502
157723
  }
157503
157724
  } catch (error53) {
157504
157725
  if (error53.code !== "ENOENT") {
157505
- logger5.warn(`Failed to release wallet process lock at ${pidFilePath}:`, error53);
157726
+ logger5.warn(`Failed to release ${label} at ${pidFilePath}:`, error53);
157506
157727
  }
157507
157728
  }
157508
157729
  };
157730
+ process.once("exit", release);
157731
+ return release;
157732
+ }
157733
+ function constructCocoManager(repo, seed) {
157734
+ return new Manager(repo, async () => seed, createCocoLogger());
157735
+ }
157736
+ async function enableCocoManager(coco) {
157737
+ await coco.initPlugins();
157738
+ await coco.reconcileLegacyMintQuotes();
157739
+ await coco.enableMintOperationWatcher();
157740
+ await coco.enableProofStateWatcher();
157741
+ await coco.enableMintOperationProcessor();
157742
+ }
157743
+ var EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15000;
157744
+ function withTimeout(promise2, timeoutMs) {
157745
+ let timer6;
157746
+ const timeout6 = new Promise((_resolve, reject) => {
157747
+ timer6 = setTimeout(() => reject(new Error("Timed out contacting mint")), timeoutMs);
157748
+ });
157749
+ return Promise.race([promise2, timeout6]).finally(() => {
157750
+ if (timer6 !== undefined)
157751
+ clearTimeout(timer6);
157752
+ });
157753
+ }
157754
+ async function settleExpiredMintQuotes(source, nowMs, deadlineMs = EXPIRED_MINT_OBSERVATION_DEADLINE_MS) {
157755
+ const pendingMints = await source.ops.mint.listPending();
157756
+ const selection = selectCleanupOperations({
157757
+ mints: pendingMints,
157758
+ sends: [],
157759
+ melts: [],
157760
+ nowMs,
157761
+ minAgeMs: 0
157762
+ });
157763
+ const settlement = {
157764
+ failed: 0,
157765
+ leftForRecovery: 0,
157766
+ unobserved: 0
157767
+ };
157768
+ const candidates = selection.mintsToFail;
157769
+ if (candidates.length === 0)
157770
+ return settlement;
157771
+ const startedAt = Date.now();
157772
+ for (const op of candidates) {
157773
+ const remainingMs = deadlineMs - (Date.now() - startedAt);
157774
+ if (remainingMs <= 0) {
157775
+ const skipped = candidates.length - settlement.failed - settlement.leftForRecovery - settlement.unobserved;
157776
+ settlement.unobserved += skipped;
157777
+ startupProgress(`Expired mint quote check budget exhausted; ${skipped} quote(s) left for mint recovery.`);
157778
+ break;
157779
+ }
157780
+ try {
157781
+ const result = await withTimeout(source.mintOperationService.observePendingOperation(op.id), remainingMs);
157782
+ if (result.category === "waiting") {
157783
+ await source.mintOperationService.failPendingOperation({ id: op.id }, {
157784
+ reason: "Expired mint quote confirmed unpaid by mint",
157785
+ retryable: false,
157786
+ observedAt: Date.now()
157787
+ });
157788
+ settlement.failed++;
157789
+ } else {
157790
+ settlement.leftForRecovery++;
157791
+ const observed = result.category === "ready" ? "was paid at the mint" : result.category === "completed" ? "was already issued at the mint" : "failed terminally at the mint";
157792
+ startupProgress(`Expired mint quote ${op.quoteId ?? op.id} at ${op.mintUrl} ${observed}; leaving it for mint recovery.`);
157793
+ }
157794
+ } catch (error53) {
157795
+ settlement.unobserved++;
157796
+ logger5.warn("Could not check expired mint quote; leaving it pending", {
157797
+ operationId: op.id,
157798
+ mintUrl: op.mintUrl,
157799
+ error: error53 instanceof Error ? error53.message : String(error53)
157800
+ });
157801
+ }
157802
+ }
157803
+ return settlement;
157804
+ }
157805
+ async function reconcileDuplicateReceiveOperations(coco, repo) {
157806
+ const internals = coco;
157807
+ const unavailableMints = new Set;
157808
+ const deadline = Date.now() + 45000;
157809
+ const ensureMintBudget = (mintUrl) => {
157810
+ if (Date.now() >= deadline)
157811
+ throw new Error("Receive cleanup time budget exhausted");
157812
+ if (unavailableMints.has(mintUrl))
157813
+ throw new Error("Mint already failed receive cleanup");
157814
+ };
157815
+ const markMintFailure = (mintUrl, error53) => {
157816
+ unavailableMints.add(mintUrl);
157817
+ throw error53;
157818
+ };
157819
+ const source = {
157820
+ listExecuting: () => repo.receiveOperationRepository.getByState("executing"),
157821
+ checkProofStates: async (operation) => {
157822
+ ensureMintBudget(operation.mintUrl);
157823
+ try {
157824
+ return await withTimeout(internals.receiveOperationService.checkProofStatesWithMint(operation.mintUrl, operation.inputProofs), Math.min(15000, Math.max(1, deadline - Date.now())));
157825
+ } catch (error53) {
157826
+ return markMintFailure(operation.mintUrl, error53);
157827
+ }
157828
+ },
157829
+ restoreOutputs: async (mintUrl, outputs) => {
157830
+ ensureMintBudget(mintUrl);
157831
+ const restored = [];
157832
+ const mint = internals.mintAdapter.getCashuMint(mintUrl);
157833
+ for (let index = 0;index < outputs.length; index += 300) {
157834
+ try {
157835
+ const response3 = await withTimeout(mint.restore({ outputs: outputs.slice(index, index + 300) }), Math.min(15000, Math.max(1, deadline - Date.now())));
157836
+ restored.push(...response3.outputs);
157837
+ } catch (error53) {
157838
+ return markMintFailure(mintUrl, error53);
157839
+ }
157840
+ }
157841
+ return restored;
157842
+ },
157843
+ hasSavedOutputs: (operation) => internals.receiveOperationService.hasSavedOutputs(operation),
157844
+ rollBack: async (operation, reason) => {
157845
+ await internals.receiveOperationService.markAsRolledBack(operation, reason);
157846
+ }
157847
+ };
157848
+ return reconcileExecutingReceives(source);
157849
+ }
157850
+ async function runWalletRecovery(coco, onProgress, receiveOperationIds) {
157851
+ surfacingRecoveryProgress = true;
157852
+ let failedMintQuotes = 0;
157853
+ try {
157854
+ onProgress({ phase: "Settling expired mint quotes", failedMintQuotes });
157855
+ const settlement = await settleExpiredMintQuotes({
157856
+ ops: coco.ops,
157857
+ mintOperationService: coco.mintOperationService
157858
+ }, Date.now());
157859
+ failedMintQuotes = settlement.failed;
157860
+ if (settlement.leftForRecovery > 0 || settlement.unobserved > 0) {
157861
+ startupProgress(`Expired mint quotes: ${settlement.failed} failed locally, ` + `${settlement.leftForRecovery} paid/issued (kept for recovery), ` + `${settlement.unobserved} unverifiable (kept pending).`);
157862
+ }
157863
+ onProgress({ phase: "Settled expired mint quotes", failedMintQuotes });
157864
+ onProgress({ phase: "Send recovery", failedMintQuotes });
157865
+ await coco.ops.send.recovery.run();
157866
+ onProgress({ phase: "Melt recovery", failedMintQuotes });
157867
+ await coco.ops.melt.recovery.run();
157868
+ onProgress({ phase: "Receive recovery", failedMintQuotes });
157869
+ if (receiveOperationIds) {
157870
+ for (const operationId of receiveOperationIds) {
157871
+ try {
157872
+ await withTimeout(coco.ops.receive.refresh(operationId), 15000);
157873
+ } catch (error53) {
157874
+ logger5.warn("Targeted receive recovery did not complete", {
157875
+ operationId,
157876
+ error: error53 instanceof Error ? error53.message : String(error53)
157877
+ });
157878
+ }
157879
+ }
157880
+ } else {
157881
+ await coco.ops.receive.recovery.run();
157882
+ }
157883
+ onProgress({ phase: "Mint recovery", failedMintQuotes });
157884
+ await coco.recoverPendingMintOperations();
157885
+ onProgress({ phase: "done", failedMintQuotes });
157886
+ } finally {
157887
+ surfacingRecoveryProgress = false;
157888
+ }
157509
157889
  }
157510
157890
  async function createCocoClient(options = {}) {
157511
157891
  const configDir = options.walletDir || options.configDir || walletDir();
@@ -157516,12 +157896,12 @@ async function createCocoClient(options = {}) {
157516
157896
  const legacyPidFile = options.legacyPidPath || legacyCocodPidPath();
157517
157897
  const npcBaseUrl = options.npcBaseUrl || NPC_DEFAULT_BASE_URL;
157518
157898
  const npcAddressDomain = new URL(npcBaseUrl).host;
157519
- await assertLegacyCocodNotRunning({
157520
- socketPath: legacySocket,
157521
- pidFilePath: legacyPidFile
157522
- });
157899
+ await assertLegacyCocodNotRunning({ socketPath: legacySocket });
157523
157900
  mkdirSync8(dirname7(legacyPidFile), { recursive: true, mode: 448 });
157524
- const releaseWalletPidClaim = claimPidFile({ pidFilePath: walletPidFile });
157901
+ const releaseWalletPidClaim = claimPidFile({
157902
+ pidFilePath: walletPidFile,
157903
+ label: "routstrd wallet lock"
157904
+ });
157525
157905
  let releaseLegacyPidClaim;
157526
157906
  try {
157527
157907
  releaseLegacyPidClaim = claimLegacyCocodPidFile({
@@ -157533,7 +157913,21 @@ async function createCocoClient(options = {}) {
157533
157913
  }
157534
157914
  let database;
157535
157915
  let coco;
157916
+ let findFinalizedReceiveSibling = async () => null;
157536
157917
  let walletConfig = loadConfig2(configFile);
157918
+ let recoveryPhase = "queued";
157919
+ let recoveryFailedMintQuotes = 0;
157920
+ let recoveryDone = false;
157921
+ let recoveryError;
157922
+ const recoveryCounts = {
157923
+ pendingSends: 0,
157924
+ inflightProofs: 0,
157925
+ pendingMints: 0
157926
+ };
157927
+ let recoveryResolve;
157928
+ const recoveryPromise = new Promise((resolve) => {
157929
+ recoveryResolve = resolve;
157930
+ });
157537
157931
  try {
157538
157932
  startupProgress("Opening Cashu wallet database...");
157539
157933
  const mnemonic = walletConfig.mnemonic;
@@ -157541,22 +157935,88 @@ async function createCocoClient(options = {}) {
157541
157935
  database = new Database2(dbPath);
157542
157936
  const repo = new SqliteRepositories({ database });
157543
157937
  await repo.init();
157938
+ initReceiveDedupSchema(database);
157939
+ const interruptedReservations = clearInterruptedReceiveReservations(database);
157940
+ if (interruptedReservations > 0) {
157941
+ logger5.warn("Cleared interrupted receive reservations with no Coco operation", {
157942
+ count: interruptedReservations
157943
+ });
157944
+ }
157544
157945
  const [pendingSends, inflightProofs, pendingMints] = await Promise.all([
157545
157946
  repo.sendOperationRepository.getPending(),
157546
157947
  repo.proofRepository.getInflightProofs(),
157547
157948
  repo.mintOperationRepository.getPending()
157548
157949
  ]);
157549
- const recoveryCount = pendingSends.length + inflightProofs.length + pendingMints.length;
157950
+ recoveryCounts.pendingSends = pendingSends.length;
157951
+ recoveryCounts.inflightProofs = inflightProofs.length;
157952
+ recoveryCounts.pendingMints = pendingMints.length;
157953
+ const recoveryCount = recoveryCounts.pendingSends + recoveryCounts.inflightProofs + recoveryCounts.pendingMints;
157550
157954
  if (recoveryCount > 0) {
157551
- startupProgress(`Recovering wallet state: ${pendingSends.length} pending sends, ` + `${inflightProofs.length} in-flight proofs, ${pendingMints.length} pending mints. ` + "This may take a few minutes while Cashu mints are contacted.");
157955
+ startupProgress(`Recovering wallet state in background: ${recoveryCounts.pendingSends} pending sends, ` + `${recoveryCounts.inflightProofs} in-flight proofs, ${recoveryCounts.pendingMints} pending mints.`);
157552
157956
  } else {
157553
157957
  startupProgress("Initializing Cashu wallet...");
157554
157958
  }
157555
- coco = await initializeCoco({
157556
- repo,
157557
- seedGetter: async () => seed,
157558
- logger: createCocoLogger()
157559
- });
157959
+ coco = constructCocoManager(repo, seed);
157960
+ const executingReceives = await repo.receiveOperationRepository.getByState("executing");
157961
+ let receiveRecoveryOperationIds;
157962
+ if (executingReceives.length > 0) {
157963
+ startupProgress(`Checking ${executingReceives.length} unfinished Cashu receive operation(s) for duplicates...`);
157964
+ const recordedBackup = getReceiveReconcileBackup(database);
157965
+ if (!recordedBackup || !existsSync9(recordedBackup)) {
157966
+ const backupPath = `${dbPath}.pre-receive-reconcile-${Date.now()}`;
157967
+ database.exec(`VACUUM INTO '${backupPath.replaceAll("'", "''")}'`);
157968
+ setReceiveReconcileBackup(database, backupPath);
157969
+ startupProgress(`Created wallet backup before receive cleanup: ${backupPath}`);
157970
+ }
157971
+ const receiveReconcile = await reconcileDuplicateReceiveOperations(coco, repo);
157972
+ receiveRecoveryOperationIds = receiveReconcile.recoveryOperationIds;
157973
+ startupProgress(`Receive cleanup: ${receiveReconcile.executing} operation(s), ` + `${receiveReconcile.uniqueGroups} unique input set(s), ` + `${receiveReconcile.rolledBack} stale duplicate(s) retired, ` + `${receiveReconcile.unresolved} unresolved.`);
157974
+ }
157975
+ await enableCocoManager(coco);
157976
+ const openDatabase = database;
157977
+ findFinalizedReceiveSibling = async (operation) => {
157978
+ if (!operation)
157979
+ return null;
157980
+ const fingerprint = receiveInputFingerprint(operation);
157981
+ const siblings = await repo.receiveOperationRepository.getByMintUrl(operation.mintUrl);
157982
+ return siblings.find((candidate) => candidate.state === "finalized" && receiveInputFingerprint(candidate) === fingerprint)?.id ?? null;
157983
+ };
157984
+ const syncReceiveReservations = async () => {
157985
+ for (const reservation of listProcessingReceiveTokens(openDatabase)) {
157986
+ if (!reservation.operationId)
157987
+ continue;
157988
+ try {
157989
+ const operation = await coco.ops.receive.get(reservation.operationId);
157990
+ if (operation?.state === "finalized") {
157991
+ updateReceiveToken(openDatabase, reservation.tokenHash, {
157992
+ state: "succeeded",
157993
+ operationId: operation.id
157994
+ });
157995
+ } else if (operation?.state === "rolled_back") {
157996
+ const finalizedSibling = await findFinalizedReceiveSibling(operation);
157997
+ updateReceiveToken(openDatabase, reservation.tokenHash, finalizedSibling ? {
157998
+ state: "succeeded",
157999
+ operationId: finalizedSibling
158000
+ } : {
158001
+ state: "failed",
158002
+ operationId: operation.id,
158003
+ error: operation.error || "Token receive was rolled back"
158004
+ });
158005
+ } else if (operation?.state === "prepared") {
158006
+ await coco.ops.receive.cancel(operation.id, "Cancelled interrupted receive before execution");
158007
+ deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
158008
+ } else if (!operation) {
158009
+ deleteReceiveTokenReservation(openDatabase, reservation.tokenHash);
158010
+ }
158011
+ } catch (error53) {
158012
+ logger5.warn("Could not reconcile receive token reservation", {
158013
+ operationId: reservation.operationId,
158014
+ error: error53 instanceof Error ? error53.message : String(error53)
158015
+ });
158016
+ }
158017
+ }
158018
+ };
158019
+ await syncReceiveReservations();
157560
158020
  const trustedMints = await coco.mint.getAllTrustedMints();
157561
158021
  const configuredDefault = walletConfig.defaultMintUrl;
157562
158022
  const defaultMintUrl = normalizeMintUrl(configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL);
@@ -157579,6 +158039,25 @@ async function createCocoClient(options = {}) {
157579
158039
  coco.use(npcPlugin);
157580
158040
  }
157581
158041
  startupProgress("Cashu wallet ready.");
158042
+ runWalletRecovery(coco, (progress) => {
158043
+ recoveryPhase = progress.phase;
158044
+ recoveryFailedMintQuotes = progress.failedMintQuotes;
158045
+ if (progress.phase !== "done") {
158046
+ startupProgress(`Wallet recovery: ${progress.phase}...`);
158047
+ }
158048
+ }, receiveRecoveryOperationIds).then(async () => {
158049
+ await syncReceiveReservations();
158050
+ recoveryDone = true;
158051
+ recoveryPhase = "done";
158052
+ recoveryResolve?.();
158053
+ startupProgress("Wallet recovery complete.");
158054
+ }).catch((error53) => {
158055
+ recoveryDone = true;
158056
+ recoveryPhase = "error";
158057
+ recoveryError = error53 instanceof Error ? error53.message : String(error53);
158058
+ recoveryResolve?.();
158059
+ startupProgress(`Wallet recovery failed: ${recoveryError}`);
158060
+ });
157582
158061
  } catch (error53) {
157583
158062
  database?.close();
157584
158063
  releaseLegacyPidClaim();
@@ -157593,6 +158072,13 @@ async function createCocoClient(options = {}) {
157593
158072
  return api2;
157594
158073
  };
157595
158074
  let disposed = false;
158075
+ const waitForRecovery = async () => {
158076
+ if (!recoveryDone)
158077
+ await recoveryPromise;
158078
+ if (recoveryError) {
158079
+ throw new Error(`Wallet is not ready: ${recoveryError}`);
158080
+ }
158081
+ };
157596
158082
  return {
157597
158083
  async ping() {
157598
158084
  try {
@@ -157603,6 +158089,10 @@ async function createCocoClient(options = {}) {
157603
158089
  }
157604
158090
  },
157605
158091
  async getStatus() {
158092
+ if (recoveryError)
158093
+ return "ERROR";
158094
+ if (!recoveryDone)
158095
+ return "RECOVERING";
157606
158096
  try {
157607
158097
  await coco.wallet.balances.total();
157608
158098
  return "UNLOCKED";
@@ -157610,6 +158100,17 @@ async function createCocoClient(options = {}) {
157610
158100
  return "ERROR";
157611
158101
  }
157612
158102
  },
158103
+ async getRecoveryProgress() {
158104
+ return {
158105
+ state: recoveryError ? "ERROR" : recoveryDone ? "UNLOCKED" : "RECOVERING",
158106
+ phase: recoveryPhase,
158107
+ pendingSends: recoveryCounts.pendingSends,
158108
+ inflightProofs: recoveryCounts.inflightProofs,
158109
+ pendingMints: recoveryCounts.pendingMints,
158110
+ failedMintQuotes: recoveryFailedMintQuotes,
158111
+ ...recoveryError ? { error: recoveryError } : {}
158112
+ };
158113
+ },
157613
158114
  async unlock(_passphrase) {
157614
158115
  return "wallet does not require unlocking";
157615
158116
  },
@@ -157621,10 +158122,120 @@ async function createCocoClient(options = {}) {
157621
158122
  ]));
157622
158123
  },
157623
158124
  async receiveCashu(token) {
157624
- await coco.wallet.receive(token);
157625
- return "Token received successfully";
158125
+ const reservation = reserveReceiveToken(database, token);
158126
+ if (!reservation.acquired) {
158127
+ if (reservation.existing?.state === "succeeded") {
158128
+ return "Token already received successfully";
158129
+ }
158130
+ if (reservation.existing?.state === "processing" && reservation.existing.operationId) {
158131
+ try {
158132
+ const operation = await withTimeout(coco.ops.receive.refresh(reservation.existing.operationId), 15000);
158133
+ if (operation.state === "finalized") {
158134
+ updateReceiveToken(database, reservation.tokenHash, {
158135
+ state: "succeeded",
158136
+ operationId: operation.id
158137
+ });
158138
+ return "Token received successfully";
158139
+ }
158140
+ if (operation.state === "rolled_back") {
158141
+ const finalizedSibling = await findFinalizedReceiveSibling(operation);
158142
+ if (finalizedSibling) {
158143
+ updateReceiveToken(database, reservation.tokenHash, {
158144
+ state: "succeeded",
158145
+ operationId: finalizedSibling
158146
+ });
158147
+ return "Token already received successfully";
158148
+ }
158149
+ updateReceiveToken(database, reservation.tokenHash, {
158150
+ state: "failed",
158151
+ operationId: operation.id,
158152
+ error: operation.error || "Token receive was rolled back"
158153
+ });
158154
+ throw new Error(operation.error || "Token receive was rolled back");
158155
+ }
158156
+ } catch (error53) {
158157
+ const latest = await coco.ops.receive.get(reservation.existing.operationId);
158158
+ if (latest?.state === "finalized") {
158159
+ updateReceiveToken(database, reservation.tokenHash, {
158160
+ state: "succeeded",
158161
+ operationId: latest.id
158162
+ });
158163
+ return "Token received successfully";
158164
+ }
158165
+ throw error53;
158166
+ }
158167
+ throw new Error("Token receive is still unresolved");
158168
+ }
158169
+ if (reservation.existing?.state === "processing") {
158170
+ throw new Error("Token receive is already in progress");
158171
+ }
158172
+ throw new Error(reservation.existing?.error || "Token receive previously failed");
158173
+ }
158174
+ let preparedOperationId;
158175
+ try {
158176
+ await waitForRecovery();
158177
+ const prepared = await coco.ops.receive.prepare({ token });
158178
+ preparedOperationId = prepared.id;
158179
+ updateReceiveToken(database, reservation.tokenHash, {
158180
+ state: "processing",
158181
+ operationId: prepared.id
158182
+ });
158183
+ await coco.ops.receive.execute(prepared.id);
158184
+ updateReceiveToken(database, reservation.tokenHash, {
158185
+ state: "succeeded",
158186
+ operationId: prepared.id
158187
+ });
158188
+ return "Token received successfully";
158189
+ } catch (error53) {
158190
+ const message = error53 instanceof Error ? error53.message : String(error53);
158191
+ if (preparedOperationId) {
158192
+ let latest = null;
158193
+ try {
158194
+ latest = await coco.ops.receive.get(preparedOperationId);
158195
+ } catch (lookupError) {
158196
+ logger5.warn("Could not inspect failed receive operation", {
158197
+ operationId: preparedOperationId,
158198
+ error: lookupError instanceof Error ? lookupError.message : String(lookupError)
158199
+ });
158200
+ }
158201
+ if (latest?.state === "finalized") {
158202
+ updateReceiveToken(database, reservation.tokenHash, {
158203
+ state: "succeeded",
158204
+ operationId: preparedOperationId
158205
+ });
158206
+ return "Token received successfully";
158207
+ }
158208
+ if (latest?.state === "executing") {
158209
+ updateReceiveToken(database, reservation.tokenHash, {
158210
+ state: "processing",
158211
+ operationId: preparedOperationId,
158212
+ error: message
158213
+ });
158214
+ } else if (latest?.state === "rolled_back") {
158215
+ const finalizedSibling = await findFinalizedReceiveSibling(latest);
158216
+ if (finalizedSibling) {
158217
+ updateReceiveToken(database, reservation.tokenHash, {
158218
+ state: "succeeded",
158219
+ operationId: finalizedSibling
158220
+ });
158221
+ return "Token already received successfully";
158222
+ }
158223
+ updateReceiveToken(database, reservation.tokenHash, {
158224
+ state: "failed",
158225
+ operationId: preparedOperationId,
158226
+ error: latest.error || message
158227
+ });
158228
+ } else {
158229
+ deleteReceiveTokenReservation(database, reservation.tokenHash);
158230
+ }
158231
+ } else {
158232
+ releaseReceiveToken(database, reservation.tokenHash);
158233
+ }
158234
+ throw error53;
158235
+ }
157626
158236
  },
157627
158237
  async receiveBolt11(amount, mintUrl) {
158238
+ await waitForRecovery();
157628
158239
  const targetMint = mintUrl ? normalizeMintUrl(mintUrl) : walletConfig.defaultMintUrl;
157629
158240
  if (!targetMint) {
157630
158241
  throw new Error("No trusted mint available for Lightning invoice");
@@ -157640,6 +158251,7 @@ async function createCocoClient(options = {}) {
157640
158251
  return op.request;
157641
158252
  },
157642
158253
  async sendCashu(amount, mintUrl) {
158254
+ await waitForRecovery();
157643
158255
  const targetMint = mintUrl ? normalizeMintUrl(mintUrl) : walletConfig.defaultMintUrl;
157644
158256
  if (!targetMint) {
157645
158257
  throw new Error("No trusted mint available for sending");
@@ -157652,6 +158264,7 @@ async function createCocoClient(options = {}) {
157652
158264
  return ar(token);
157653
158265
  },
157654
158266
  async sendBolt11(invoice, mintUrl) {
158267
+ await waitForRecovery();
157655
158268
  const targetMint = mintUrl ? normalizeMintUrl(mintUrl) : walletConfig.defaultMintUrl;
157656
158269
  if (!targetMint) {
157657
158270
  throw new Error("No trusted mint available for Lightning payment");
@@ -157669,6 +158282,7 @@ async function createCocoClient(options = {}) {
157669
158282
  return mints.map((m2) => m2.mintUrl);
157670
158283
  },
157671
158284
  async addMint(url5) {
158285
+ await waitForRecovery();
157672
158286
  const mintUrl = normalizeMintUrl(url5);
157673
158287
  await coco.mint.addMint(mintUrl, { trusted: true });
157674
158288
  return `Mint ${mintUrl} added successfully`;
@@ -157680,6 +158294,7 @@ async function createCocoClient(options = {}) {
157680
158294
  return walletConfig.defaultMintUrl || null;
157681
158295
  },
157682
158296
  async setDefaultMint(url5) {
158297
+ await waitForRecovery();
157683
158298
  const mintUrl = normalizeMintUrl(url5);
157684
158299
  const trustedMints = await coco.mint.getAllTrustedMints();
157685
158300
  if (!trustedMints.some((mint) => mint.mintUrl === mintUrl)) {
@@ -157694,6 +158309,7 @@ async function createCocoClient(options = {}) {
157694
158309
  return;
157695
158310
  disposed = true;
157696
158311
  try {
158312
+ await recoveryPromise;
157697
158313
  await coco.dispose();
157698
158314
  } finally {
157699
158315
  try {
@@ -157718,6 +158334,7 @@ async function createCocoClient(options = {}) {
157718
158334
  };
157719
158335
  },
157720
158336
  async setNpcUsername(username, confirm) {
158337
+ await waitForRecovery();
157721
158338
  const result = await npcApi().setUsername(username, confirm === true);
157722
158339
  if (result.success) {
157723
158340
  return { success: true };
@@ -157728,7 +158345,77 @@ async function createCocoClient(options = {}) {
157728
158345
  };
157729
158346
  },
157730
158347
  async syncNpc() {
158348
+ await waitForRecovery();
157731
158349
  await npcApi().sync();
158350
+ },
158351
+ async cleanupStuckOperations(options2 = {}) {
158352
+ await waitForRecovery();
158353
+ const minAgeMs = options2.minAgeMs ?? 7 * 24 * 60 * 60 * 1000;
158354
+ const dryRun = options2.dryRun === true;
158355
+ const nowMs = Date.now();
158356
+ const [pendingMints, inFlightSends, preparedMelts] = await Promise.all([
158357
+ coco.ops.mint.listPending(),
158358
+ coco.ops.send.listInFlight(),
158359
+ coco.ops.melt.listPrepared()
158360
+ ]);
158361
+ const filteredMints = options2.mintUrl ? pendingMints.filter((op) => op.mintUrl === options2.mintUrl) : pendingMints;
158362
+ const filteredSends = options2.mintUrl ? inFlightSends.filter((op) => op.mintUrl === options2.mintUrl) : inFlightSends;
158363
+ const filteredMelts = options2.mintUrl ? preparedMelts.filter((op) => op.mintUrl === options2.mintUrl) : preparedMelts;
158364
+ const selection = selectCleanupOperations({
158365
+ mints: filteredMints,
158366
+ sends: filteredSends,
158367
+ melts: filteredMelts,
158368
+ nowMs,
158369
+ minAgeMs
158370
+ });
158371
+ const errors5 = [];
158372
+ if (!dryRun) {
158373
+ const mintService = coco.mintOperationService;
158374
+ for (const op of selection.mintsToFail) {
158375
+ try {
158376
+ await mintService.failPendingOperation({ id: op.id }, {
158377
+ reason: "Expired unpaid mint quote cleaned up by routstrd",
158378
+ retryable: false,
158379
+ observedAt: nowMs
158380
+ });
158381
+ } catch (error53) {
158382
+ errors5.push({
158383
+ operationId: op.id,
158384
+ error: error53 instanceof Error ? error53.message : String(error53)
158385
+ });
158386
+ }
158387
+ }
158388
+ for (const op of selection.sendsToReclaim) {
158389
+ try {
158390
+ await coco.ops.send.reclaim(op.id);
158391
+ } catch (error53) {
158392
+ errors5.push({
158393
+ operationId: op.id,
158394
+ error: error53 instanceof Error ? error53.message : String(error53)
158395
+ });
158396
+ }
158397
+ }
158398
+ for (const op of selection.meltsToCancel) {
158399
+ try {
158400
+ await coco.ops.melt.cancel(op.id, "Cancelled by wallet cleanup");
158401
+ } catch (error53) {
158402
+ errors5.push({
158403
+ operationId: op.id,
158404
+ error: error53 instanceof Error ? error53.message : String(error53)
158405
+ });
158406
+ }
158407
+ }
158408
+ }
158409
+ const actedOn = selection.mintsToFail.length + selection.sendsToReclaim.length + selection.meltsToCancel.length;
158410
+ const skipped = filteredMints.length + filteredSends.length + filteredMelts.length - actedOn;
158411
+ return {
158412
+ dryRun,
158413
+ failedMintQuotes: selection.mintsToFail.length,
158414
+ reclaimedSends: selection.sendsToReclaim.length,
158415
+ cancelledMelts: selection.meltsToCancel.length,
158416
+ skipped,
158417
+ errors: errors5
158418
+ };
157732
158419
  }
157733
158420
  };
157734
158421
  }
@@ -157737,16 +158424,25 @@ async function createCocoClient(options = {}) {
157737
158424
  import {
157738
158425
  chmodSync,
157739
158426
  copyFileSync,
157740
- existsSync as existsSync10,
158427
+ existsSync as existsSync12,
157741
158428
  mkdirSync as mkdirSync9,
157742
- readFileSync as readFileSync3,
157743
158429
  renameSync as renameSync2,
157744
158430
  rmSync,
157745
- statSync
158431
+ statSync as statSync3
157746
158432
  } from "fs";
158433
+ import { Database as Database4 } from "bun:sqlite";
158434
+ import { basename, dirname as dirname8, join as join14 } from "path";
158435
+
158436
+ // src/daemon/wallet/diagnostics.ts
158437
+ import { createHash as createHash4 } from "crypto";
158438
+ import { existsSync as existsSync11, readFileSync as readFileSync4, statSync as statSync2 } from "fs";
157747
158439
  import { Database as Database3 } from "bun:sqlite";
157748
- import { basename, dirname as dirname8, join as join12 } from "path";
157749
- function state(configPath, dbPath) {
158440
+ import { join as join13 } from "path";
158441
+
158442
+ // src/daemon/wallet/wallet-state.ts
158443
+ import { existsSync as existsSync10, readFileSync as readFileSync3, statSync } from "fs";
158444
+ import { join as join12 } from "path";
158445
+ function walletState(configPath, dbPath) {
157750
158446
  const hasConfig = existsSync10(configPath);
157751
158447
  const hasDb = existsSync10(dbPath);
157752
158448
  if (hasConfig)
@@ -157762,9 +158458,36 @@ function filesEqual(left, right) {
157762
158458
  return false;
157763
158459
  return readFileSync3(left).equals(readFileSync3(right));
157764
158460
  }
157765
- function sqlString(value) {
157766
- return `'${value.replaceAll("'", "''")}'`;
158461
+ function classifyWalletMigration(targetDir, sourceDir) {
158462
+ const targetConfig = join12(targetDir, "config.json");
158463
+ const targetDb = join12(targetDir, "coco.db");
158464
+ const sourceConfig = join12(sourceDir, "config.json");
158465
+ const sourceDb = join12(sourceDir, "coco.db");
158466
+ const sourceWal = `${sourceDb}-wal`;
158467
+ const sourceShm = `${sourceDb}-shm`;
158468
+ const targetState = walletState(targetConfig, targetDb);
158469
+ const sourceState = walletState(sourceConfig, sourceDb);
158470
+ if (targetState === "initialized" && sourceState === "initialized") {
158471
+ const configsMatch = filesEqual(targetConfig, sourceConfig);
158472
+ const databasesMatch = !existsSync10(targetDb) && !existsSync10(sourceDb) ? true : filesEqual(targetDb, sourceDb);
158473
+ if (configsMatch && databasesMatch)
158474
+ return { kind: "already-current" };
158475
+ return { kind: "conflict" };
158476
+ }
158477
+ if (targetState === "initialized")
158478
+ return { kind: "already-current" };
158479
+ if (!existsSync10(sourceDb) && (existsSync10(sourceWal) || existsSync10(sourceShm))) {
158480
+ return { kind: "orphaned-sidecars" };
158481
+ }
158482
+ if (targetState === "database-only" || sourceState === "database-only") {
158483
+ return { kind: "database-only", targetState, sourceState };
158484
+ }
158485
+ if (sourceState === "absent")
158486
+ return { kind: "fresh" };
158487
+ return { kind: "migrate" };
157767
158488
  }
158489
+
158490
+ // src/daemon/wallet/diagnostics.ts
157768
158491
  var SUMMARY_QUERIES = {
157769
158492
  proofs: "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
157770
158493
  counters: "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
@@ -157786,8 +158509,241 @@ function verifyDatabase(database, label) {
157786
158509
  }
157787
158510
  return summary;
157788
158511
  }
158512
+ function mnemonicFingerprint(mnemonic) {
158513
+ const normalized = mnemonic.trim().split(/\s+/).join(" ");
158514
+ const hex9 = createHash4("sha256").update(normalized).digest("hex");
158515
+ return hex9.slice(0, 16).match(/.{1,4}/g)?.join("-") ?? hex9.slice(0, 16);
158516
+ }
158517
+ function summarizeDbReadonly(dbPath) {
158518
+ let database;
158519
+ try {
158520
+ database = new Database3(dbPath, { readonly: true });
158521
+ const raw = verifyDatabase(database, "Wallet database");
158522
+ const proofs = raw.proofs ?? [];
158523
+ const mints = raw.mints ?? [];
158524
+ const amountByState = {};
158525
+ let totalProofs = 0;
158526
+ let totalAmount = 0;
158527
+ for (const row of proofs) {
158528
+ const count = typeof row.count === "number" ? row.count : 0;
158529
+ const amount = typeof row.amount === "number" ? row.amount : 0;
158530
+ totalProofs += count;
158531
+ totalAmount += amount;
158532
+ const state = row.state || "unknown";
158533
+ amountByState[state] = (amountByState[state] ?? 0) + amount;
158534
+ }
158535
+ return {
158536
+ summary: {
158537
+ proofs,
158538
+ mints,
158539
+ totalProofs,
158540
+ totalAmount,
158541
+ amountByState,
158542
+ distinctMints: new Set(proofs.map((row) => row.mintUrl).filter(Boolean)).size
158543
+ }
158544
+ };
158545
+ } catch (error53) {
158546
+ return { error: error53 instanceof Error ? error53.message : String(error53) };
158547
+ } finally {
158548
+ database?.close();
158549
+ }
158550
+ }
158551
+ function summarizeConfig(configPath) {
158552
+ const result = { exists: false, path: configPath };
158553
+ try {
158554
+ if (!existsSync11(configPath))
158555
+ return result;
158556
+ result.exists = true;
158557
+ result.mtimeMs = statSync2(configPath).mtimeMs;
158558
+ const parsed = JSON.parse(readFileSync4(configPath, "utf-8"));
158559
+ result.encrypted = parsed.encrypted === true;
158560
+ if (typeof parsed.createdAt === "string")
158561
+ result.createdAt = parsed.createdAt;
158562
+ if (typeof parsed.version === "number")
158563
+ result.version = parsed.version;
158564
+ if (typeof parsed.defaultMintUrl === "string") {
158565
+ result.defaultMintUrl = parsed.defaultMintUrl;
158566
+ }
158567
+ if (typeof parsed.mnemonic === "string") {
158568
+ result.hasMnemonic = true;
158569
+ if (!result.encrypted)
158570
+ result.fingerprint = mnemonicFingerprint(parsed.mnemonic);
158571
+ }
158572
+ } catch (error53) {
158573
+ result.error = error53 instanceof Error ? error53.message : String(error53);
158574
+ }
158575
+ return result;
158576
+ }
158577
+ function summarizeDb(dbPath) {
158578
+ const result = { exists: false, path: dbPath };
158579
+ try {
158580
+ if (!existsSync11(dbPath))
158581
+ return result;
158582
+ result.exists = true;
158583
+ const stats = statSync2(dbPath);
158584
+ result.mtimeMs = stats.mtimeMs;
158585
+ result.sizeBytes = stats.size;
158586
+ const { summary, error: error53 } = summarizeDbReadonly(dbPath);
158587
+ if (summary)
158588
+ result.summary = summary;
158589
+ if (error53)
158590
+ result.error = error53;
158591
+ } catch (error53) {
158592
+ result.error = error53 instanceof Error ? error53.message : String(error53);
158593
+ }
158594
+ return result;
158595
+ }
158596
+ function summarizeWalletDirectory(dir, role) {
158597
+ return {
158598
+ dir,
158599
+ role,
158600
+ config: summarizeConfig(join13(dir, "config.json")),
158601
+ db: summarizeDb(join13(dir, "coco.db"))
158602
+ };
158603
+ }
158604
+
158605
+ class WalletMigrationConflictError extends Error {
158606
+ target;
158607
+ source;
158608
+ constructor(target, source) {
158609
+ super(renderWalletConflict(target, source));
158610
+ this.name = "WalletMigrationConflictError";
158611
+ this.target = target;
158612
+ this.source = source;
158613
+ }
158614
+ }
158615
+ function timeAgo(ms) {
158616
+ if (ms === undefined || !Number.isFinite(ms))
158617
+ return;
158618
+ const elapsed = Date.now() - ms;
158619
+ if (elapsed < 0)
158620
+ return;
158621
+ const seconds = Math.floor(elapsed / 1000);
158622
+ if (seconds < 5)
158623
+ return "just now";
158624
+ if (seconds < 60)
158625
+ return `${seconds}s ago`;
158626
+ const minutes = Math.floor(seconds / 60);
158627
+ if (minutes < 60)
158628
+ return `${minutes}m ago`;
158629
+ const hours = Math.floor(minutes / 60);
158630
+ if (hours < 48)
158631
+ return `${hours}h ago`;
158632
+ const days = Math.floor(hours / 24);
158633
+ if (days < 60)
158634
+ return `${days}d ago`;
158635
+ const months = Math.floor(days / 30);
158636
+ if (months < 24)
158637
+ return `${months}mo ago`;
158638
+ return `${Math.floor(days / 365)}y ago`;
158639
+ }
158640
+ function modifiedDescription(config2) {
158641
+ if (config2.createdAt)
158642
+ return `created ${config2.createdAt}`;
158643
+ const ago = timeAgo(config2.mtimeMs);
158644
+ if (ago)
158645
+ return `modified ${ago}`;
158646
+ return "modified time unknown";
158647
+ }
158648
+ function roleLabel(role) {
158649
+ return role === "canonical" ? "current routstrd wallet" : "legacy cocod wallet";
158650
+ }
158651
+ function configLine(config2) {
158652
+ if (!config2.exists)
158653
+ return "config.json absent";
158654
+ if (config2.error)
158655
+ return `config.json present (unreadable: ${config2.error})`;
158656
+ const parts = ["config.json present"];
158657
+ if (config2.fingerprint)
158658
+ parts.push(`fingerprint ${config2.fingerprint}`);
158659
+ else if (config2.encrypted)
158660
+ parts.push("encrypted (mnemonic not readable)");
158661
+ else
158662
+ parts.push("no mnemonic found");
158663
+ parts.push(modifiedDescription(config2));
158664
+ if (config2.defaultMintUrl)
158665
+ parts.push(`default mint ${config2.defaultMintUrl}`);
158666
+ return parts.join(" \xB7 ");
158667
+ }
158668
+ function formatNumber(value) {
158669
+ return value.toLocaleString("en-US");
158670
+ }
158671
+ function amountByStateLine(summary) {
158672
+ const parts = Object.entries(summary.amountByState).filter(([, amount]) => amount > 0);
158673
+ if (parts.length === 0)
158674
+ return "0 sats";
158675
+ return parts.map(([state, amount]) => `${state}: ${formatNumber(amount)} sats`).join(", ");
158676
+ }
158677
+ function dbLine(db) {
158678
+ if (!db.exists)
158679
+ return "coco.db absent";
158680
+ if (db.error && !db.summary)
158681
+ return `coco.db present (unreadable: ${db.error})`;
158682
+ const parts = ["coco.db present"];
158683
+ if (db.summary) {
158684
+ const { totalProofs, mints, distinctMints } = db.summary;
158685
+ const mintCount = mints.length > 0 ? mints.length : distinctMints;
158686
+ parts.push(`${formatNumber(totalProofs)} proof${totalProofs === 1 ? "" : "s"}`);
158687
+ parts.push(amountByStateLine(db.summary));
158688
+ parts.push(`${mintCount} mint${mintCount === 1 ? "" : "s"}`);
158689
+ }
158690
+ return parts.join(" \xB7 ");
158691
+ }
158692
+ function block(diag, letter) {
158693
+ return [
158694
+ ` ${letter}) ${diag.dir} (${roleLabel(diag.role)})`,
158695
+ ` ${configLine(diag.config)}`,
158696
+ ` ${dbLine(diag.db)}`
158697
+ ].join(`
158698
+ `);
158699
+ }
158700
+ function moveAsideCommands(target, source) {
158701
+ return [
158702
+ " \u2022 If B is your wallet (keep the legacy wallet):",
158703
+ ` mv "${target.dir}" "${target.dir}.old"`,
158704
+ "",
158705
+ " \u2022 If A is your wallet (keep the normal routstrd wallet):",
158706
+ ` mv "${source.dir}" "${source.dir}.old"`
158707
+ ].join(`
158708
+ `);
158709
+ }
158710
+ function renderResolutionSteps(target, source) {
158711
+ return [
158712
+ "To resolve, decide which wallet is yours, move the other one aside, and run",
158713
+ "'routstrd onboard' (or 'routstrd start') again. Stop the daemon first:",
158714
+ "",
158715
+ " routstrd stop",
158716
+ "",
158717
+ "Then:",
158718
+ "",
158719
+ moveAsideCommands(target, source)
158720
+ ].join(`
158721
+ `);
158722
+ }
158723
+ function renderWalletConflict(target, source) {
158724
+ return [
158725
+ "Cannot migrate wallet: two different wallets were found, and routstrd will",
158726
+ "not choose a mnemonic or merge databases automatically \u2014 the wrong choice",
158727
+ "could lose access to your funds.",
158728
+ "",
158729
+ block(target, "A"),
158730
+ block(source, "B"),
158731
+ "",
158732
+ "No files were changed.",
158733
+ "",
158734
+ renderResolutionSteps(target, source),
158735
+ "",
158736
+ "For a full comparison and safe cleanup, run: routstrd wallet doctor"
158737
+ ].join(`
158738
+ `);
158739
+ }
158740
+
158741
+ // src/daemon/wallet/migration.ts
158742
+ function sqlString(value) {
158743
+ return `'${value.replaceAll("'", "''")}'`;
158744
+ }
157789
158745
  function snapshotDatabase(sourcePath, destinationPath) {
157790
- const source = new Database3(sourcePath);
158746
+ const source = new Database4(sourcePath);
157791
158747
  let sourceSummary;
157792
158748
  try {
157793
158749
  sourceSummary = verifyDatabase(source, "Legacy wallet database");
@@ -157795,7 +158751,7 @@ function snapshotDatabase(sourcePath, destinationPath) {
157795
158751
  } finally {
157796
158752
  source.close();
157797
158753
  }
157798
- const destination = new Database3(destinationPath, { readonly: true });
158754
+ const destination = new Database4(destinationPath, { readonly: true });
157799
158755
  try {
157800
158756
  const destinationSummary = verifyDatabase(destination, "Staged wallet database");
157801
158757
  if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) {
@@ -157808,48 +158764,42 @@ function snapshotDatabase(sourcePath, destinationPath) {
157808
158764
  async function migrateLegacyWallet(options = {}) {
157809
158765
  const targetDir = options.walletDir || walletDir();
157810
158766
  const sourceDir = options.legacyDir || legacyCocodDir();
157811
- const targetConfig = join12(targetDir, "config.json");
157812
- const targetDb = join12(targetDir, "coco.db");
157813
- const sourceConfig = join12(sourceDir, "config.json");
157814
- const sourceDb = join12(sourceDir, "coco.db");
158767
+ const sourceConfig = join14(sourceDir, "config.json");
158768
+ const sourceDb = join14(sourceDir, "coco.db");
157815
158769
  const sourceWal = `${sourceDb}-wal`;
157816
158770
  const sourceShm = `${sourceDb}-shm`;
157817
- const targetState = state(targetConfig, targetDb);
157818
- const sourceState = state(sourceConfig, sourceDb);
157819
- if (targetState === "initialized" && sourceState === "initialized") {
157820
- const configsMatch = filesEqual(targetConfig, sourceConfig);
157821
- const databasesMatch = !existsSync10(targetDb) && !existsSync10(sourceDb) ? true : filesEqual(targetDb, sourceDb);
157822
- if (configsMatch && databasesMatch)
158771
+ const classification = classifyWalletMigration(targetDir, sourceDir);
158772
+ switch (classification.kind) {
158773
+ case "fresh":
158774
+ return { status: "fresh" };
158775
+ case "already-current":
157823
158776
  return { status: "already-current" };
157824
- throw new Error(`Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` + "Refusing to choose a mnemonic or merge wallet databases automatically.");
157825
- }
157826
- if (targetState === "initialized")
157827
- return { status: "already-current" };
157828
- if (!existsSync10(sourceDb) && (existsSync10(sourceWal) || existsSync10(sourceShm))) {
157829
- throw new Error(`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` + "Restore the matching main database before migration.");
157830
- }
157831
- if (targetState === "database-only" || sourceState === "database-only") {
157832
- throw new Error(`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` + "A coco.db without config.json cannot be opened; restore the matching config first.");
158777
+ case "migrate":
158778
+ break;
158779
+ case "conflict":
158780
+ throw new WalletMigrationConflictError(summarizeWalletDirectory(targetDir, "canonical"), summarizeWalletDirectory(sourceDir, "legacy"));
158781
+ case "orphaned-sidecars":
158782
+ throw new Error(`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` + "Restore the matching main database before migration.");
158783
+ case "database-only":
158784
+ throw new Error(`Cannot migrate wallet: ${targetDir} is ${classification.targetState} and ${sourceDir} is ${classification.sourceState}. ` + "A coco.db without config.json cannot be opened; restore the matching config first.");
157833
158785
  }
157834
- if (sourceState === "absent")
157835
- return { status: "fresh" };
157836
158786
  await options.assertLegacyStopped?.();
157837
158787
  const releaseLegacyLock = await options.acquireLegacyLock?.();
157838
- const stagingDir = join12(dirname8(targetDir), `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`);
158788
+ const stagingDir = join14(dirname8(targetDir), `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`);
157839
158789
  try {
157840
158790
  await options.assertLegacyStopped?.();
157841
158791
  mkdirSync9(dirname8(targetDir), { recursive: true, mode: 448 });
157842
158792
  mkdirSync9(stagingDir, { mode: 448 });
157843
- const stagedConfig = join12(stagingDir, "config.json");
157844
- const stagedDb = join12(stagingDir, "coco.db");
158793
+ const stagedConfig = join14(stagingDir, "config.json");
158794
+ const stagedDb = join14(stagingDir, "coco.db");
157845
158795
  copyFileSync(sourceConfig, stagedConfig);
157846
158796
  chmodSync(stagedConfig, 384);
157847
- const hasSourceDb = existsSync10(sourceDb);
158797
+ const hasSourceDb = existsSync12(sourceDb);
157848
158798
  if (hasSourceDb) {
157849
158799
  snapshotDatabase(sourceDb, stagedDb);
157850
158800
  chmodSync(stagedDb, 384);
157851
158801
  }
157852
- if (statSync(stagedConfig).size !== statSync(sourceConfig).size) {
158802
+ if (statSync3(stagedConfig).size !== statSync3(sourceConfig).size) {
157853
158803
  throw new Error("Cannot migrate wallet: staged config failed size validation.");
157854
158804
  }
157855
158805
  renameSync2(stagingDir, targetDir);
@@ -157860,13 +158810,13 @@ async function migrateLegacyWallet(options = {}) {
157860
158810
  releaseLegacyLock?.();
157861
158811
  }
157862
158812
  const cleanupWarnings = [];
157863
- const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync10);
158813
+ const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync12);
157864
158814
  if (sourceFiles.length > 0) {
157865
- const archiveDir = join12(sourceDir, `wallet-migrated-${Date.now()}`);
158815
+ const archiveDir = join14(sourceDir, `wallet-migrated-${Date.now()}`);
157866
158816
  try {
157867
158817
  mkdirSync9(archiveDir, { mode: 448 });
157868
158818
  for (const path2 of sourceFiles)
157869
- renameSync2(path2, join12(archiveDir, basename(path2)));
158819
+ renameSync2(path2, join14(archiveDir, basename(path2)));
157870
158820
  } catch (error53) {
157871
158821
  const message = error53 instanceof Error ? error53.message : String(error53);
157872
158822
  cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`);
@@ -157875,6 +158825,19 @@ async function migrateLegacyWallet(options = {}) {
157875
158825
  return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings };
157876
158826
  }
157877
158827
 
158828
+ // src/daemon/fatal-error.ts
158829
+ function exitOnUncaughtException(error53, exit = (code) => process.exit(code)) {
158830
+ logger5.error("UNCAUGHT EXCEPTION:", error53);
158831
+ console.error("UNCAUGHT EXCEPTION:", error53);
158832
+ exit(1);
158833
+ }
158834
+ function installGlobalErrorHandlers() {
158835
+ process.on("uncaughtException", (error53) => exitOnUncaughtException(error53));
158836
+ process.on("unhandledRejection", (reason) => {
158837
+ logger5.error("UNHANDLED REJECTION:", reason);
158838
+ });
158839
+ }
158840
+
157878
158841
  // src/daemon/index.ts
157879
158842
  function makeSdkLogger2(prefix) {
157880
158843
  const tag2 = prefix ? `[${prefix}]` : undefined;
@@ -157893,13 +158856,15 @@ function startupProgress2(message) {
157893
158856
  logger5.info(message);
157894
158857
  console.log(`${STARTUP_LOG_PREFIX2} ${message}`);
157895
158858
  }
157896
- process.on("uncaughtException", (error53) => {
157897
- logger5.error("UNCAUGHT EXCEPTION:", error53);
157898
- });
157899
- process.on("unhandledRejection", (reason) => {
157900
- logger5.error("UNHANDLED REJECTION:", reason);
157901
- });
158859
+ installGlobalErrorHandlers();
157902
158860
  async function main() {
158861
+ let shutdownDaemon = () => process.exit(0);
158862
+ const shutdownForSignal = (signal) => {
158863
+ logger5.log(`Received ${signal}; shutting down...`);
158864
+ shutdownDaemon();
158865
+ };
158866
+ process.once("SIGINT", shutdownForSignal);
158867
+ process.once("SIGTERM", shutdownForSignal);
157903
158868
  startupProgress2("Loading configuration...");
157904
158869
  const args = parseArgs(process.argv);
157905
158870
  const config2 = await loadDaemonConfig();
@@ -157937,26 +158902,18 @@ async function main() {
157937
158902
  socketPath: legacyCocodSocketPath(),
157938
158903
  pidFilePath: legacyCocodPidPath()
157939
158904
  });
157940
- let migrationLockOwner;
157941
158905
  const migration = await migrateLegacyWallet({
157942
158906
  assertLegacyStopped: () => assertLegacyCocodNotRunning({
157943
- socketPath: legacyCocodSocketPath(),
157944
- pidFilePath: legacyCocodPidPath(),
157945
- ignorePid: migrationLockOwner
158907
+ socketPath: legacyCocodSocketPath()
157946
158908
  }),
157947
158909
  acquireLegacyLock: () => {
157948
158910
  mkdirSync10(dirname9(legacyCocodPidPath()), {
157949
158911
  recursive: true,
157950
158912
  mode: 448
157951
158913
  });
157952
- const release = claimLegacyCocodPidFile({
158914
+ return claimLegacyCocodPidFile({
157953
158915
  pidFilePath: legacyCocodPidPath()
157954
158916
  });
157955
- migrationLockOwner = process.pid;
157956
- return () => {
157957
- migrationLockOwner = undefined;
157958
- release();
157959
- };
157960
158917
  }
157961
158918
  });
157962
158919
  if (migration.status === "migrated") {
@@ -157984,9 +158941,6 @@ async function main() {
157984
158941
  });
157985
158942
  const refundClient = new RoutstrClient(walletAdapter, storageAdapter, discoveryAdapter, "min", "apikeys", { logger: daemonSdkLogger });
157986
158943
  const server = createServer();
157987
- let shutdownDaemon = () => {
157988
- server.close(() => process.exit(0));
157989
- };
157990
158944
  server.on("request", createDaemonRequestHandler({
157991
158945
  provider,
157992
158946
  server,
@@ -158011,7 +158965,7 @@ async function main() {
158011
158965
  }));
158012
158966
  Bun.write(PID_FILE, String(process.pid));
158013
158967
  try {
158014
- if (existsSync11(SOCKET_PATH)) {
158968
+ if (existsSync13(SOCKET_PATH)) {
158015
158969
  unlinkSync2(SOCKET_PATH);
158016
158970
  }
158017
158971
  } catch {}
@@ -158051,12 +159005,12 @@ async function main() {
158051
159005
  (async () => {
158052
159006
  logger5.log("Running scheduled refund...");
158053
159007
  try {
158054
- const state2 = store.getState();
158055
- const pendingDistribution = (state2.cachedTokens || []).map((t) => ({
159008
+ const state = store.getState();
159009
+ const pendingDistribution = (state.cachedTokens || []).map((t) => ({
158056
159010
  baseUrl: t.baseUrl,
158057
159011
  amount: t.balance || 0
158058
159012
  }));
158059
- const apiKeysStored = (state2.apiKeys || []).map((k6) => ({
159013
+ const apiKeysStored = (state.apiKeys || []).map((k6) => ({
158060
159014
  baseUrl: k6.baseUrl,
158061
159015
  amount: k6.balance || 0
158062
159016
  }));
@@ -158103,12 +159057,11 @@ async function main() {
158103
159057
  disposeWallet().finally(() => process.exit(0));
158104
159058
  });
158105
159059
  };
158106
- const shutdownForSignal = (signal) => {
158107
- logger5.log(`Received ${signal}; shutting down...`);
158108
- shutdownDaemon();
158109
- };
158110
- process.once("SIGINT", shutdownForSignal);
158111
- process.once("SIGTERM", shutdownForSignal);
159060
+ server.on("error", (error53) => {
159061
+ logger5.error("Failed to start Routstr daemon:", error53);
159062
+ console.error("Failed to start Routstr daemon:", error53 instanceof Error ? error53.message : error53);
159063
+ process.exit(1);
159064
+ });
158112
159065
  startupProgress2("Starting HTTP server...");
158113
159066
  server.listen(port, host, async () => {
158114
159067
  logger5.log(`Routstr daemon listening on http://${host}:${port}/v1`);