@unicitylabs/sphere-sdk 0.14.0-dev.5 → 0.14.0-dev.7

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.
@@ -23,7 +23,10 @@ var STORE_KEYS = {
23
23
  shortfalls: "shortfalls",
24
24
  settlingLinks: "settling",
25
25
  streamCursor: (s) => `cursor:${s}`,
26
- epochLatch: "epoch-latch"
26
+ epochLatch: "epoch-latch",
27
+ // §5.2 InventoryView durable overlays (#625/#679).
28
+ suspectedSpent: "suspected-spent",
29
+ knownSpends: "known-spends"
27
30
  };
28
31
 
29
32
  // node_modules/@noble/hashes/utils.js
@@ -549,6 +552,10 @@ function isValidationReject(err) {
549
552
  if (e === null) return false;
550
553
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
551
554
  }
555
+ function retryAfterMsOf(err) {
556
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
557
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
558
+ }
552
559
  var chainRegistry = /* @__PURE__ */ new WeakMap();
553
560
  var replayRegistry = /* @__PURE__ */ new WeakMap();
554
561
  function registryFor(registry, kv, storeKey, make) {
@@ -811,7 +818,7 @@ var TransferMachine = class {
811
818
  }
812
819
  if (cls === null) {
813
820
  await this.applyCommitted(engine, plan.transferId, committed);
814
- this.closeTail(plan.transferId, plan.payload);
821
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
815
822
  return { committed, deliveryPending };
816
823
  }
817
824
  if (cls === "conflict" && committed.length > 0) {
@@ -837,10 +844,10 @@ var TransferMachine = class {
837
844
  }
838
845
  await this.applyCommitted(engine, r.transferId, committed);
839
846
  if (conflicts.length > 0) {
840
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
847
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
841
848
  }
842
849
  await this.completeIntent(r.transferId, r.payload);
843
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
850
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
844
851
  return { deliveryPending, partial: null };
845
852
  }
846
853
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -1022,7 +1029,7 @@ var TransferMachine = class {
1022
1029
  }
1023
1030
  return added;
1024
1031
  }
1025
- async settlePartial(r, committed, conflicts) {
1032
+ async settlePartial(engine, r, committed, conflicts) {
1026
1033
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
1027
1034
  const entry = {
1028
1035
  transferId: r.transferId,
@@ -1034,26 +1041,27 @@ var TransferMachine = class {
1034
1041
  };
1035
1042
  await this.stores.shortfalls.upsert(entry);
1036
1043
  await this.completeIntent(r.transferId, r.payload);
1037
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
1044
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
1038
1045
  return entry;
1039
1046
  }
1040
- closeTail(transferId, payload) {
1047
+ closeTail(engine, transferId, payload, committed) {
1041
1048
  void (async () => {
1042
1049
  try {
1043
1050
  await this.completeIntent(transferId, payload);
1044
1051
  } catch {
1045
1052
  return;
1046
1053
  }
1047
- await this.finishBookkeeping(transferId, payload, "sent");
1054
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
1048
1055
  })();
1049
1056
  }
1050
1057
  async completeIntent(transferId, payload) {
1051
1058
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
1052
1059
  await this.deps.intents.complete(transferId, signature);
1053
1060
  }
1054
- async finishBookkeeping(transferId, payload, phase) {
1061
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
1055
1062
  try {
1056
- await this.deps.recordHistory?.({ transferId, payload, phase });
1063
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
1064
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
1057
1065
  } catch {
1058
1066
  }
1059
1067
  try {
@@ -1061,6 +1069,26 @@ var TransferMachine = class {
1061
1069
  } catch {
1062
1070
  }
1063
1071
  }
1072
+ /**
1073
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
1074
+ * certified recipient blobs (summarize()'s committed set is the only source;
1075
+ * a journal-rehydrated resume leg has no source token, but every committed
1076
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
1077
+ */
1078
+ async settledAmount(engine, coinId, committed) {
1079
+ let total = 0n;
1080
+ for (const outcome of committed) {
1081
+ if (outcome.recipientBlob === void 0) continue;
1082
+ const token = await engine.decodeToken({
1083
+ v: TOKEN_BLOB_VERSION,
1084
+ network: 0,
1085
+ tokenId: "",
1086
+ token: outcome.recipientBlob
1087
+ });
1088
+ total += engine.balanceOf(token, coinId);
1089
+ }
1090
+ return total.toString();
1091
+ }
1064
1092
  async disposeOnFailure(transferId, committed, cls) {
1065
1093
  if (committed.length > 0 || cls === "keep-open") return;
1066
1094
  try {
@@ -1085,7 +1113,7 @@ function conflictAmount(engine, payload, op, source) {
1085
1113
  }
1086
1114
 
1087
1115
  // modules/payments-v2/machine/resume.ts
1088
- async function resumeAll(deps) {
1116
+ async function resumeAll(deps, options = {}) {
1089
1117
  const report = { resumed: [], conflicted: [], failed: [] };
1090
1118
  const stores = createMachineStores(deps.kv);
1091
1119
  const open = await deps.intents.listOpen();
@@ -1095,7 +1123,12 @@ async function resumeAll(deps) {
1095
1123
  } catch {
1096
1124
  return report;
1097
1125
  }
1098
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
1126
+ await dropStaleBackstops(
1127
+ stores,
1128
+ locals,
1129
+ new Set(open.map((i) => i.transferId)),
1130
+ options.isLocallyActive ?? (() => false)
1131
+ );
1099
1132
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
1100
1133
  const ctx = {
1101
1134
  deps,
@@ -1108,11 +1141,16 @@ async function resumeAll(deps) {
1108
1141
  }
1109
1142
  return report;
1110
1143
  }
1111
- async function dropStaleLocalAborts(stores, locals, openIds) {
1144
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
1112
1145
  for (const entry of locals.values()) {
1113
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
1146
+ if (openIds.has(entry.transferId)) continue;
1147
+ if (entry.disposition !== "open") {
1114
1148
  await stores.backstop.removeByKey(entry.transferId);
1149
+ continue;
1115
1150
  }
1151
+ if (isLocallyActive(entry.transferId)) continue;
1152
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
1153
+ await stores.backstop.removeByKey(entry.transferId);
1116
1154
  }
1117
1155
  }
1118
1156
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -1225,6 +1263,349 @@ function validatePayload(raw) {
1225
1263
  return p;
1226
1264
  }
1227
1265
 
1266
+ // modules/payments-v2/convergence.ts
1267
+ var HEARTBEAT_SEED_MS = 5e3;
1268
+ var HEARTBEAT_CAP_MS = 12e4;
1269
+ var ConvergenceHeartbeat = class {
1270
+ constructor(deps) {
1271
+ this.deps = deps;
1272
+ }
1273
+ timer = null;
1274
+ delayMs = HEARTBEAT_SEED_MS;
1275
+ notBefore = 0;
1276
+ running = false;
1277
+ start() {
1278
+ this.running = true;
1279
+ this.delayMs = HEARTBEAT_SEED_MS;
1280
+ this.notBefore = 0;
1281
+ }
1282
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
1283
+ stop() {
1284
+ this.running = false;
1285
+ this.clearTimer();
1286
+ }
1287
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
1288
+ settle(outcome, pending) {
1289
+ if (outcome.retryAfterMs !== null) {
1290
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
1291
+ }
1292
+ if (!pending) {
1293
+ this.delayMs = HEARTBEAT_SEED_MS;
1294
+ return;
1295
+ }
1296
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
1297
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
1298
+ this.schedule(delay);
1299
+ }
1300
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
1301
+ arm() {
1302
+ if (!this.running) return;
1303
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
1304
+ this.schedule(HEARTBEAT_SEED_MS);
1305
+ }
1306
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
1307
+ recovered() {
1308
+ this.delayMs = HEARTBEAT_SEED_MS;
1309
+ if (this.timer !== null) this.arm();
1310
+ }
1311
+ schedule(delayMs) {
1312
+ this.clearTimer();
1313
+ if (!this.running) return;
1314
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
1315
+ this.timer = setTimeout(() => {
1316
+ this.timer = null;
1317
+ this.deps.onTick();
1318
+ }, floored);
1319
+ }
1320
+ clearTimer() {
1321
+ if (this.timer !== null) clearTimeout(this.timer);
1322
+ this.timer = null;
1323
+ }
1324
+ };
1325
+ var Converger = class {
1326
+ constructor(deps) {
1327
+ this.deps = deps;
1328
+ }
1329
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
1330
+ async convergeOnce() {
1331
+ let progress = false;
1332
+ let retryAfterMs = null;
1333
+ try {
1334
+ progress = await this.resumePass();
1335
+ } catch (err) {
1336
+ retryAfterMs = retryAfterMsOf(err);
1337
+ }
1338
+ try {
1339
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
1340
+ progress = progress || stats.delivered > 0;
1341
+ } catch (err) {
1342
+ retryAfterMs ??= retryAfterMsOf(err);
1343
+ }
1344
+ try {
1345
+ progress = await this.deps.replayMints() > 0 || progress;
1346
+ } catch (err) {
1347
+ retryAfterMs ??= retryAfterMsOf(err);
1348
+ }
1349
+ return { progress, retryAfterMs };
1350
+ }
1351
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
1352
+ async pendingWork() {
1353
+ const [backstop, journal, mints] = await Promise.all([
1354
+ this.deps.stores.backstop.list(),
1355
+ this.deps.stores.deliveryJournal.list(),
1356
+ this.deps.stores.mintJournal.list()
1357
+ ]);
1358
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
1359
+ }
1360
+ async resumePass() {
1361
+ const report = await resumeAll(this.filteredMachineDeps(), {
1362
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
1363
+ });
1364
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
1365
+ await this.deps.reconcile({
1366
+ resumed: report.resumed,
1367
+ conflicted: report.conflicted,
1368
+ open: report.failed
1369
+ });
1370
+ if (progress) await this.deps.refreshView().catch(() => void 0);
1371
+ return progress;
1372
+ }
1373
+ filteredMachineDeps() {
1374
+ const intents = this.deps.machineDeps.intents;
1375
+ return {
1376
+ ...this.deps.machineDeps,
1377
+ intents: {
1378
+ ...intents,
1379
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
1380
+ }
1381
+ };
1382
+ }
1383
+ replayDeps() {
1384
+ return {
1385
+ delivery: this.deps.delivery,
1386
+ now: () => this.deps.now(),
1387
+ attention: (transferId, code, detail) => {
1388
+ this.deps.emit(
1389
+ "transfer:attention",
1390
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
1391
+ );
1392
+ }
1393
+ };
1394
+ }
1395
+ };
1396
+ async function derivePendingTransfers(stores, decryptPayload) {
1397
+ const [backstop, journal, shortfalls] = await Promise.all([
1398
+ stores.backstop.list(),
1399
+ stores.deliveryJournal.list(),
1400
+ stores.shortfalls.list()
1401
+ ]);
1402
+ const byTransfer = groupJournal(journal);
1403
+ const out = [];
1404
+ const seen = /* @__PURE__ */ new Set();
1405
+ for (const entry of backstop) {
1406
+ if (entry.disposition !== "open") continue;
1407
+ seen.add(entry.transferId);
1408
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
1409
+ }
1410
+ for (const [transferId, entries] of byTransfer) {
1411
+ if (seen.has(transferId)) continue;
1412
+ seen.add(transferId);
1413
+ out.push(journalOnlyRow(transferId, entries));
1414
+ }
1415
+ for (const entry of shortfalls) {
1416
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
1417
+ }
1418
+ return out;
1419
+ }
1420
+ function groupJournal(journal) {
1421
+ const byTransfer = /* @__PURE__ */ new Map();
1422
+ for (const entry of journal) {
1423
+ const mine = byTransfer.get(entry.transferId);
1424
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
1425
+ else mine.push(entry);
1426
+ }
1427
+ return byTransfer;
1428
+ }
1429
+ async function openRow(entry, journal, decryptPayload) {
1430
+ let payload = null;
1431
+ try {
1432
+ const raw = await decryptPayload(entry.payloadEnvelope);
1433
+ if (raw !== null && typeof raw === "object") payload = raw;
1434
+ } catch {
1435
+ payload = null;
1436
+ }
1437
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
1438
+ const certified = distinctOpCount(journal);
1439
+ return {
1440
+ transferId: entry.transferId,
1441
+ kind: "open",
1442
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
1443
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
1444
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
1445
+ legs: { certified, total: Math.max(total, certified) },
1446
+ deliveryPending: journal.length > 0,
1447
+ createdAt: entry.createdAt
1448
+ };
1449
+ }
1450
+ function journalOnlyRow(transferId, entries) {
1451
+ const certified = distinctOpCount(entries);
1452
+ return {
1453
+ transferId,
1454
+ kind: "open",
1455
+ recipient: entries[0].recipientPubkey,
1456
+ coinId: "",
1457
+ amount: "",
1458
+ legs: { certified, total: certified },
1459
+ deliveryPending: true,
1460
+ createdAt: 0
1461
+ };
1462
+ }
1463
+ function shortfallRow(entry) {
1464
+ return {
1465
+ transferId: entry.transferId,
1466
+ kind: "shortfall",
1467
+ recipient: entry.recipient,
1468
+ coinId: entry.coinId,
1469
+ amount: entry.remainingAmount,
1470
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
1471
+ deliveryPending: false,
1472
+ createdAt: entry.createdAt
1473
+ };
1474
+ }
1475
+ function distinctOpCount(entries) {
1476
+ return new Set(entries.map((entry) => entry.opIndex)).size;
1477
+ }
1478
+
1479
+ // modules/payments-v2/restore.ts
1480
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
1481
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
1482
+ async function reseedAndReset(deps) {
1483
+ for (const entry of await deps.stores.backstop.list()) {
1484
+ if (entry.disposition !== "open") continue;
1485
+ try {
1486
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
1487
+ if (entry.requiresSeedClose) {
1488
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
1489
+ }
1490
+ } catch (err) {
1491
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
1492
+ }
1493
+ }
1494
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
1495
+ await deps.fullRePull();
1496
+ }
1497
+ async function splitOpIndex(deps, envelope) {
1498
+ const payload = await deps.decryptPayload(envelope);
1499
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
1500
+ }
1501
+
1502
+ // modules/payments-v2/inventory/presentation.ts
1503
+ function toToken(tokenId, snapshot, asset, registry, flags) {
1504
+ const iconUrl = registry.getIconUrl(asset.coinId);
1505
+ return {
1506
+ id: tokenId,
1507
+ coinId: asset.coinId,
1508
+ symbol: registry.getSymbol(asset.coinId),
1509
+ name: registry.getName(asset.coinId),
1510
+ decimals: registry.getDecimals(asset.coinId),
1511
+ ...iconUrl !== null ? { iconUrl } : {},
1512
+ amount: asset.amount,
1513
+ status: flags.transferring ? "transferring" : "confirmed",
1514
+ createdAt: snapshot.createdAt,
1515
+ updatedAt: snapshot.updatedAt,
1516
+ lazy: true,
1517
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
1518
+ };
1519
+ }
1520
+ function aggregateAssets(entries, isTransferring, registry) {
1521
+ const groups = /* @__PURE__ */ new Map();
1522
+ for (const [tokenId, entry] of entries) {
1523
+ const moving = isTransferring(tokenId);
1524
+ for (const asset of entry.assets) {
1525
+ let group = groups.get(asset.coinId);
1526
+ if (!group) {
1527
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
1528
+ groups.set(asset.coinId, group);
1529
+ }
1530
+ if (moving) {
1531
+ group.transferring += BigInt(asset.amount);
1532
+ group.transferringCount += 1;
1533
+ } else {
1534
+ group.total += BigInt(asset.amount);
1535
+ group.count += 1;
1536
+ }
1537
+ }
1538
+ }
1539
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
1540
+ }
1541
+ function toAsset(coinId, totals, registry) {
1542
+ const iconUrl = registry.getIconUrl(coinId);
1543
+ return {
1544
+ coinId,
1545
+ symbol: registry.getSymbol(coinId),
1546
+ name: registry.getName(coinId),
1547
+ decimals: registry.getDecimals(coinId),
1548
+ ...iconUrl !== null ? { iconUrl } : {},
1549
+ totalAmount: totals.total.toString(),
1550
+ tokenCount: totals.count,
1551
+ confirmedAmount: totals.total.toString(),
1552
+ unconfirmedAmount: "0",
1553
+ confirmedTokenCount: totals.count,
1554
+ unconfirmedTokenCount: 0,
1555
+ transferringTokenCount: totals.transferringCount,
1556
+ transferringAmount: totals.transferring.toString(),
1557
+ priceUsd: null,
1558
+ priceEur: null,
1559
+ change24h: null,
1560
+ fiatValueUsd: null,
1561
+ fiatValueEur: null
1562
+ };
1563
+ }
1564
+ async function withPrices(raw, registry, price) {
1565
+ try {
1566
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
1567
+ const quotes = await price.getPrices(names);
1568
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
1569
+ } catch {
1570
+ return raw;
1571
+ }
1572
+ }
1573
+ function priceOne(asset, quote) {
1574
+ if (!quote) return asset;
1575
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
1576
+ return {
1577
+ ...asset,
1578
+ priceUsd: quote.priceUsd,
1579
+ priceEur: quote.priceEur ?? null,
1580
+ change24h: quote.change24h ?? null,
1581
+ fiatValueUsd: human * quote.priceUsd,
1582
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
1583
+ };
1584
+ }
1585
+
1586
+ // modules/payments-v2/machine/payload.ts
1587
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
1588
+ return {
1589
+ v: 2,
1590
+ recipient: recipientPubkey,
1591
+ coinId: request.coinId,
1592
+ amount: request.amount,
1593
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
1594
+ direct: [...spend.plan.direct],
1595
+ ...spend.plan.split !== void 0 ? {
1596
+ split: {
1597
+ tokenId: spend.plan.split.tokenId,
1598
+ splitAmount: spend.plan.split.splitAmount.toString(),
1599
+ remainderAmount: spend.plan.split.remainderAmount.toString()
1600
+ }
1601
+ } : {},
1602
+ spentStates
1603
+ };
1604
+ }
1605
+ function messageOf(err) {
1606
+ return err instanceof Error ? err.message : String(err);
1607
+ }
1608
+
1228
1609
  // node_modules/@noble/ciphers/utils.js
1229
1610
  function isBytes2(a) {
1230
1611
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -2133,13 +2514,14 @@ var History = class {
2133
2514
  }
2134
2515
  // ── internals ────────────────────────────────────────────────────────────────
2135
2516
  async post(build) {
2517
+ const record = build();
2136
2518
  try {
2137
- await this.deps.client.postHistory([build()]);
2519
+ await this.deps.client.postHistory([record]);
2138
2520
  } catch (err) {
2139
2521
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
2140
2522
  return;
2141
2523
  }
2142
- this.deps.emit("history:updated");
2524
+ this.deps.emit("history:updated", this.toEntry(record));
2143
2525
  }
2144
2526
  ts(timestamp) {
2145
2527
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -2196,93 +2578,9 @@ var History = class {
2196
2578
  }
2197
2579
  };
2198
2580
 
2199
- // modules/payments-v2/inventory/presentation.ts
2200
- function toToken(tokenId, snapshot, asset, registry, flags) {
2201
- const iconUrl = registry.getIconUrl(asset.coinId);
2202
- return {
2203
- id: tokenId,
2204
- coinId: asset.coinId,
2205
- symbol: registry.getSymbol(asset.coinId),
2206
- name: registry.getName(asset.coinId),
2207
- decimals: registry.getDecimals(asset.coinId),
2208
- ...iconUrl !== null ? { iconUrl } : {},
2209
- amount: asset.amount,
2210
- status: flags.transferring ? "transferring" : "confirmed",
2211
- createdAt: snapshot.createdAt,
2212
- updatedAt: snapshot.updatedAt,
2213
- lazy: true,
2214
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
2215
- };
2216
- }
2217
- function aggregateAssets(entries, isTransferring, registry) {
2218
- const groups = /* @__PURE__ */ new Map();
2219
- for (const [tokenId, entry] of entries) {
2220
- const moving = isTransferring(tokenId);
2221
- for (const asset of entry.assets) {
2222
- let group = groups.get(asset.coinId);
2223
- if (!group) {
2224
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
2225
- groups.set(asset.coinId, group);
2226
- }
2227
- if (moving) {
2228
- group.transferring += BigInt(asset.amount);
2229
- group.transferringCount += 1;
2230
- } else {
2231
- group.total += BigInt(asset.amount);
2232
- group.count += 1;
2233
- }
2234
- }
2235
- }
2236
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
2237
- }
2238
- function toAsset(coinId, totals, registry) {
2239
- const iconUrl = registry.getIconUrl(coinId);
2240
- return {
2241
- coinId,
2242
- symbol: registry.getSymbol(coinId),
2243
- name: registry.getName(coinId),
2244
- decimals: registry.getDecimals(coinId),
2245
- ...iconUrl !== null ? { iconUrl } : {},
2246
- totalAmount: totals.total.toString(),
2247
- tokenCount: totals.count,
2248
- confirmedAmount: totals.total.toString(),
2249
- unconfirmedAmount: "0",
2250
- confirmedTokenCount: totals.count,
2251
- unconfirmedTokenCount: 0,
2252
- transferringTokenCount: totals.transferringCount,
2253
- transferringAmount: totals.transferring.toString(),
2254
- priceUsd: null,
2255
- priceEur: null,
2256
- change24h: null,
2257
- fiatValueUsd: null,
2258
- fiatValueEur: null
2259
- };
2260
- }
2261
- async function withPrices(raw, registry, price) {
2262
- try {
2263
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
2264
- const quotes = await price.getPrices(names);
2265
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
2266
- } catch {
2267
- return raw;
2268
- }
2269
- }
2270
- function priceOne(asset, quote) {
2271
- if (!quote) return asset;
2272
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
2273
- return {
2274
- ...asset,
2275
- priceUsd: quote.priceUsd,
2276
- priceEur: quote.priceEur ?? null,
2277
- change24h: quote.change24h ?? null,
2278
- fiatValueUsd: human * quote.priceUsd,
2279
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
2280
- };
2281
- }
2282
-
2283
2581
  // modules/payments-v2/inventory/InventoryView.ts
2284
- var SUSPECTED_KEY = "suspected-spent";
2285
- var KNOWN_SPENDS_KEY = "known-spends";
2582
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
2583
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
2286
2584
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
2287
2585
  function stateKey(tokenId, stateHash) {
2288
2586
  return `${tokenId}:${stateHash}`;
@@ -2678,19 +2976,24 @@ var Receive = class {
2678
2976
  const engine = deps.engine();
2679
2977
  const stored = [];
2680
2978
  const pending = [];
2681
- let epoch = "";
2979
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
2682
2980
  try {
2683
- epoch = deps.syncEpoch();
2684
2981
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
2685
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
2686
- for await (const entry of deps.delivery.incoming(since)) {
2687
- await this.processEntry(deps, engine, entry, pending, stored);
2688
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
2982
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
2983
+ for (let pass = 0; pass < 2; pass++) {
2984
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
2985
+ await this.processEntry(deps, engine, entry, pending, stored);
2986
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
2987
+ }
2988
+ await flushAcks(deps, pending, pageEpoch);
2989
+ const served = deps.delivery.incomingEpoch();
2990
+ if (basis === null || served === null || served === basis.syncEpoch) break;
2991
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
2992
+ basis = null;
2689
2993
  }
2690
- await flushAcks(deps, pending, epoch);
2691
2994
  } catch (err) {
2692
2995
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
2693
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
2996
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
2694
2997
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
2695
2998
  });
2696
2999
  }
@@ -2763,7 +3066,7 @@ async function announce(deps, entry, record) {
2763
3066
  receivedAt
2764
3067
  };
2765
3068
  }
2766
- async function flushAcks(deps, pending, epoch) {
3069
+ async function flushAcks(deps, pending, epochOf) {
2767
3070
  let lastAcked = null;
2768
3071
  try {
2769
3072
  while (pending.length > 0) {
@@ -2774,7 +3077,7 @@ async function flushAcks(deps, pending, epoch) {
2774
3077
  }
2775
3078
  } finally {
2776
3079
  if (lastAcked !== null) {
2777
- const record = { cursor: lastAcked, syncEpoch: epoch };
3080
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
2778
3081
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
2779
3082
  }
2780
3083
  }
@@ -2875,9 +3178,19 @@ var Requests = class {
2875
3178
  }
2876
3179
  });
2877
3180
  }
3181
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
2878
3182
  writeLink(requestId, transferId, committed) {
2879
3183
  return this.mutateJournal((m) => {
2880
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
3184
+ const prev = m.get(requestId);
3185
+ const same = prev !== void 0 && prev.transferId === transferId;
3186
+ const next = {
3187
+ requestId,
3188
+ transferId,
3189
+ committed: committed || same && prev.committed,
3190
+ createdAt: same ? prev.createdAt : this.now()
3191
+ };
3192
+ if (same && prev.committed === next.committed) return false;
3193
+ m.set(requestId, next);
2881
3194
  return true;
2882
3195
  });
2883
3196
  }
@@ -3030,23 +3343,46 @@ var Requests = class {
3030
3343
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
3031
3344
  if (transferId !== void 0) {
3032
3345
  const committed = error instanceof PartialSendConflictError;
3033
- await this.writeLink(id, transferId, committed);
3034
- this.setStatus(id, "settling");
3346
+ await this.settle(id, transferId, { committed, respond: false });
3035
3347
  } else {
3036
3348
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
3037
3349
  this.setStatus(id, "paid");
3038
3350
  }
3039
3351
  } else {
3040
- await this.clearLink(id);
3041
- this.setStatus(id, "pending");
3352
+ await this.revertPayable(id);
3042
3353
  }
3043
3354
  throw error;
3044
3355
  }
3045
- this.setStatus(id, "paid");
3046
- await this.respondPaid(id, result.id);
3047
- await this.clearLink(id);
3356
+ await this.settle(id, result.id, { committed: true, respond: true });
3048
3357
  return result;
3049
3358
  }
3359
+ /**
3360
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
3361
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
3362
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
3363
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
3364
+ * not a reload. Every path that binds a request to a transfer outcome funnels
3365
+ * through here — pay()'s clean success (respond now), pay()'s
3366
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
3367
+ * — so a failed respond always leaves the link + 'settling' and the next
3368
+ * reconcile pass (the committed-link override) retries the respond.
3369
+ */
3370
+ async settle(requestId, transferId, opts) {
3371
+ let journalDown = false;
3372
+ try {
3373
+ await this.writeLink(requestId, transferId, opts.committed);
3374
+ } catch (err) {
3375
+ if (!opts.respond) throw err;
3376
+ journalDown = true;
3377
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
3378
+ }
3379
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
3380
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
3381
+ return;
3382
+ }
3383
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
3384
+ this.setStatus(requestId, "paid");
3385
+ }
3050
3386
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
3051
3387
  async respondPaid(id, transferId) {
3052
3388
  try {
@@ -3082,32 +3418,32 @@ var Requests = class {
3082
3418
  let aborted = null;
3083
3419
  for (const [requestId, link] of [...this.journal]) {
3084
3420
  if (link.committed || resumed.has(link.transferId)) {
3085
- await this.resolvePaid(requestId, link.transferId);
3421
+ await this.resolvePaid(requestId, link);
3086
3422
  } else if (conflicted.has(link.transferId)) {
3087
3423
  await this.revertPayable(requestId);
3088
3424
  } else if (open.has(link.transferId)) {
3089
3425
  } else {
3090
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
3426
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
3091
3427
  }
3092
3428
  }
3093
3429
  }
3094
3430
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
3095
3431
  // a probe failure defers the link to the next session.
3096
- async reconcileUnaccounted(requestId, transferId, aborted) {
3432
+ async reconcileUnaccounted(requestId, link, aborted) {
3097
3433
  try {
3098
3434
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
3099
- if (aborted.has(transferId)) await this.revertPayable(requestId);
3100
- else await this.resolvePaid(requestId, transferId);
3435
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
3436
+ else await this.resolvePaid(requestId, link);
3101
3437
  } catch (err) {
3102
3438
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
3103
3439
  }
3104
3440
  return aborted;
3105
3441
  }
3106
- async resolvePaid(requestId, transferId) {
3107
- if (!await this.respondPaid(requestId, transferId)) return;
3108
- await this.clearLink(requestId);
3109
- this.setStatus(requestId, "paid");
3442
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
3443
+ resolvePaid(requestId, link) {
3444
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
3110
3445
  }
3446
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
3111
3447
  async revertPayable(requestId) {
3112
3448
  await this.clearLink(requestId);
3113
3449
  this.setStatus(requestId, "pending");
@@ -3431,11 +3767,12 @@ function composeFacadeParts(deps, hooks) {
3431
3767
  client: deps.client,
3432
3768
  fieldKey: deps.fieldKey,
3433
3769
  registry: deps.registry,
3434
- emit: (event) => deps.emit(event, {}),
3770
+ emit: (event, entry) => deps.emit(event, entry),
3435
3771
  ...deps.now !== void 0 ? { now: deps.now } : {},
3436
3772
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
3437
3773
  });
3438
3774
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
3775
+ const machineStores = createMachineStores(deps.kv);
3439
3776
  const heldStates = /* @__PURE__ */ new Map();
3440
3777
  return {
3441
3778
  ownPubkeyBytes,
@@ -3443,12 +3780,29 @@ function composeFacadeParts(deps, hooks) {
3443
3780
  ledger,
3444
3781
  queue,
3445
3782
  historyStore,
3446
- machineStores: createMachineStores(deps.kv),
3783
+ machineStores,
3447
3784
  machineDeps,
3448
3785
  machine: new TransferMachine(machineDeps),
3449
3786
  heldStates,
3450
3787
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
3451
- requests: buildRequests(deps, hooks)
3788
+ requests: buildRequests(deps, hooks),
3789
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3790
+ };
3791
+ }
3792
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3793
+ return {
3794
+ stores: machineStores,
3795
+ kv: deps.kv,
3796
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
3797
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
3798
+ decryptPayload: machineDeps.decryptPayload,
3799
+ fullRePull: () => view.onEpochReset(),
3800
+ attention: (transferId, code, detail) => {
3801
+ deps.emit(
3802
+ "transfer:attention",
3803
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
3804
+ );
3805
+ }
3452
3806
  };
3453
3807
  }
3454
3808
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -3470,11 +3824,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
3470
3824
  ownPubkey: hexToBytes(deps.ownPubkey),
3471
3825
  emit: deps.emit,
3472
3826
  now: deps.now ?? Date.now,
3473
- recordHistory: async ({ transferId, payload }) => {
3827
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
3474
3828
  await historyStore.recordSent({
3475
3829
  transferId,
3476
3830
  coinId: payload.coinId,
3477
- amount: payload.amount,
3831
+ // §5.9: the SETTLED amount (machine-computed from the certified
3832
+ // recipient blobs), never payload.amount — the plan.
3833
+ amount: committedAmount,
3478
3834
  recipientPubkey: payload.recipient,
3479
3835
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
3480
3836
  });
@@ -3501,7 +3857,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
3501
3857
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
3502
3858
  );
3503
3859
  },
3504
- syncEpoch: deps.syncEpoch ?? (() => ""),
3860
+ syncEpoch: deps.syncEpoch,
3505
3861
  ...deps.now !== void 0 ? { now: deps.now } : {}
3506
3862
  });
3507
3863
  }
@@ -3554,8 +3910,24 @@ var PaymentsFacade = class {
3554
3910
  this.receiveLoop = parts.receiveLoop;
3555
3911
  this.heldStates = parts.heldStates;
3556
3912
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3913
+ this.restoreDeps = parts.restoreDeps;
3557
3914
  this.requests = parts.requests;
3558
3915
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3916
+ this.heartbeat = new ConvergenceHeartbeat({
3917
+ now: () => this.nowMs(),
3918
+ onTick: () => this.trackTail(this.runConvergencePass())
3919
+ });
3920
+ this.converger = new Converger({
3921
+ machineDeps: this.machineDeps,
3922
+ stores: this.machineStores,
3923
+ delivery: deps.deliveryPort,
3924
+ reconcile: (report) => this.requests.reconcile(report),
3925
+ refreshView: () => this.view.delta(),
3926
+ replayMints: () => this.replayMints(),
3927
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
3928
+ emit: deps.emit,
3929
+ now: () => this.nowMs()
3930
+ });
3559
3931
  }
3560
3932
  view;
3561
3933
  ledger;
@@ -3567,17 +3939,28 @@ var PaymentsFacade = class {
3567
3939
  receiveLoop;
3568
3940
  heldStates;
3569
3941
  ownPubkeyBytes;
3942
+ restoreDeps;
3570
3943
  requests;
3571
3944
  currentEngine = null;
3572
3945
  pendingOps = /* @__PURE__ */ new Set();
3573
3946
  unsubscribers = [];
3574
3947
  started = false;
3948
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
3949
+ heartbeat;
3950
+ converger;
3951
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
3952
+ resumeFlight = new SingleFlight();
3953
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
3954
+ passChain = new SerialChain();
3955
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3956
+ activeMoneyOps = /* @__PURE__ */ new Set();
3575
3957
  // ── lifecycle ──────────────────────────────────────────────────────────────
3576
3958
  async start() {
3577
3959
  if (this.started) return;
3578
3960
  this.started = true;
3579
- await this.deps.session.start();
3961
+ this.heartbeat.start();
3580
3962
  this.unsubscribers.push(
3963
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
3581
3964
  this.deps.session.subscribeStream("mailbox", () => {
3582
3965
  this.trackTail(this.receiveLoop.drainOnce());
3583
3966
  }),
@@ -3588,20 +3971,12 @@ var PaymentsFacade = class {
3588
3971
  this.trackTail(this.requests.drainIncoming());
3589
3972
  })
3590
3973
  );
3591
- this.trackTail(this.runResume());
3592
- this.trackTail(
3593
- this.machineStores.deliveryJournal.replay({
3594
- delivery: this.deps.deliveryPort,
3595
- now: this.deps.now ?? Date.now,
3596
- attention: (transferId, code, detail) => {
3597
- this.deps.emit(
3598
- "transfer:attention",
3599
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
3600
- );
3601
- }
3602
- })
3603
- );
3604
- this.trackTail(this.replayMints());
3974
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
3975
+ if (status === "connected") this.heartbeat.recovered();
3976
+ });
3977
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
3978
+ await this.deps.session.start();
3979
+ this.trackTail(this.runConvergencePass());
3605
3980
  this.trackTail(this.seedHeldStates());
3606
3981
  await this.view.fullPull().catch(() => void 0);
3607
3982
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -3609,6 +3984,7 @@ var PaymentsFacade = class {
3609
3984
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
3610
3985
  async stop() {
3611
3986
  this.started = false;
3987
+ this.heartbeat.stop();
3612
3988
  this.receiveLoop.stop();
3613
3989
  for (const unsubscribe of this.unsubscribers) unsubscribe();
3614
3990
  this.unsubscribers = [];
@@ -3634,9 +4010,13 @@ var PaymentsFacade = class {
3634
4010
  history(page) {
3635
4011
  return this.historyStore.page(page ?? {});
3636
4012
  }
4013
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
4014
+ pendingTransfers() {
4015
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
4016
+ }
3637
4017
  // ── money movement ─────────────────────────────────────────────────────────
3638
4018
  send(request) {
3639
- return this.track(this.sendWithPolicy(request));
4019
+ return this.track(this.sendOutcome(request));
3640
4020
  }
3641
4021
  async receive() {
3642
4022
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -3646,9 +4026,32 @@ var PaymentsFacade = class {
3646
4026
  mint(coinId, amount) {
3647
4027
  return this.track(this.mintInner(coinId, amount));
3648
4028
  }
4029
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
4030
+ async resumeNow() {
4031
+ if (!this.started) return;
4032
+ await this.track(this.runConvergencePass());
4033
+ }
4034
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
4035
+ async handleEpochChange(_newEpoch) {
4036
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
4037
+ await this.passChain.enqueue(() => this.convergeBody());
4038
+ }
4039
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
4040
+ runConvergencePass() {
4041
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
4042
+ }
4043
+ async convergeBody() {
4044
+ const outcome = await this.converger.convergeOnce();
4045
+ const pending = await this.converger.pendingWork().catch(() => true);
4046
+ this.heartbeat.settle(outcome, pending);
4047
+ }
4048
+ nowMs() {
4049
+ return (this.deps.now ?? Date.now)();
4050
+ }
3649
4051
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
3650
- async sendWithPolicy(request) {
3651
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
4052
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
4053
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
4054
+ async sendOutcome(request) {
3652
4055
  const run = {
3653
4056
  amount: request.amount,
3654
4057
  delivered: [],
@@ -3656,12 +4059,36 @@ var PaymentsFacade = class {
3656
4059
  tokenTransfers: [],
3657
4060
  settledShortfalls: [],
3658
4061
  firstPartialId: void 0,
3659
- pendingAny: false
4062
+ pendingAny: false,
4063
+ lastTransferId: ""
3660
4064
  };
4065
+ try {
4066
+ return await this.sendWithPolicy(request, run);
4067
+ } catch (err) {
4068
+ this.emitCleanFailure(err, run.lastTransferId);
4069
+ throw err;
4070
+ }
4071
+ }
4072
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
4073
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
4074
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
4075
+ emitCleanFailure(err, transferId) {
4076
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
4077
+ const failed = {
4078
+ id: transferId,
4079
+ status: "failed",
4080
+ tokens: [],
4081
+ tokenTransfers: [],
4082
+ error: messageOf(err)
4083
+ };
4084
+ this.deps.emit("transfer:updated", failed);
4085
+ }
4086
+ async sendWithPolicy(request, run) {
4087
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
3661
4088
  for (let attempt = 0; ; attempt++) {
3662
4089
  let ctx;
3663
4090
  try {
3664
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
4091
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
3665
4092
  } catch (err) {
3666
4093
  throw this.partialize(err, run);
3667
4094
  }
@@ -3695,21 +4122,24 @@ var PaymentsFacade = class {
3695
4122
  return recipient;
3696
4123
  }
3697
4124
  async runAttempt(ctx) {
4125
+ this.activeMoneyOps.add(ctx.transferId);
3698
4126
  try {
3699
4127
  const { deliveryPending } = await this.machine.run(ctx.plan);
3700
4128
  this.settleSuccess(ctx, ctx.sourceIds);
3701
4129
  return { kind: "success", deliveryPending };
3702
4130
  } catch (err) {
3703
- return this.disposeFailedAttempt(ctx, err);
4131
+ return await this.disposeFailedAttempt(ctx, err);
4132
+ } finally {
4133
+ this.activeMoneyOps.delete(ctx.transferId);
3704
4134
  }
3705
4135
  }
3706
- /**
3707
- * Partial outcome (#677/#690): the shortfall is already durable (written by
3708
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
3709
- * the remainder under a NEW transferId — never the full amount.
3710
- */
4136
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
4137
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
3711
4138
  async consumePartial(ctx, disposition, run, attempt) {
3712
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
4139
+ if (disposition.deliveryPending) {
4140
+ run.pendingAny = true;
4141
+ this.heartbeat.arm();
4142
+ }
3713
4143
  const partial = disposition.entry;
3714
4144
  run.delivered.push(...partial.committedTokenIds);
3715
4145
  run.firstPartialId ??= partial.transferId;
@@ -3730,11 +4160,8 @@ var PaymentsFacade = class {
3730
4160
  run.amount = partial.remainingAmount;
3731
4161
  return null;
3732
4162
  }
3733
- /**
3734
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
3735
- * backstop still 'open' (committed>0) → converge via the same machine's
3736
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
3737
- */
4163
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
4164
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
3738
4165
  async disposeFailedAttempt(ctx, err) {
3739
4166
  if (isPossiblyCommittedSendOutcome(err)) {
3740
4167
  this.settleKeepOpen(ctx);
@@ -3783,8 +4210,9 @@ var PaymentsFacade = class {
3783
4210
  }
3784
4211
  return { kind: "rethrow", error: err };
3785
4212
  }
3786
- async planAndMaterialize(recipientPubkey, request) {
4213
+ async planAndMaterialize(recipientPubkey, request, run) {
3787
4214
  const transferId = this.newId();
4215
+ run.lastTransferId = transferId;
3788
4216
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
3789
4217
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
3790
4218
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -3867,6 +4295,7 @@ var PaymentsFacade = class {
3867
4295
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
3868
4296
  settleKeepOpen(ctx) {
3869
4297
  this.queue.clearExpectedChange(ctx.transferId);
4298
+ this.heartbeat.arm();
3870
4299
  }
3871
4300
  settlePartial(ctx, partial) {
3872
4301
  this.ledger.cancel(ctx.transferId);
@@ -3903,6 +4332,7 @@ var PaymentsFacade = class {
3903
4332
  for (const id of run.settledShortfalls.splice(0)) {
3904
4333
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
3905
4334
  }
4335
+ if (run.pendingAny) this.heartbeat.arm();
3906
4336
  const result = {
3907
4337
  id: transferId,
3908
4338
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -3914,11 +4344,7 @@ var PaymentsFacade = class {
3914
4344
  this.deps.emit("transfer:updated", result);
3915
4345
  return result;
3916
4346
  }
3917
- /**
3918
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
3919
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
3920
- * over the accumulated settled set — never bare, never a full-amount retry.
3921
- */
4347
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
3922
4348
  partialize(err, run) {
3923
4349
  if (run.delivered.length === 0) return err;
3924
4350
  return new PartialSendConflictError(
@@ -3947,21 +4373,29 @@ var PaymentsFacade = class {
3947
4373
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
3948
4374
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
3949
4375
  }
3950
- const engine = this.engine();
3951
4376
  const mintId = this.newId();
3952
- const params = this.mintParams(coinId, amount);
4377
+ this.activeMoneyOps.add(mintId);
4378
+ try {
4379
+ return await this.mintUnderJournal(mintId, coinId, amount);
4380
+ } finally {
4381
+ this.activeMoneyOps.delete(mintId);
4382
+ }
4383
+ }
4384
+ async mintUnderJournal(mintId, coinId, amount) {
4385
+ const engine = this.engine();
3953
4386
  const entry = {
3954
4387
  mintId,
3955
4388
  coinId,
3956
4389
  amount: amount.toString(),
3957
4390
  tokenId: "",
3958
- createdAt: (this.deps.now ?? Date.now)()
4391
+ createdAt: this.nowMs()
3959
4392
  };
3960
4393
  await this.machineStores.mintJournal.upsert(entry);
3961
4394
  let token;
3962
4395
  try {
3963
- token = await engine.mint(params, { transferId: mintId });
4396
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
3964
4397
  } catch (err) {
4398
+ this.heartbeat.arm();
3965
4399
  return { success: false, error: messageOf(err) };
3966
4400
  }
3967
4401
  if (token.blob.tokenId !== entry.tokenId) {
@@ -3970,6 +4404,7 @@ var PaymentsFacade = class {
3970
4404
  try {
3971
4405
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
3972
4406
  } catch (err) {
4407
+ this.heartbeat.arm();
3973
4408
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
3974
4409
  }
3975
4410
  return { success: true, tokenId: token.blob.tokenId };
@@ -3992,18 +4427,22 @@ var PaymentsFacade = class {
3992
4427
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
3993
4428
  this.trackTail(this.view.delta());
3994
4429
  }
4430
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
3995
4431
  async replayMints() {
4432
+ let resolved = 0;
3996
4433
  for (const entry of await this.machineStores.mintJournal.list()) {
4434
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
3997
4435
  try {
3998
- await this.replayMint(entry);
4436
+ if (await this.replayMint(entry)) resolved += 1;
3999
4437
  } catch {
4000
4438
  }
4001
4439
  }
4440
+ return resolved;
4002
4441
  }
4003
4442
  async replayMint(entry) {
4004
4443
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
4005
4444
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
4006
- return;
4445
+ return true;
4007
4446
  }
4008
4447
  const engine = this.engine();
4009
4448
  if (!supportsDeterministicMint(engine)) {
@@ -4011,7 +4450,7 @@ var PaymentsFacade = class {
4011
4450
  transferId: entry.mintId,
4012
4451
  code: ATTENTION_MINT_UNRESOLVED
4013
4452
  });
4014
- return;
4453
+ return false;
4015
4454
  }
4016
4455
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
4017
4456
  transferId: entry.mintId
@@ -4020,6 +4459,7 @@ var PaymentsFacade = class {
4020
4459
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
4021
4460
  }
4022
4461
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
4462
+ return true;
4023
4463
  }
4024
4464
  mintParams(coinId, amount) {
4025
4465
  return {
@@ -4045,17 +4485,6 @@ var PaymentsFacade = class {
4045
4485
  newId() {
4046
4486
  return (this.deps.newId ?? randomUUID)();
4047
4487
  }
4048
- async runResume() {
4049
- const report = await resumeAll(this.machineDeps);
4050
- await this.requests.reconcile({
4051
- resumed: report.resumed,
4052
- conflicted: report.conflicted,
4053
- open: report.failed
4054
- });
4055
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
4056
- await this.view.delta().catch(() => void 0);
4057
- }
4058
- }
4059
4488
  async seedHeldStates() {
4060
4489
  let page = await this.deps.storagePort.listInventory();
4061
4490
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -4067,22 +4496,14 @@ var PaymentsFacade = class {
4067
4496
  }
4068
4497
  }
4069
4498
  toUiToken(tokenId, coinId, amount) {
4070
- const registry = this.deps.registry;
4071
- const iconUrl = registry.getIconUrl(coinId);
4072
- const now = (this.deps.now ?? Date.now)();
4073
- return {
4074
- id: tokenId,
4075
- coinId,
4076
- symbol: registry.getSymbol(coinId),
4077
- name: registry.getName(coinId),
4078
- decimals: registry.getDecimals(coinId),
4079
- ...iconUrl !== null ? { iconUrl } : {},
4080
- amount: amount.toString(),
4081
- status: "transferring",
4082
- createdAt: now,
4083
- updatedAt: now,
4084
- lazy: true
4085
- };
4499
+ const now = this.nowMs();
4500
+ return toToken(
4501
+ tokenId,
4502
+ { assets: [], createdAt: now, updatedAt: now },
4503
+ { coinId, amount: amount.toString() },
4504
+ this.deps.registry,
4505
+ { transferring: true, suspectedSpent: false }
4506
+ );
4086
4507
  }
4087
4508
  track(op) {
4088
4509
  const settled = op.then(
@@ -4097,29 +4518,11 @@ var PaymentsFacade = class {
4097
4518
  this.track(op).catch(() => void 0);
4098
4519
  }
4099
4520
  };
4100
- function buildPayload(recipientPubkey, request, spend, spentStates) {
4101
- return {
4102
- v: 2,
4103
- recipient: recipientPubkey,
4104
- coinId: request.coinId,
4105
- amount: request.amount,
4106
- ...request.memo !== void 0 ? { memo: request.memo } : {},
4107
- direct: [...spend.plan.direct],
4108
- ...spend.plan.split !== void 0 ? {
4109
- split: {
4110
- tokenId: spend.plan.split.tokenId,
4111
- splitAmount: spend.plan.split.splitAmount.toString(),
4112
- remainderAmount: spend.plan.split.remainderAmount.toString()
4113
- }
4114
- } : {},
4115
- spentStates
4116
- };
4117
- }
4118
- function messageOf(err) {
4119
- return err instanceof Error ? err.message : String(err);
4120
- }
4121
4521
  export {
4122
4522
  ATTENTION_MINT_UNRESOLVED,
4523
+ ATTENTION_RESEED_REJECTED,
4524
+ HEARTBEAT_CAP_MS,
4525
+ HEARTBEAT_SEED_MS,
4123
4526
  MAX_RESELECT,
4124
4527
  PaymentsFacade,
4125
4528
  STORE_KEYS,