@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.
@@ -31,6 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var payments_v2_exports = {};
32
32
  __export(payments_v2_exports, {
33
33
  ATTENTION_MINT_UNRESOLVED: () => ATTENTION_MINT_UNRESOLVED,
34
+ ATTENTION_RESEED_REJECTED: () => ATTENTION_RESEED_REJECTED,
35
+ HEARTBEAT_CAP_MS: () => HEARTBEAT_CAP_MS,
36
+ HEARTBEAT_SEED_MS: () => HEARTBEAT_SEED_MS,
34
37
  MAX_RESELECT: () => MAX_RESELECT,
35
38
  PaymentsFacade: () => PaymentsFacade,
36
39
  STORE_KEYS: () => STORE_KEYS,
@@ -64,7 +67,10 @@ var STORE_KEYS = {
64
67
  shortfalls: "shortfalls",
65
68
  settlingLinks: "settling",
66
69
  streamCursor: (s) => `cursor:${s}`,
67
- epochLatch: "epoch-latch"
70
+ epochLatch: "epoch-latch",
71
+ // §5.2 InventoryView durable overlays (#625/#679).
72
+ suspectedSpent: "suspected-spent",
73
+ knownSpends: "known-spends"
68
74
  };
69
75
 
70
76
  // node_modules/@noble/hashes/utils.js
@@ -590,6 +596,10 @@ function isValidationReject(err) {
590
596
  if (e === null) return false;
591
597
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
592
598
  }
599
+ function retryAfterMsOf(err) {
600
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
601
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
602
+ }
593
603
  var chainRegistry = /* @__PURE__ */ new WeakMap();
594
604
  var replayRegistry = /* @__PURE__ */ new WeakMap();
595
605
  function registryFor(registry, kv, storeKey, make) {
@@ -852,7 +862,7 @@ var TransferMachine = class {
852
862
  }
853
863
  if (cls === null) {
854
864
  await this.applyCommitted(engine, plan.transferId, committed);
855
- this.closeTail(plan.transferId, plan.payload);
865
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
856
866
  return { committed, deliveryPending };
857
867
  }
858
868
  if (cls === "conflict" && committed.length > 0) {
@@ -878,10 +888,10 @@ var TransferMachine = class {
878
888
  }
879
889
  await this.applyCommitted(engine, r.transferId, committed);
880
890
  if (conflicts.length > 0) {
881
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
891
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
882
892
  }
883
893
  await this.completeIntent(r.transferId, r.payload);
884
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
894
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
885
895
  return { deliveryPending, partial: null };
886
896
  }
887
897
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -1063,7 +1073,7 @@ var TransferMachine = class {
1063
1073
  }
1064
1074
  return added;
1065
1075
  }
1066
- async settlePartial(r, committed, conflicts) {
1076
+ async settlePartial(engine, r, committed, conflicts) {
1067
1077
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
1068
1078
  const entry = {
1069
1079
  transferId: r.transferId,
@@ -1075,26 +1085,27 @@ var TransferMachine = class {
1075
1085
  };
1076
1086
  await this.stores.shortfalls.upsert(entry);
1077
1087
  await this.completeIntent(r.transferId, r.payload);
1078
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
1088
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
1079
1089
  return entry;
1080
1090
  }
1081
- closeTail(transferId, payload) {
1091
+ closeTail(engine, transferId, payload, committed) {
1082
1092
  void (async () => {
1083
1093
  try {
1084
1094
  await this.completeIntent(transferId, payload);
1085
1095
  } catch {
1086
1096
  return;
1087
1097
  }
1088
- await this.finishBookkeeping(transferId, payload, "sent");
1098
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
1089
1099
  })();
1090
1100
  }
1091
1101
  async completeIntent(transferId, payload) {
1092
1102
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
1093
1103
  await this.deps.intents.complete(transferId, signature);
1094
1104
  }
1095
- async finishBookkeeping(transferId, payload, phase) {
1105
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
1096
1106
  try {
1097
- await this.deps.recordHistory?.({ transferId, payload, phase });
1107
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
1108
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
1098
1109
  } catch {
1099
1110
  }
1100
1111
  try {
@@ -1102,6 +1113,26 @@ var TransferMachine = class {
1102
1113
  } catch {
1103
1114
  }
1104
1115
  }
1116
+ /**
1117
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
1118
+ * certified recipient blobs (summarize()'s committed set is the only source;
1119
+ * a journal-rehydrated resume leg has no source token, but every committed
1120
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
1121
+ */
1122
+ async settledAmount(engine, coinId, committed) {
1123
+ let total = 0n;
1124
+ for (const outcome of committed) {
1125
+ if (outcome.recipientBlob === void 0) continue;
1126
+ const token = await engine.decodeToken({
1127
+ v: TOKEN_BLOB_VERSION,
1128
+ network: 0,
1129
+ tokenId: "",
1130
+ token: outcome.recipientBlob
1131
+ });
1132
+ total += engine.balanceOf(token, coinId);
1133
+ }
1134
+ return total.toString();
1135
+ }
1105
1136
  async disposeOnFailure(transferId, committed, cls) {
1106
1137
  if (committed.length > 0 || cls === "keep-open") return;
1107
1138
  try {
@@ -1126,7 +1157,7 @@ function conflictAmount(engine, payload, op, source) {
1126
1157
  }
1127
1158
 
1128
1159
  // modules/payments-v2/machine/resume.ts
1129
- async function resumeAll(deps) {
1160
+ async function resumeAll(deps, options = {}) {
1130
1161
  const report = { resumed: [], conflicted: [], failed: [] };
1131
1162
  const stores = createMachineStores(deps.kv);
1132
1163
  const open = await deps.intents.listOpen();
@@ -1136,7 +1167,12 @@ async function resumeAll(deps) {
1136
1167
  } catch {
1137
1168
  return report;
1138
1169
  }
1139
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
1170
+ await dropStaleBackstops(
1171
+ stores,
1172
+ locals,
1173
+ new Set(open.map((i) => i.transferId)),
1174
+ options.isLocallyActive ?? (() => false)
1175
+ );
1140
1176
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
1141
1177
  const ctx = {
1142
1178
  deps,
@@ -1149,11 +1185,16 @@ async function resumeAll(deps) {
1149
1185
  }
1150
1186
  return report;
1151
1187
  }
1152
- async function dropStaleLocalAborts(stores, locals, openIds) {
1188
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
1153
1189
  for (const entry of locals.values()) {
1154
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
1190
+ if (openIds.has(entry.transferId)) continue;
1191
+ if (entry.disposition !== "open") {
1155
1192
  await stores.backstop.removeByKey(entry.transferId);
1193
+ continue;
1156
1194
  }
1195
+ if (isLocallyActive(entry.transferId)) continue;
1196
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
1197
+ await stores.backstop.removeByKey(entry.transferId);
1157
1198
  }
1158
1199
  }
1159
1200
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -1266,6 +1307,349 @@ function validatePayload(raw) {
1266
1307
  return p;
1267
1308
  }
1268
1309
 
1310
+ // modules/payments-v2/convergence.ts
1311
+ var HEARTBEAT_SEED_MS = 5e3;
1312
+ var HEARTBEAT_CAP_MS = 12e4;
1313
+ var ConvergenceHeartbeat = class {
1314
+ constructor(deps) {
1315
+ this.deps = deps;
1316
+ }
1317
+ timer = null;
1318
+ delayMs = HEARTBEAT_SEED_MS;
1319
+ notBefore = 0;
1320
+ running = false;
1321
+ start() {
1322
+ this.running = true;
1323
+ this.delayMs = HEARTBEAT_SEED_MS;
1324
+ this.notBefore = 0;
1325
+ }
1326
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
1327
+ stop() {
1328
+ this.running = false;
1329
+ this.clearTimer();
1330
+ }
1331
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
1332
+ settle(outcome, pending) {
1333
+ if (outcome.retryAfterMs !== null) {
1334
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
1335
+ }
1336
+ if (!pending) {
1337
+ this.delayMs = HEARTBEAT_SEED_MS;
1338
+ return;
1339
+ }
1340
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
1341
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
1342
+ this.schedule(delay);
1343
+ }
1344
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
1345
+ arm() {
1346
+ if (!this.running) return;
1347
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
1348
+ this.schedule(HEARTBEAT_SEED_MS);
1349
+ }
1350
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
1351
+ recovered() {
1352
+ this.delayMs = HEARTBEAT_SEED_MS;
1353
+ if (this.timer !== null) this.arm();
1354
+ }
1355
+ schedule(delayMs) {
1356
+ this.clearTimer();
1357
+ if (!this.running) return;
1358
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
1359
+ this.timer = setTimeout(() => {
1360
+ this.timer = null;
1361
+ this.deps.onTick();
1362
+ }, floored);
1363
+ }
1364
+ clearTimer() {
1365
+ if (this.timer !== null) clearTimeout(this.timer);
1366
+ this.timer = null;
1367
+ }
1368
+ };
1369
+ var Converger = class {
1370
+ constructor(deps) {
1371
+ this.deps = deps;
1372
+ }
1373
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
1374
+ async convergeOnce() {
1375
+ let progress = false;
1376
+ let retryAfterMs = null;
1377
+ try {
1378
+ progress = await this.resumePass();
1379
+ } catch (err) {
1380
+ retryAfterMs = retryAfterMsOf(err);
1381
+ }
1382
+ try {
1383
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
1384
+ progress = progress || stats.delivered > 0;
1385
+ } catch (err) {
1386
+ retryAfterMs ??= retryAfterMsOf(err);
1387
+ }
1388
+ try {
1389
+ progress = await this.deps.replayMints() > 0 || progress;
1390
+ } catch (err) {
1391
+ retryAfterMs ??= retryAfterMsOf(err);
1392
+ }
1393
+ return { progress, retryAfterMs };
1394
+ }
1395
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
1396
+ async pendingWork() {
1397
+ const [backstop, journal, mints] = await Promise.all([
1398
+ this.deps.stores.backstop.list(),
1399
+ this.deps.stores.deliveryJournal.list(),
1400
+ this.deps.stores.mintJournal.list()
1401
+ ]);
1402
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
1403
+ }
1404
+ async resumePass() {
1405
+ const report = await resumeAll(this.filteredMachineDeps(), {
1406
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
1407
+ });
1408
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
1409
+ await this.deps.reconcile({
1410
+ resumed: report.resumed,
1411
+ conflicted: report.conflicted,
1412
+ open: report.failed
1413
+ });
1414
+ if (progress) await this.deps.refreshView().catch(() => void 0);
1415
+ return progress;
1416
+ }
1417
+ filteredMachineDeps() {
1418
+ const intents = this.deps.machineDeps.intents;
1419
+ return {
1420
+ ...this.deps.machineDeps,
1421
+ intents: {
1422
+ ...intents,
1423
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
1424
+ }
1425
+ };
1426
+ }
1427
+ replayDeps() {
1428
+ return {
1429
+ delivery: this.deps.delivery,
1430
+ now: () => this.deps.now(),
1431
+ attention: (transferId, code, detail) => {
1432
+ this.deps.emit(
1433
+ "transfer:attention",
1434
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
1435
+ );
1436
+ }
1437
+ };
1438
+ }
1439
+ };
1440
+ async function derivePendingTransfers(stores, decryptPayload) {
1441
+ const [backstop, journal, shortfalls] = await Promise.all([
1442
+ stores.backstop.list(),
1443
+ stores.deliveryJournal.list(),
1444
+ stores.shortfalls.list()
1445
+ ]);
1446
+ const byTransfer = groupJournal(journal);
1447
+ const out = [];
1448
+ const seen = /* @__PURE__ */ new Set();
1449
+ for (const entry of backstop) {
1450
+ if (entry.disposition !== "open") continue;
1451
+ seen.add(entry.transferId);
1452
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
1453
+ }
1454
+ for (const [transferId, entries] of byTransfer) {
1455
+ if (seen.has(transferId)) continue;
1456
+ seen.add(transferId);
1457
+ out.push(journalOnlyRow(transferId, entries));
1458
+ }
1459
+ for (const entry of shortfalls) {
1460
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
1461
+ }
1462
+ return out;
1463
+ }
1464
+ function groupJournal(journal) {
1465
+ const byTransfer = /* @__PURE__ */ new Map();
1466
+ for (const entry of journal) {
1467
+ const mine = byTransfer.get(entry.transferId);
1468
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
1469
+ else mine.push(entry);
1470
+ }
1471
+ return byTransfer;
1472
+ }
1473
+ async function openRow(entry, journal, decryptPayload) {
1474
+ let payload = null;
1475
+ try {
1476
+ const raw = await decryptPayload(entry.payloadEnvelope);
1477
+ if (raw !== null && typeof raw === "object") payload = raw;
1478
+ } catch {
1479
+ payload = null;
1480
+ }
1481
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
1482
+ const certified = distinctOpCount(journal);
1483
+ return {
1484
+ transferId: entry.transferId,
1485
+ kind: "open",
1486
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
1487
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
1488
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
1489
+ legs: { certified, total: Math.max(total, certified) },
1490
+ deliveryPending: journal.length > 0,
1491
+ createdAt: entry.createdAt
1492
+ };
1493
+ }
1494
+ function journalOnlyRow(transferId, entries) {
1495
+ const certified = distinctOpCount(entries);
1496
+ return {
1497
+ transferId,
1498
+ kind: "open",
1499
+ recipient: entries[0].recipientPubkey,
1500
+ coinId: "",
1501
+ amount: "",
1502
+ legs: { certified, total: certified },
1503
+ deliveryPending: true,
1504
+ createdAt: 0
1505
+ };
1506
+ }
1507
+ function shortfallRow(entry) {
1508
+ return {
1509
+ transferId: entry.transferId,
1510
+ kind: "shortfall",
1511
+ recipient: entry.recipient,
1512
+ coinId: entry.coinId,
1513
+ amount: entry.remainingAmount,
1514
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
1515
+ deliveryPending: false,
1516
+ createdAt: entry.createdAt
1517
+ };
1518
+ }
1519
+ function distinctOpCount(entries) {
1520
+ return new Set(entries.map((entry) => entry.opIndex)).size;
1521
+ }
1522
+
1523
+ // modules/payments-v2/restore.ts
1524
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
1525
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
1526
+ async function reseedAndReset(deps) {
1527
+ for (const entry of await deps.stores.backstop.list()) {
1528
+ if (entry.disposition !== "open") continue;
1529
+ try {
1530
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
1531
+ if (entry.requiresSeedClose) {
1532
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
1533
+ }
1534
+ } catch (err) {
1535
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
1536
+ }
1537
+ }
1538
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
1539
+ await deps.fullRePull();
1540
+ }
1541
+ async function splitOpIndex(deps, envelope) {
1542
+ const payload = await deps.decryptPayload(envelope);
1543
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
1544
+ }
1545
+
1546
+ // modules/payments-v2/inventory/presentation.ts
1547
+ function toToken(tokenId, snapshot, asset, registry, flags) {
1548
+ const iconUrl = registry.getIconUrl(asset.coinId);
1549
+ return {
1550
+ id: tokenId,
1551
+ coinId: asset.coinId,
1552
+ symbol: registry.getSymbol(asset.coinId),
1553
+ name: registry.getName(asset.coinId),
1554
+ decimals: registry.getDecimals(asset.coinId),
1555
+ ...iconUrl !== null ? { iconUrl } : {},
1556
+ amount: asset.amount,
1557
+ status: flags.transferring ? "transferring" : "confirmed",
1558
+ createdAt: snapshot.createdAt,
1559
+ updatedAt: snapshot.updatedAt,
1560
+ lazy: true,
1561
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
1562
+ };
1563
+ }
1564
+ function aggregateAssets(entries, isTransferring, registry) {
1565
+ const groups = /* @__PURE__ */ new Map();
1566
+ for (const [tokenId, entry] of entries) {
1567
+ const moving = isTransferring(tokenId);
1568
+ for (const asset of entry.assets) {
1569
+ let group = groups.get(asset.coinId);
1570
+ if (!group) {
1571
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
1572
+ groups.set(asset.coinId, group);
1573
+ }
1574
+ if (moving) {
1575
+ group.transferring += BigInt(asset.amount);
1576
+ group.transferringCount += 1;
1577
+ } else {
1578
+ group.total += BigInt(asset.amount);
1579
+ group.count += 1;
1580
+ }
1581
+ }
1582
+ }
1583
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
1584
+ }
1585
+ function toAsset(coinId, totals, registry) {
1586
+ const iconUrl = registry.getIconUrl(coinId);
1587
+ return {
1588
+ coinId,
1589
+ symbol: registry.getSymbol(coinId),
1590
+ name: registry.getName(coinId),
1591
+ decimals: registry.getDecimals(coinId),
1592
+ ...iconUrl !== null ? { iconUrl } : {},
1593
+ totalAmount: totals.total.toString(),
1594
+ tokenCount: totals.count,
1595
+ confirmedAmount: totals.total.toString(),
1596
+ unconfirmedAmount: "0",
1597
+ confirmedTokenCount: totals.count,
1598
+ unconfirmedTokenCount: 0,
1599
+ transferringTokenCount: totals.transferringCount,
1600
+ transferringAmount: totals.transferring.toString(),
1601
+ priceUsd: null,
1602
+ priceEur: null,
1603
+ change24h: null,
1604
+ fiatValueUsd: null,
1605
+ fiatValueEur: null
1606
+ };
1607
+ }
1608
+ async function withPrices(raw, registry, price) {
1609
+ try {
1610
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
1611
+ const quotes = await price.getPrices(names);
1612
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
1613
+ } catch {
1614
+ return raw;
1615
+ }
1616
+ }
1617
+ function priceOne(asset, quote) {
1618
+ if (!quote) return asset;
1619
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
1620
+ return {
1621
+ ...asset,
1622
+ priceUsd: quote.priceUsd,
1623
+ priceEur: quote.priceEur ?? null,
1624
+ change24h: quote.change24h ?? null,
1625
+ fiatValueUsd: human * quote.priceUsd,
1626
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
1627
+ };
1628
+ }
1629
+
1630
+ // modules/payments-v2/machine/payload.ts
1631
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
1632
+ return {
1633
+ v: 2,
1634
+ recipient: recipientPubkey,
1635
+ coinId: request.coinId,
1636
+ amount: request.amount,
1637
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
1638
+ direct: [...spend.plan.direct],
1639
+ ...spend.plan.split !== void 0 ? {
1640
+ split: {
1641
+ tokenId: spend.plan.split.tokenId,
1642
+ splitAmount: spend.plan.split.splitAmount.toString(),
1643
+ remainderAmount: spend.plan.split.remainderAmount.toString()
1644
+ }
1645
+ } : {},
1646
+ spentStates
1647
+ };
1648
+ }
1649
+ function messageOf(err) {
1650
+ return err instanceof Error ? err.message : String(err);
1651
+ }
1652
+
1269
1653
  // node_modules/@noble/ciphers/utils.js
1270
1654
  function isBytes2(a) {
1271
1655
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -2174,13 +2558,14 @@ var History = class {
2174
2558
  }
2175
2559
  // ── internals ────────────────────────────────────────────────────────────────
2176
2560
  async post(build) {
2561
+ const record = build();
2177
2562
  try {
2178
- await this.deps.client.postHistory([build()]);
2563
+ await this.deps.client.postHistory([record]);
2179
2564
  } catch (err) {
2180
2565
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
2181
2566
  return;
2182
2567
  }
2183
- this.deps.emit("history:updated");
2568
+ this.deps.emit("history:updated", this.toEntry(record));
2184
2569
  }
2185
2570
  ts(timestamp) {
2186
2571
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -2237,93 +2622,9 @@ var History = class {
2237
2622
  }
2238
2623
  };
2239
2624
 
2240
- // modules/payments-v2/inventory/presentation.ts
2241
- function toToken(tokenId, snapshot, asset, registry, flags) {
2242
- const iconUrl = registry.getIconUrl(asset.coinId);
2243
- return {
2244
- id: tokenId,
2245
- coinId: asset.coinId,
2246
- symbol: registry.getSymbol(asset.coinId),
2247
- name: registry.getName(asset.coinId),
2248
- decimals: registry.getDecimals(asset.coinId),
2249
- ...iconUrl !== null ? { iconUrl } : {},
2250
- amount: asset.amount,
2251
- status: flags.transferring ? "transferring" : "confirmed",
2252
- createdAt: snapshot.createdAt,
2253
- updatedAt: snapshot.updatedAt,
2254
- lazy: true,
2255
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
2256
- };
2257
- }
2258
- function aggregateAssets(entries, isTransferring, registry) {
2259
- const groups = /* @__PURE__ */ new Map();
2260
- for (const [tokenId, entry] of entries) {
2261
- const moving = isTransferring(tokenId);
2262
- for (const asset of entry.assets) {
2263
- let group = groups.get(asset.coinId);
2264
- if (!group) {
2265
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
2266
- groups.set(asset.coinId, group);
2267
- }
2268
- if (moving) {
2269
- group.transferring += BigInt(asset.amount);
2270
- group.transferringCount += 1;
2271
- } else {
2272
- group.total += BigInt(asset.amount);
2273
- group.count += 1;
2274
- }
2275
- }
2276
- }
2277
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
2278
- }
2279
- function toAsset(coinId, totals, registry) {
2280
- const iconUrl = registry.getIconUrl(coinId);
2281
- return {
2282
- coinId,
2283
- symbol: registry.getSymbol(coinId),
2284
- name: registry.getName(coinId),
2285
- decimals: registry.getDecimals(coinId),
2286
- ...iconUrl !== null ? { iconUrl } : {},
2287
- totalAmount: totals.total.toString(),
2288
- tokenCount: totals.count,
2289
- confirmedAmount: totals.total.toString(),
2290
- unconfirmedAmount: "0",
2291
- confirmedTokenCount: totals.count,
2292
- unconfirmedTokenCount: 0,
2293
- transferringTokenCount: totals.transferringCount,
2294
- transferringAmount: totals.transferring.toString(),
2295
- priceUsd: null,
2296
- priceEur: null,
2297
- change24h: null,
2298
- fiatValueUsd: null,
2299
- fiatValueEur: null
2300
- };
2301
- }
2302
- async function withPrices(raw, registry, price) {
2303
- try {
2304
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
2305
- const quotes = await price.getPrices(names);
2306
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
2307
- } catch {
2308
- return raw;
2309
- }
2310
- }
2311
- function priceOne(asset, quote) {
2312
- if (!quote) return asset;
2313
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
2314
- return {
2315
- ...asset,
2316
- priceUsd: quote.priceUsd,
2317
- priceEur: quote.priceEur ?? null,
2318
- change24h: quote.change24h ?? null,
2319
- fiatValueUsd: human * quote.priceUsd,
2320
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
2321
- };
2322
- }
2323
-
2324
2625
  // modules/payments-v2/inventory/InventoryView.ts
2325
- var SUSPECTED_KEY = "suspected-spent";
2326
- var KNOWN_SPENDS_KEY = "known-spends";
2626
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
2627
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
2327
2628
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
2328
2629
  function stateKey(tokenId, stateHash) {
2329
2630
  return `${tokenId}:${stateHash}`;
@@ -2719,19 +3020,24 @@ var Receive = class {
2719
3020
  const engine = deps.engine();
2720
3021
  const stored = [];
2721
3022
  const pending = [];
2722
- let epoch = "";
3023
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
2723
3024
  try {
2724
- epoch = deps.syncEpoch();
2725
3025
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
2726
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
2727
- for await (const entry of deps.delivery.incoming(since)) {
2728
- await this.processEntry(deps, engine, entry, pending, stored);
2729
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
3026
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
3027
+ for (let pass = 0; pass < 2; pass++) {
3028
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
3029
+ await this.processEntry(deps, engine, entry, pending, stored);
3030
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
3031
+ }
3032
+ await flushAcks(deps, pending, pageEpoch);
3033
+ const served = deps.delivery.incomingEpoch();
3034
+ if (basis === null || served === null || served === basis.syncEpoch) break;
3035
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
3036
+ basis = null;
2730
3037
  }
2731
- await flushAcks(deps, pending, epoch);
2732
3038
  } catch (err) {
2733
3039
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
2734
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
3040
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
2735
3041
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
2736
3042
  });
2737
3043
  }
@@ -2804,7 +3110,7 @@ async function announce(deps, entry, record) {
2804
3110
  receivedAt
2805
3111
  };
2806
3112
  }
2807
- async function flushAcks(deps, pending, epoch) {
3113
+ async function flushAcks(deps, pending, epochOf) {
2808
3114
  let lastAcked = null;
2809
3115
  try {
2810
3116
  while (pending.length > 0) {
@@ -2815,7 +3121,7 @@ async function flushAcks(deps, pending, epoch) {
2815
3121
  }
2816
3122
  } finally {
2817
3123
  if (lastAcked !== null) {
2818
- const record = { cursor: lastAcked, syncEpoch: epoch };
3124
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
2819
3125
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
2820
3126
  }
2821
3127
  }
@@ -2916,9 +3222,19 @@ var Requests = class {
2916
3222
  }
2917
3223
  });
2918
3224
  }
3225
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
2919
3226
  writeLink(requestId, transferId, committed) {
2920
3227
  return this.mutateJournal((m) => {
2921
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
3228
+ const prev = m.get(requestId);
3229
+ const same = prev !== void 0 && prev.transferId === transferId;
3230
+ const next = {
3231
+ requestId,
3232
+ transferId,
3233
+ committed: committed || same && prev.committed,
3234
+ createdAt: same ? prev.createdAt : this.now()
3235
+ };
3236
+ if (same && prev.committed === next.committed) return false;
3237
+ m.set(requestId, next);
2922
3238
  return true;
2923
3239
  });
2924
3240
  }
@@ -3071,23 +3387,46 @@ var Requests = class {
3071
3387
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
3072
3388
  if (transferId !== void 0) {
3073
3389
  const committed = error instanceof PartialSendConflictError;
3074
- await this.writeLink(id, transferId, committed);
3075
- this.setStatus(id, "settling");
3390
+ await this.settle(id, transferId, { committed, respond: false });
3076
3391
  } else {
3077
3392
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
3078
3393
  this.setStatus(id, "paid");
3079
3394
  }
3080
3395
  } else {
3081
- await this.clearLink(id);
3082
- this.setStatus(id, "pending");
3396
+ await this.revertPayable(id);
3083
3397
  }
3084
3398
  throw error;
3085
3399
  }
3086
- this.setStatus(id, "paid");
3087
- await this.respondPaid(id, result.id);
3088
- await this.clearLink(id);
3400
+ await this.settle(id, result.id, { committed: true, respond: true });
3089
3401
  return result;
3090
3402
  }
3403
+ /**
3404
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
3405
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
3406
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
3407
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
3408
+ * not a reload. Every path that binds a request to a transfer outcome funnels
3409
+ * through here — pay()'s clean success (respond now), pay()'s
3410
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
3411
+ * — so a failed respond always leaves the link + 'settling' and the next
3412
+ * reconcile pass (the committed-link override) retries the respond.
3413
+ */
3414
+ async settle(requestId, transferId, opts) {
3415
+ let journalDown = false;
3416
+ try {
3417
+ await this.writeLink(requestId, transferId, opts.committed);
3418
+ } catch (err) {
3419
+ if (!opts.respond) throw err;
3420
+ journalDown = true;
3421
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
3422
+ }
3423
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
3424
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
3425
+ return;
3426
+ }
3427
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
3428
+ this.setStatus(requestId, "paid");
3429
+ }
3091
3430
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
3092
3431
  async respondPaid(id, transferId) {
3093
3432
  try {
@@ -3123,32 +3462,32 @@ var Requests = class {
3123
3462
  let aborted = null;
3124
3463
  for (const [requestId, link] of [...this.journal]) {
3125
3464
  if (link.committed || resumed.has(link.transferId)) {
3126
- await this.resolvePaid(requestId, link.transferId);
3465
+ await this.resolvePaid(requestId, link);
3127
3466
  } else if (conflicted.has(link.transferId)) {
3128
3467
  await this.revertPayable(requestId);
3129
3468
  } else if (open.has(link.transferId)) {
3130
3469
  } else {
3131
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
3470
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
3132
3471
  }
3133
3472
  }
3134
3473
  }
3135
3474
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
3136
3475
  // a probe failure defers the link to the next session.
3137
- async reconcileUnaccounted(requestId, transferId, aborted) {
3476
+ async reconcileUnaccounted(requestId, link, aborted) {
3138
3477
  try {
3139
3478
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
3140
- if (aborted.has(transferId)) await this.revertPayable(requestId);
3141
- else await this.resolvePaid(requestId, transferId);
3479
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
3480
+ else await this.resolvePaid(requestId, link);
3142
3481
  } catch (err) {
3143
3482
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
3144
3483
  }
3145
3484
  return aborted;
3146
3485
  }
3147
- async resolvePaid(requestId, transferId) {
3148
- if (!await this.respondPaid(requestId, transferId)) return;
3149
- await this.clearLink(requestId);
3150
- this.setStatus(requestId, "paid");
3486
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
3487
+ resolvePaid(requestId, link) {
3488
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
3151
3489
  }
3490
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
3152
3491
  async revertPayable(requestId) {
3153
3492
  await this.clearLink(requestId);
3154
3493
  this.setStatus(requestId, "pending");
@@ -3472,11 +3811,12 @@ function composeFacadeParts(deps, hooks) {
3472
3811
  client: deps.client,
3473
3812
  fieldKey: deps.fieldKey,
3474
3813
  registry: deps.registry,
3475
- emit: (event) => deps.emit(event, {}),
3814
+ emit: (event, entry) => deps.emit(event, entry),
3476
3815
  ...deps.now !== void 0 ? { now: deps.now } : {},
3477
3816
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
3478
3817
  });
3479
3818
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
3819
+ const machineStores = createMachineStores(deps.kv);
3480
3820
  const heldStates = /* @__PURE__ */ new Map();
3481
3821
  return {
3482
3822
  ownPubkeyBytes,
@@ -3484,12 +3824,29 @@ function composeFacadeParts(deps, hooks) {
3484
3824
  ledger,
3485
3825
  queue,
3486
3826
  historyStore,
3487
- machineStores: createMachineStores(deps.kv),
3827
+ machineStores,
3488
3828
  machineDeps,
3489
3829
  machine: new TransferMachine(machineDeps),
3490
3830
  heldStates,
3491
3831
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
3492
- requests: buildRequests(deps, hooks)
3832
+ requests: buildRequests(deps, hooks),
3833
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3834
+ };
3835
+ }
3836
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3837
+ return {
3838
+ stores: machineStores,
3839
+ kv: deps.kv,
3840
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
3841
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
3842
+ decryptPayload: machineDeps.decryptPayload,
3843
+ fullRePull: () => view.onEpochReset(),
3844
+ attention: (transferId, code, detail) => {
3845
+ deps.emit(
3846
+ "transfer:attention",
3847
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
3848
+ );
3849
+ }
3493
3850
  };
3494
3851
  }
3495
3852
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -3511,11 +3868,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
3511
3868
  ownPubkey: hexToBytes(deps.ownPubkey),
3512
3869
  emit: deps.emit,
3513
3870
  now: deps.now ?? Date.now,
3514
- recordHistory: async ({ transferId, payload }) => {
3871
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
3515
3872
  await historyStore.recordSent({
3516
3873
  transferId,
3517
3874
  coinId: payload.coinId,
3518
- amount: payload.amount,
3875
+ // §5.9: the SETTLED amount (machine-computed from the certified
3876
+ // recipient blobs), never payload.amount — the plan.
3877
+ amount: committedAmount,
3519
3878
  recipientPubkey: payload.recipient,
3520
3879
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
3521
3880
  });
@@ -3542,7 +3901,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
3542
3901
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
3543
3902
  );
3544
3903
  },
3545
- syncEpoch: deps.syncEpoch ?? (() => ""),
3904
+ syncEpoch: deps.syncEpoch,
3546
3905
  ...deps.now !== void 0 ? { now: deps.now } : {}
3547
3906
  });
3548
3907
  }
@@ -3595,8 +3954,24 @@ var PaymentsFacade = class {
3595
3954
  this.receiveLoop = parts.receiveLoop;
3596
3955
  this.heldStates = parts.heldStates;
3597
3956
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3957
+ this.restoreDeps = parts.restoreDeps;
3598
3958
  this.requests = parts.requests;
3599
3959
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3960
+ this.heartbeat = new ConvergenceHeartbeat({
3961
+ now: () => this.nowMs(),
3962
+ onTick: () => this.trackTail(this.runConvergencePass())
3963
+ });
3964
+ this.converger = new Converger({
3965
+ machineDeps: this.machineDeps,
3966
+ stores: this.machineStores,
3967
+ delivery: deps.deliveryPort,
3968
+ reconcile: (report) => this.requests.reconcile(report),
3969
+ refreshView: () => this.view.delta(),
3970
+ replayMints: () => this.replayMints(),
3971
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
3972
+ emit: deps.emit,
3973
+ now: () => this.nowMs()
3974
+ });
3600
3975
  }
3601
3976
  view;
3602
3977
  ledger;
@@ -3608,17 +3983,28 @@ var PaymentsFacade = class {
3608
3983
  receiveLoop;
3609
3984
  heldStates;
3610
3985
  ownPubkeyBytes;
3986
+ restoreDeps;
3611
3987
  requests;
3612
3988
  currentEngine = null;
3613
3989
  pendingOps = /* @__PURE__ */ new Set();
3614
3990
  unsubscribers = [];
3615
3991
  started = false;
3992
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
3993
+ heartbeat;
3994
+ converger;
3995
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
3996
+ resumeFlight = new SingleFlight();
3997
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
3998
+ passChain = new SerialChain();
3999
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
4000
+ activeMoneyOps = /* @__PURE__ */ new Set();
3616
4001
  // ── lifecycle ──────────────────────────────────────────────────────────────
3617
4002
  async start() {
3618
4003
  if (this.started) return;
3619
4004
  this.started = true;
3620
- await this.deps.session.start();
4005
+ this.heartbeat.start();
3621
4006
  this.unsubscribers.push(
4007
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
3622
4008
  this.deps.session.subscribeStream("mailbox", () => {
3623
4009
  this.trackTail(this.receiveLoop.drainOnce());
3624
4010
  }),
@@ -3629,20 +4015,12 @@ var PaymentsFacade = class {
3629
4015
  this.trackTail(this.requests.drainIncoming());
3630
4016
  })
3631
4017
  );
3632
- this.trackTail(this.runResume());
3633
- this.trackTail(
3634
- this.machineStores.deliveryJournal.replay({
3635
- delivery: this.deps.deliveryPort,
3636
- now: this.deps.now ?? Date.now,
3637
- attention: (transferId, code, detail) => {
3638
- this.deps.emit(
3639
- "transfer:attention",
3640
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
3641
- );
3642
- }
3643
- })
3644
- );
3645
- this.trackTail(this.replayMints());
4018
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
4019
+ if (status === "connected") this.heartbeat.recovered();
4020
+ });
4021
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4022
+ await this.deps.session.start();
4023
+ this.trackTail(this.runConvergencePass());
3646
4024
  this.trackTail(this.seedHeldStates());
3647
4025
  await this.view.fullPull().catch(() => void 0);
3648
4026
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -3650,6 +4028,7 @@ var PaymentsFacade = class {
3650
4028
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
3651
4029
  async stop() {
3652
4030
  this.started = false;
4031
+ this.heartbeat.stop();
3653
4032
  this.receiveLoop.stop();
3654
4033
  for (const unsubscribe of this.unsubscribers) unsubscribe();
3655
4034
  this.unsubscribers = [];
@@ -3675,9 +4054,13 @@ var PaymentsFacade = class {
3675
4054
  history(page) {
3676
4055
  return this.historyStore.page(page ?? {});
3677
4056
  }
4057
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
4058
+ pendingTransfers() {
4059
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
4060
+ }
3678
4061
  // ── money movement ─────────────────────────────────────────────────────────
3679
4062
  send(request) {
3680
- return this.track(this.sendWithPolicy(request));
4063
+ return this.track(this.sendOutcome(request));
3681
4064
  }
3682
4065
  async receive() {
3683
4066
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -3687,9 +4070,32 @@ var PaymentsFacade = class {
3687
4070
  mint(coinId, amount) {
3688
4071
  return this.track(this.mintInner(coinId, amount));
3689
4072
  }
4073
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
4074
+ async resumeNow() {
4075
+ if (!this.started) return;
4076
+ await this.track(this.runConvergencePass());
4077
+ }
4078
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
4079
+ async handleEpochChange(_newEpoch) {
4080
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
4081
+ await this.passChain.enqueue(() => this.convergeBody());
4082
+ }
4083
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
4084
+ runConvergencePass() {
4085
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
4086
+ }
4087
+ async convergeBody() {
4088
+ const outcome = await this.converger.convergeOnce();
4089
+ const pending = await this.converger.pendingWork().catch(() => true);
4090
+ this.heartbeat.settle(outcome, pending);
4091
+ }
4092
+ nowMs() {
4093
+ return (this.deps.now ?? Date.now)();
4094
+ }
3690
4095
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
3691
- async sendWithPolicy(request) {
3692
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
4096
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
4097
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
4098
+ async sendOutcome(request) {
3693
4099
  const run = {
3694
4100
  amount: request.amount,
3695
4101
  delivered: [],
@@ -3697,12 +4103,36 @@ var PaymentsFacade = class {
3697
4103
  tokenTransfers: [],
3698
4104
  settledShortfalls: [],
3699
4105
  firstPartialId: void 0,
3700
- pendingAny: false
4106
+ pendingAny: false,
4107
+ lastTransferId: ""
3701
4108
  };
4109
+ try {
4110
+ return await this.sendWithPolicy(request, run);
4111
+ } catch (err) {
4112
+ this.emitCleanFailure(err, run.lastTransferId);
4113
+ throw err;
4114
+ }
4115
+ }
4116
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
4117
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
4118
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
4119
+ emitCleanFailure(err, transferId) {
4120
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
4121
+ const failed = {
4122
+ id: transferId,
4123
+ status: "failed",
4124
+ tokens: [],
4125
+ tokenTransfers: [],
4126
+ error: messageOf(err)
4127
+ };
4128
+ this.deps.emit("transfer:updated", failed);
4129
+ }
4130
+ async sendWithPolicy(request, run) {
4131
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
3702
4132
  for (let attempt = 0; ; attempt++) {
3703
4133
  let ctx;
3704
4134
  try {
3705
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
4135
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
3706
4136
  } catch (err) {
3707
4137
  throw this.partialize(err, run);
3708
4138
  }
@@ -3736,21 +4166,24 @@ var PaymentsFacade = class {
3736
4166
  return recipient;
3737
4167
  }
3738
4168
  async runAttempt(ctx) {
4169
+ this.activeMoneyOps.add(ctx.transferId);
3739
4170
  try {
3740
4171
  const { deliveryPending } = await this.machine.run(ctx.plan);
3741
4172
  this.settleSuccess(ctx, ctx.sourceIds);
3742
4173
  return { kind: "success", deliveryPending };
3743
4174
  } catch (err) {
3744
- return this.disposeFailedAttempt(ctx, err);
4175
+ return await this.disposeFailedAttempt(ctx, err);
4176
+ } finally {
4177
+ this.activeMoneyOps.delete(ctx.transferId);
3745
4178
  }
3746
4179
  }
3747
- /**
3748
- * Partial outcome (#677/#690): the shortfall is already durable (written by
3749
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
3750
- * the remainder under a NEW transferId — never the full amount.
3751
- */
4180
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
4181
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
3752
4182
  async consumePartial(ctx, disposition, run, attempt) {
3753
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
4183
+ if (disposition.deliveryPending) {
4184
+ run.pendingAny = true;
4185
+ this.heartbeat.arm();
4186
+ }
3754
4187
  const partial = disposition.entry;
3755
4188
  run.delivered.push(...partial.committedTokenIds);
3756
4189
  run.firstPartialId ??= partial.transferId;
@@ -3771,11 +4204,8 @@ var PaymentsFacade = class {
3771
4204
  run.amount = partial.remainingAmount;
3772
4205
  return null;
3773
4206
  }
3774
- /**
3775
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
3776
- * backstop still 'open' (committed>0) → converge via the same machine's
3777
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
3778
- */
4207
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
4208
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
3779
4209
  async disposeFailedAttempt(ctx, err) {
3780
4210
  if (isPossiblyCommittedSendOutcome(err)) {
3781
4211
  this.settleKeepOpen(ctx);
@@ -3824,8 +4254,9 @@ var PaymentsFacade = class {
3824
4254
  }
3825
4255
  return { kind: "rethrow", error: err };
3826
4256
  }
3827
- async planAndMaterialize(recipientPubkey, request) {
4257
+ async planAndMaterialize(recipientPubkey, request, run) {
3828
4258
  const transferId = this.newId();
4259
+ run.lastTransferId = transferId;
3829
4260
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
3830
4261
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
3831
4262
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -3908,6 +4339,7 @@ var PaymentsFacade = class {
3908
4339
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
3909
4340
  settleKeepOpen(ctx) {
3910
4341
  this.queue.clearExpectedChange(ctx.transferId);
4342
+ this.heartbeat.arm();
3911
4343
  }
3912
4344
  settlePartial(ctx, partial) {
3913
4345
  this.ledger.cancel(ctx.transferId);
@@ -3944,6 +4376,7 @@ var PaymentsFacade = class {
3944
4376
  for (const id of run.settledShortfalls.splice(0)) {
3945
4377
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
3946
4378
  }
4379
+ if (run.pendingAny) this.heartbeat.arm();
3947
4380
  const result = {
3948
4381
  id: transferId,
3949
4382
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -3955,11 +4388,7 @@ var PaymentsFacade = class {
3955
4388
  this.deps.emit("transfer:updated", result);
3956
4389
  return result;
3957
4390
  }
3958
- /**
3959
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
3960
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
3961
- * over the accumulated settled set — never bare, never a full-amount retry.
3962
- */
4391
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
3963
4392
  partialize(err, run) {
3964
4393
  if (run.delivered.length === 0) return err;
3965
4394
  return new PartialSendConflictError(
@@ -3988,21 +4417,29 @@ var PaymentsFacade = class {
3988
4417
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
3989
4418
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
3990
4419
  }
3991
- const engine = this.engine();
3992
4420
  const mintId = this.newId();
3993
- const params = this.mintParams(coinId, amount);
4421
+ this.activeMoneyOps.add(mintId);
4422
+ try {
4423
+ return await this.mintUnderJournal(mintId, coinId, amount);
4424
+ } finally {
4425
+ this.activeMoneyOps.delete(mintId);
4426
+ }
4427
+ }
4428
+ async mintUnderJournal(mintId, coinId, amount) {
4429
+ const engine = this.engine();
3994
4430
  const entry = {
3995
4431
  mintId,
3996
4432
  coinId,
3997
4433
  amount: amount.toString(),
3998
4434
  tokenId: "",
3999
- createdAt: (this.deps.now ?? Date.now)()
4435
+ createdAt: this.nowMs()
4000
4436
  };
4001
4437
  await this.machineStores.mintJournal.upsert(entry);
4002
4438
  let token;
4003
4439
  try {
4004
- token = await engine.mint(params, { transferId: mintId });
4440
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
4005
4441
  } catch (err) {
4442
+ this.heartbeat.arm();
4006
4443
  return { success: false, error: messageOf(err) };
4007
4444
  }
4008
4445
  if (token.blob.tokenId !== entry.tokenId) {
@@ -4011,6 +4448,7 @@ var PaymentsFacade = class {
4011
4448
  try {
4012
4449
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
4013
4450
  } catch (err) {
4451
+ this.heartbeat.arm();
4014
4452
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
4015
4453
  }
4016
4454
  return { success: true, tokenId: token.blob.tokenId };
@@ -4033,18 +4471,22 @@ var PaymentsFacade = class {
4033
4471
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
4034
4472
  this.trackTail(this.view.delta());
4035
4473
  }
4474
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
4036
4475
  async replayMints() {
4476
+ let resolved = 0;
4037
4477
  for (const entry of await this.machineStores.mintJournal.list()) {
4478
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
4038
4479
  try {
4039
- await this.replayMint(entry);
4480
+ if (await this.replayMint(entry)) resolved += 1;
4040
4481
  } catch {
4041
4482
  }
4042
4483
  }
4484
+ return resolved;
4043
4485
  }
4044
4486
  async replayMint(entry) {
4045
4487
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
4046
4488
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
4047
- return;
4489
+ return true;
4048
4490
  }
4049
4491
  const engine = this.engine();
4050
4492
  if (!supportsDeterministicMint(engine)) {
@@ -4052,7 +4494,7 @@ var PaymentsFacade = class {
4052
4494
  transferId: entry.mintId,
4053
4495
  code: ATTENTION_MINT_UNRESOLVED
4054
4496
  });
4055
- return;
4497
+ return false;
4056
4498
  }
4057
4499
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
4058
4500
  transferId: entry.mintId
@@ -4061,6 +4503,7 @@ var PaymentsFacade = class {
4061
4503
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
4062
4504
  }
4063
4505
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
4506
+ return true;
4064
4507
  }
4065
4508
  mintParams(coinId, amount) {
4066
4509
  return {
@@ -4086,17 +4529,6 @@ var PaymentsFacade = class {
4086
4529
  newId() {
4087
4530
  return (this.deps.newId ?? randomUUID)();
4088
4531
  }
4089
- async runResume() {
4090
- const report = await resumeAll(this.machineDeps);
4091
- await this.requests.reconcile({
4092
- resumed: report.resumed,
4093
- conflicted: report.conflicted,
4094
- open: report.failed
4095
- });
4096
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
4097
- await this.view.delta().catch(() => void 0);
4098
- }
4099
- }
4100
4532
  async seedHeldStates() {
4101
4533
  let page = await this.deps.storagePort.listInventory();
4102
4534
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -4108,22 +4540,14 @@ var PaymentsFacade = class {
4108
4540
  }
4109
4541
  }
4110
4542
  toUiToken(tokenId, coinId, amount) {
4111
- const registry = this.deps.registry;
4112
- const iconUrl = registry.getIconUrl(coinId);
4113
- const now = (this.deps.now ?? Date.now)();
4114
- return {
4115
- id: tokenId,
4116
- coinId,
4117
- symbol: registry.getSymbol(coinId),
4118
- name: registry.getName(coinId),
4119
- decimals: registry.getDecimals(coinId),
4120
- ...iconUrl !== null ? { iconUrl } : {},
4121
- amount: amount.toString(),
4122
- status: "transferring",
4123
- createdAt: now,
4124
- updatedAt: now,
4125
- lazy: true
4126
- };
4543
+ const now = this.nowMs();
4544
+ return toToken(
4545
+ tokenId,
4546
+ { assets: [], createdAt: now, updatedAt: now },
4547
+ { coinId, amount: amount.toString() },
4548
+ this.deps.registry,
4549
+ { transferring: true, suspectedSpent: false }
4550
+ );
4127
4551
  }
4128
4552
  track(op) {
4129
4553
  const settled = op.then(
@@ -4138,30 +4562,12 @@ var PaymentsFacade = class {
4138
4562
  this.track(op).catch(() => void 0);
4139
4563
  }
4140
4564
  };
4141
- function buildPayload(recipientPubkey, request, spend, spentStates) {
4142
- return {
4143
- v: 2,
4144
- recipient: recipientPubkey,
4145
- coinId: request.coinId,
4146
- amount: request.amount,
4147
- ...request.memo !== void 0 ? { memo: request.memo } : {},
4148
- direct: [...spend.plan.direct],
4149
- ...spend.plan.split !== void 0 ? {
4150
- split: {
4151
- tokenId: spend.plan.split.tokenId,
4152
- splitAmount: spend.plan.split.splitAmount.toString(),
4153
- remainderAmount: spend.plan.split.remainderAmount.toString()
4154
- }
4155
- } : {},
4156
- spentStates
4157
- };
4158
- }
4159
- function messageOf(err) {
4160
- return err instanceof Error ? err.message : String(err);
4161
- }
4162
4565
  // Annotate the CommonJS export names for ESM import in node:
4163
4566
  0 && (module.exports = {
4164
4567
  ATTENTION_MINT_UNRESOLVED,
4568
+ ATTENTION_RESEED_REJECTED,
4569
+ HEARTBEAT_CAP_MS,
4570
+ HEARTBEAT_SEED_MS,
4165
4571
  MAX_RESELECT,
4166
4572
  PaymentsFacade,
4167
4573
  STORE_KEYS,