@unicitylabs/sphere-sdk 0.14.1-dev.1 → 0.14.1-dev.2

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.
Files changed (47) hide show
  1. package/dist/connect/index.cjs +20 -5
  2. package/dist/connect/index.cjs.map +1 -1
  3. package/dist/connect/index.d.cts +0 -1
  4. package/dist/connect/index.d.ts +0 -1
  5. package/dist/connect/index.js +20 -5
  6. package/dist/connect/index.js.map +1 -1
  7. package/dist/core/index.cjs +395 -213
  8. package/dist/core/index.cjs.map +1 -1
  9. package/dist/core/index.d.cts +10 -3
  10. package/dist/core/index.d.ts +10 -3
  11. package/dist/core/index.js +395 -213
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/impl/browser/connect/index.cjs +10 -2
  14. package/dist/impl/browser/connect/index.cjs.map +1 -1
  15. package/dist/impl/browser/connect/index.d.cts +0 -1
  16. package/dist/impl/browser/connect/index.d.ts +0 -1
  17. package/dist/impl/browser/connect/index.js +10 -2
  18. package/dist/impl/browser/connect/index.js.map +1 -1
  19. package/dist/impl/nodejs/connect/index.cjs +9 -1
  20. package/dist/impl/nodejs/connect/index.cjs.map +1 -1
  21. package/dist/impl/nodejs/connect/index.d.cts +0 -1
  22. package/dist/impl/nodejs/connect/index.d.ts +0 -1
  23. package/dist/impl/nodejs/connect/index.js +9 -1
  24. package/dist/impl/nodejs/connect/index.js.map +1 -1
  25. package/dist/impl/nodejs/index.d.cts +8 -1
  26. package/dist/impl/nodejs/index.d.ts +8 -1
  27. package/dist/impl/shared/wallet-api/index.d.cts +8 -1
  28. package/dist/impl/shared/wallet-api/index.d.ts +8 -1
  29. package/dist/impl/wallet-api-v2/index.cjs +38 -5
  30. package/dist/impl/wallet-api-v2/index.cjs.map +1 -1
  31. package/dist/impl/wallet-api-v2/index.d.cts +23 -2
  32. package/dist/impl/wallet-api-v2/index.d.ts +23 -2
  33. package/dist/impl/wallet-api-v2/index.js +38 -5
  34. package/dist/impl/wallet-api-v2/index.js.map +1 -1
  35. package/dist/index.cjs +395 -213
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +10 -3
  38. package/dist/index.d.ts +10 -3
  39. package/dist/index.js +395 -213
  40. package/dist/index.js.map +1 -1
  41. package/dist/modules/payments-v2/index.cjs +340 -199
  42. package/dist/modules/payments-v2/index.cjs.map +1 -1
  43. package/dist/modules/payments-v2/index.d.cts +68 -19
  44. package/dist/modules/payments-v2/index.d.ts +68 -19
  45. package/dist/modules/payments-v2/index.js +339 -199
  46. package/dist/modules/payments-v2/index.js.map +1 -1
  47. package/package.json +1 -1
@@ -31,6 +31,7 @@ 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,
34
35
  HEARTBEAT_CAP_MS: () => HEARTBEAT_CAP_MS,
35
36
  HEARTBEAT_SEED_MS: () => HEARTBEAT_SEED_MS,
36
37
  MAX_RESELECT: () => MAX_RESELECT,
@@ -66,7 +67,10 @@ var STORE_KEYS = {
66
67
  shortfalls: "shortfalls",
67
68
  settlingLinks: "settling",
68
69
  streamCursor: (s) => `cursor:${s}`,
69
- epochLatch: "epoch-latch"
70
+ epochLatch: "epoch-latch",
71
+ // §5.2 InventoryView durable overlays (#625/#679).
72
+ suspectedSpent: "suspected-spent",
73
+ knownSpends: "known-spends"
70
74
  };
71
75
 
72
76
  // node_modules/@noble/hashes/utils.js
@@ -858,7 +862,7 @@ var TransferMachine = class {
858
862
  }
859
863
  if (cls === null) {
860
864
  await this.applyCommitted(engine, plan.transferId, committed);
861
- this.closeTail(plan.transferId, plan.payload);
865
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
862
866
  return { committed, deliveryPending };
863
867
  }
864
868
  if (cls === "conflict" && committed.length > 0) {
@@ -884,10 +888,10 @@ var TransferMachine = class {
884
888
  }
885
889
  await this.applyCommitted(engine, r.transferId, committed);
886
890
  if (conflicts.length > 0) {
887
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
891
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
888
892
  }
889
893
  await this.completeIntent(r.transferId, r.payload);
890
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
894
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
891
895
  return { deliveryPending, partial: null };
892
896
  }
893
897
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -1069,7 +1073,7 @@ var TransferMachine = class {
1069
1073
  }
1070
1074
  return added;
1071
1075
  }
1072
- async settlePartial(r, committed, conflicts) {
1076
+ async settlePartial(engine, r, committed, conflicts) {
1073
1077
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
1074
1078
  const entry = {
1075
1079
  transferId: r.transferId,
@@ -1081,26 +1085,27 @@ var TransferMachine = class {
1081
1085
  };
1082
1086
  await this.stores.shortfalls.upsert(entry);
1083
1087
  await this.completeIntent(r.transferId, r.payload);
1084
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
1088
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
1085
1089
  return entry;
1086
1090
  }
1087
- closeTail(transferId, payload) {
1091
+ closeTail(engine, transferId, payload, committed) {
1088
1092
  void (async () => {
1089
1093
  try {
1090
1094
  await this.completeIntent(transferId, payload);
1091
1095
  } catch {
1092
1096
  return;
1093
1097
  }
1094
- await this.finishBookkeeping(transferId, payload, "sent");
1098
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
1095
1099
  })();
1096
1100
  }
1097
1101
  async completeIntent(transferId, payload) {
1098
1102
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
1099
1103
  await this.deps.intents.complete(transferId, signature);
1100
1104
  }
1101
- async finishBookkeeping(transferId, payload, phase) {
1105
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
1102
1106
  try {
1103
- 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 });
1104
1109
  } catch {
1105
1110
  }
1106
1111
  try {
@@ -1108,6 +1113,26 @@ var TransferMachine = class {
1108
1113
  } catch {
1109
1114
  }
1110
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
+ }
1111
1136
  async disposeOnFailure(transferId, committed, cls) {
1112
1137
  if (committed.length > 0 || cls === "keep-open") return;
1113
1138
  try {
@@ -1132,7 +1157,7 @@ function conflictAmount(engine, payload, op, source) {
1132
1157
  }
1133
1158
 
1134
1159
  // modules/payments-v2/machine/resume.ts
1135
- async function resumeAll(deps) {
1160
+ async function resumeAll(deps, options = {}) {
1136
1161
  const report = { resumed: [], conflicted: [], failed: [] };
1137
1162
  const stores = createMachineStores(deps.kv);
1138
1163
  const open = await deps.intents.listOpen();
@@ -1142,7 +1167,12 @@ async function resumeAll(deps) {
1142
1167
  } catch {
1143
1168
  return report;
1144
1169
  }
1145
- 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
+ );
1146
1176
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
1147
1177
  const ctx = {
1148
1178
  deps,
@@ -1155,11 +1185,16 @@ async function resumeAll(deps) {
1155
1185
  }
1156
1186
  return report;
1157
1187
  }
1158
- async function dropStaleLocalAborts(stores, locals, openIds) {
1188
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
1159
1189
  for (const entry of locals.values()) {
1160
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
1190
+ if (openIds.has(entry.transferId)) continue;
1191
+ if (entry.disposition !== "open") {
1161
1192
  await stores.backstop.removeByKey(entry.transferId);
1193
+ continue;
1162
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);
1163
1198
  }
1164
1199
  }
1165
1200
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -1367,7 +1402,9 @@ var Converger = class {
1367
1402
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
1368
1403
  }
1369
1404
  async resumePass() {
1370
- const report = await resumeAll(this.filteredMachineDeps());
1405
+ const report = await resumeAll(this.filteredMachineDeps(), {
1406
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
1407
+ });
1371
1408
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
1372
1409
  await this.deps.reconcile({
1373
1410
  resumed: report.resumed,
@@ -1483,6 +1520,136 @@ function distinctOpCount(entries) {
1483
1520
  return new Set(entries.map((entry) => entry.opIndex)).size;
1484
1521
  }
1485
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
+
1486
1653
  // node_modules/@noble/ciphers/utils.js
1487
1654
  function isBytes2(a) {
1488
1655
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -2391,13 +2558,14 @@ var History = class {
2391
2558
  }
2392
2559
  // ── internals ────────────────────────────────────────────────────────────────
2393
2560
  async post(build) {
2561
+ const record = build();
2394
2562
  try {
2395
- await this.deps.client.postHistory([build()]);
2563
+ await this.deps.client.postHistory([record]);
2396
2564
  } catch (err) {
2397
2565
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
2398
2566
  return;
2399
2567
  }
2400
- this.deps.emit("history:updated");
2568
+ this.deps.emit("history:updated", this.toEntry(record));
2401
2569
  }
2402
2570
  ts(timestamp) {
2403
2571
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -2454,93 +2622,9 @@ var History = class {
2454
2622
  }
2455
2623
  };
2456
2624
 
2457
- // modules/payments-v2/inventory/presentation.ts
2458
- function toToken(tokenId, snapshot, asset, registry, flags) {
2459
- const iconUrl = registry.getIconUrl(asset.coinId);
2460
- return {
2461
- id: tokenId,
2462
- coinId: asset.coinId,
2463
- symbol: registry.getSymbol(asset.coinId),
2464
- name: registry.getName(asset.coinId),
2465
- decimals: registry.getDecimals(asset.coinId),
2466
- ...iconUrl !== null ? { iconUrl } : {},
2467
- amount: asset.amount,
2468
- status: flags.transferring ? "transferring" : "confirmed",
2469
- createdAt: snapshot.createdAt,
2470
- updatedAt: snapshot.updatedAt,
2471
- lazy: true,
2472
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
2473
- };
2474
- }
2475
- function aggregateAssets(entries, isTransferring, registry) {
2476
- const groups = /* @__PURE__ */ new Map();
2477
- for (const [tokenId, entry] of entries) {
2478
- const moving = isTransferring(tokenId);
2479
- for (const asset of entry.assets) {
2480
- let group = groups.get(asset.coinId);
2481
- if (!group) {
2482
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
2483
- groups.set(asset.coinId, group);
2484
- }
2485
- if (moving) {
2486
- group.transferring += BigInt(asset.amount);
2487
- group.transferringCount += 1;
2488
- } else {
2489
- group.total += BigInt(asset.amount);
2490
- group.count += 1;
2491
- }
2492
- }
2493
- }
2494
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
2495
- }
2496
- function toAsset(coinId, totals, registry) {
2497
- const iconUrl = registry.getIconUrl(coinId);
2498
- return {
2499
- coinId,
2500
- symbol: registry.getSymbol(coinId),
2501
- name: registry.getName(coinId),
2502
- decimals: registry.getDecimals(coinId),
2503
- ...iconUrl !== null ? { iconUrl } : {},
2504
- totalAmount: totals.total.toString(),
2505
- tokenCount: totals.count,
2506
- confirmedAmount: totals.total.toString(),
2507
- unconfirmedAmount: "0",
2508
- confirmedTokenCount: totals.count,
2509
- unconfirmedTokenCount: 0,
2510
- transferringTokenCount: totals.transferringCount,
2511
- transferringAmount: totals.transferring.toString(),
2512
- priceUsd: null,
2513
- priceEur: null,
2514
- change24h: null,
2515
- fiatValueUsd: null,
2516
- fiatValueEur: null
2517
- };
2518
- }
2519
- async function withPrices(raw, registry, price) {
2520
- try {
2521
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
2522
- const quotes = await price.getPrices(names);
2523
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
2524
- } catch {
2525
- return raw;
2526
- }
2527
- }
2528
- function priceOne(asset, quote) {
2529
- if (!quote) return asset;
2530
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
2531
- return {
2532
- ...asset,
2533
- priceUsd: quote.priceUsd,
2534
- priceEur: quote.priceEur ?? null,
2535
- change24h: quote.change24h ?? null,
2536
- fiatValueUsd: human * quote.priceUsd,
2537
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
2538
- };
2539
- }
2540
-
2541
2625
  // modules/payments-v2/inventory/InventoryView.ts
2542
- var SUSPECTED_KEY = "suspected-spent";
2543
- var KNOWN_SPENDS_KEY = "known-spends";
2626
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
2627
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
2544
2628
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
2545
2629
  function stateKey(tokenId, stateHash) {
2546
2630
  return `${tokenId}:${stateHash}`;
@@ -2936,19 +3020,24 @@ var Receive = class {
2936
3020
  const engine = deps.engine();
2937
3021
  const stored = [];
2938
3022
  const pending = [];
2939
- let epoch = "";
3023
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
2940
3024
  try {
2941
- epoch = deps.syncEpoch();
2942
3025
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
2943
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
2944
- for await (const entry of deps.delivery.incoming(since)) {
2945
- await this.processEntry(deps, engine, entry, pending, stored);
2946
- 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;
2947
3037
  }
2948
- await flushAcks(deps, pending, epoch);
2949
3038
  } catch (err) {
2950
3039
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
2951
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
3040
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
2952
3041
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
2953
3042
  });
2954
3043
  }
@@ -3021,7 +3110,7 @@ async function announce(deps, entry, record) {
3021
3110
  receivedAt
3022
3111
  };
3023
3112
  }
3024
- async function flushAcks(deps, pending, epoch) {
3113
+ async function flushAcks(deps, pending, epochOf) {
3025
3114
  let lastAcked = null;
3026
3115
  try {
3027
3116
  while (pending.length > 0) {
@@ -3032,7 +3121,7 @@ async function flushAcks(deps, pending, epoch) {
3032
3121
  }
3033
3122
  } finally {
3034
3123
  if (lastAcked !== null) {
3035
- const record = { cursor: lastAcked, syncEpoch: epoch };
3124
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
3036
3125
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
3037
3126
  }
3038
3127
  }
@@ -3133,9 +3222,19 @@ var Requests = class {
3133
3222
  }
3134
3223
  });
3135
3224
  }
3225
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
3136
3226
  writeLink(requestId, transferId, committed) {
3137
3227
  return this.mutateJournal((m) => {
3138
- 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);
3139
3238
  return true;
3140
3239
  });
3141
3240
  }
@@ -3288,23 +3387,46 @@ var Requests = class {
3288
3387
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
3289
3388
  if (transferId !== void 0) {
3290
3389
  const committed = error instanceof PartialSendConflictError;
3291
- await this.writeLink(id, transferId, committed);
3292
- this.setStatus(id, "settling");
3390
+ await this.settle(id, transferId, { committed, respond: false });
3293
3391
  } else {
3294
3392
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
3295
3393
  this.setStatus(id, "paid");
3296
3394
  }
3297
3395
  } else {
3298
- await this.clearLink(id);
3299
- this.setStatus(id, "pending");
3396
+ await this.revertPayable(id);
3300
3397
  }
3301
3398
  throw error;
3302
3399
  }
3303
- this.setStatus(id, "paid");
3304
- await this.respondPaid(id, result.id);
3305
- await this.clearLink(id);
3400
+ await this.settle(id, result.id, { committed: true, respond: true });
3306
3401
  return result;
3307
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
+ }
3308
3430
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
3309
3431
  async respondPaid(id, transferId) {
3310
3432
  try {
@@ -3340,32 +3462,32 @@ var Requests = class {
3340
3462
  let aborted = null;
3341
3463
  for (const [requestId, link] of [...this.journal]) {
3342
3464
  if (link.committed || resumed.has(link.transferId)) {
3343
- await this.resolvePaid(requestId, link.transferId);
3465
+ await this.resolvePaid(requestId, link);
3344
3466
  } else if (conflicted.has(link.transferId)) {
3345
3467
  await this.revertPayable(requestId);
3346
3468
  } else if (open.has(link.transferId)) {
3347
3469
  } else {
3348
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
3470
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
3349
3471
  }
3350
3472
  }
3351
3473
  }
3352
3474
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
3353
3475
  // a probe failure defers the link to the next session.
3354
- async reconcileUnaccounted(requestId, transferId, aborted) {
3476
+ async reconcileUnaccounted(requestId, link, aborted) {
3355
3477
  try {
3356
3478
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
3357
- if (aborted.has(transferId)) await this.revertPayable(requestId);
3358
- else await this.resolvePaid(requestId, transferId);
3479
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
3480
+ else await this.resolvePaid(requestId, link);
3359
3481
  } catch (err) {
3360
3482
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
3361
3483
  }
3362
3484
  return aborted;
3363
3485
  }
3364
- async resolvePaid(requestId, transferId) {
3365
- if (!await this.respondPaid(requestId, transferId)) return;
3366
- await this.clearLink(requestId);
3367
- 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 });
3368
3489
  }
3490
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
3369
3491
  async revertPayable(requestId) {
3370
3492
  await this.clearLink(requestId);
3371
3493
  this.setStatus(requestId, "pending");
@@ -3689,11 +3811,12 @@ function composeFacadeParts(deps, hooks) {
3689
3811
  client: deps.client,
3690
3812
  fieldKey: deps.fieldKey,
3691
3813
  registry: deps.registry,
3692
- emit: (event) => deps.emit(event, {}),
3814
+ emit: (event, entry) => deps.emit(event, entry),
3693
3815
  ...deps.now !== void 0 ? { now: deps.now } : {},
3694
3816
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
3695
3817
  });
3696
3818
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
3819
+ const machineStores = createMachineStores(deps.kv);
3697
3820
  const heldStates = /* @__PURE__ */ new Map();
3698
3821
  return {
3699
3822
  ownPubkeyBytes,
@@ -3701,12 +3824,29 @@ function composeFacadeParts(deps, hooks) {
3701
3824
  ledger,
3702
3825
  queue,
3703
3826
  historyStore,
3704
- machineStores: createMachineStores(deps.kv),
3827
+ machineStores,
3705
3828
  machineDeps,
3706
3829
  machine: new TransferMachine(machineDeps),
3707
3830
  heldStates,
3708
3831
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
3709
- 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
+ }
3710
3850
  };
3711
3851
  }
3712
3852
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -3728,11 +3868,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
3728
3868
  ownPubkey: hexToBytes(deps.ownPubkey),
3729
3869
  emit: deps.emit,
3730
3870
  now: deps.now ?? Date.now,
3731
- recordHistory: async ({ transferId, payload }) => {
3871
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
3732
3872
  await historyStore.recordSent({
3733
3873
  transferId,
3734
3874
  coinId: payload.coinId,
3735
- 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,
3736
3878
  recipientPubkey: payload.recipient,
3737
3879
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
3738
3880
  });
@@ -3759,7 +3901,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
3759
3901
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
3760
3902
  );
3761
3903
  },
3762
- syncEpoch: deps.syncEpoch ?? (() => ""),
3904
+ syncEpoch: deps.syncEpoch,
3763
3905
  ...deps.now !== void 0 ? { now: deps.now } : {}
3764
3906
  });
3765
3907
  }
@@ -3812,6 +3954,7 @@ var PaymentsFacade = class {
3812
3954
  this.receiveLoop = parts.receiveLoop;
3813
3955
  this.heldStates = parts.heldStates;
3814
3956
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3957
+ this.restoreDeps = parts.restoreDeps;
3815
3958
  this.requests = parts.requests;
3816
3959
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3817
3960
  this.heartbeat = new ConvergenceHeartbeat({
@@ -3840,6 +3983,7 @@ var PaymentsFacade = class {
3840
3983
  receiveLoop;
3841
3984
  heldStates;
3842
3985
  ownPubkeyBytes;
3986
+ restoreDeps;
3843
3987
  requests;
3844
3988
  currentEngine = null;
3845
3989
  pendingOps = /* @__PURE__ */ new Set();
@@ -3850,6 +3994,8 @@ var PaymentsFacade = class {
3850
3994
  converger;
3851
3995
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
3852
3996
  resumeFlight = new SingleFlight();
3997
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
3998
+ passChain = new SerialChain();
3853
3999
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3854
4000
  activeMoneyOps = /* @__PURE__ */ new Set();
3855
4001
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -3857,8 +4003,8 @@ var PaymentsFacade = class {
3857
4003
  if (this.started) return;
3858
4004
  this.started = true;
3859
4005
  this.heartbeat.start();
3860
- await this.deps.session.start();
3861
4006
  this.unsubscribers.push(
4007
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
3862
4008
  this.deps.session.subscribeStream("mailbox", () => {
3863
4009
  this.trackTail(this.receiveLoop.drainOnce());
3864
4010
  }),
@@ -3873,6 +4019,7 @@ var PaymentsFacade = class {
3873
4019
  if (status === "connected") this.heartbeat.recovered();
3874
4020
  });
3875
4021
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4022
+ await this.deps.session.start();
3876
4023
  this.trackTail(this.runConvergencePass());
3877
4024
  this.trackTail(this.seedHeldStates());
3878
4025
  await this.view.fullPull().catch(() => void 0);
@@ -3913,7 +4060,7 @@ var PaymentsFacade = class {
3913
4060
  }
3914
4061
  // ── money movement ─────────────────────────────────────────────────────────
3915
4062
  send(request) {
3916
- return this.track(this.sendWithPolicy(request));
4063
+ return this.track(this.sendOutcome(request));
3917
4064
  }
3918
4065
  async receive() {
3919
4066
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -3928,20 +4075,27 @@ var PaymentsFacade = class {
3928
4075
  if (!this.started) return;
3929
4076
  await this.track(this.runConvergencePass());
3930
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
+ }
3931
4083
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
3932
4084
  runConvergencePass() {
3933
- return this.resumeFlight.run(async () => {
3934
- const outcome = await this.converger.convergeOnce();
3935
- const pending = await this.converger.pendingWork().catch(() => true);
3936
- this.heartbeat.settle(outcome, pending);
3937
- });
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);
3938
4091
  }
3939
4092
  nowMs() {
3940
4093
  return (this.deps.now ?? Date.now)();
3941
4094
  }
3942
4095
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
3943
- async sendWithPolicy(request) {
3944
- 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) {
3945
4099
  const run = {
3946
4100
  amount: request.amount,
3947
4101
  delivered: [],
@@ -3949,12 +4103,36 @@ var PaymentsFacade = class {
3949
4103
  tokenTransfers: [],
3950
4104
  settledShortfalls: [],
3951
4105
  firstPartialId: void 0,
3952
- pendingAny: false
4106
+ pendingAny: false,
4107
+ lastTransferId: ""
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)
3953
4127
  };
4128
+ this.deps.emit("transfer:updated", failed);
4129
+ }
4130
+ async sendWithPolicy(request, run) {
4131
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
3954
4132
  for (let attempt = 0; ; attempt++) {
3955
4133
  let ctx;
3956
4134
  try {
3957
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
4135
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
3958
4136
  } catch (err) {
3959
4137
  throw this.partialize(err, run);
3960
4138
  }
@@ -3999,11 +4177,8 @@ var PaymentsFacade = class {
3999
4177
  this.activeMoneyOps.delete(ctx.transferId);
4000
4178
  }
4001
4179
  }
4002
- /**
4003
- * Partial outcome (#677/#690): the shortfall is already durable (written by
4004
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
4005
- * the remainder under a NEW transferId — never the full amount.
4006
- */
4180
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
4181
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
4007
4182
  async consumePartial(ctx, disposition, run, attempt) {
4008
4183
  if (disposition.deliveryPending) {
4009
4184
  run.pendingAny = true;
@@ -4029,11 +4204,8 @@ var PaymentsFacade = class {
4029
4204
  run.amount = partial.remainingAmount;
4030
4205
  return null;
4031
4206
  }
4032
- /**
4033
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
4034
- * backstop still 'open' (committed>0) → converge via the same machine's
4035
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
4036
- */
4207
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
4208
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
4037
4209
  async disposeFailedAttempt(ctx, err) {
4038
4210
  if (isPossiblyCommittedSendOutcome(err)) {
4039
4211
  this.settleKeepOpen(ctx);
@@ -4082,8 +4254,9 @@ var PaymentsFacade = class {
4082
4254
  }
4083
4255
  return { kind: "rethrow", error: err };
4084
4256
  }
4085
- async planAndMaterialize(recipientPubkey, request) {
4257
+ async planAndMaterialize(recipientPubkey, request, run) {
4086
4258
  const transferId = this.newId();
4259
+ run.lastTransferId = transferId;
4087
4260
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
4088
4261
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
4089
4262
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -4215,11 +4388,7 @@ var PaymentsFacade = class {
4215
4388
  this.deps.emit("transfer:updated", result);
4216
4389
  return result;
4217
4390
  }
4218
- /**
4219
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
4220
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
4221
- * over the accumulated settled set — never bare, never a full-amount retry.
4222
- */
4391
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
4223
4392
  partialize(err, run) {
4224
4393
  if (run.delivered.length === 0) return err;
4225
4394
  return new PartialSendConflictError(
@@ -4371,22 +4540,14 @@ var PaymentsFacade = class {
4371
4540
  }
4372
4541
  }
4373
4542
  toUiToken(tokenId, coinId, amount) {
4374
- const registry = this.deps.registry;
4375
- const iconUrl = registry.getIconUrl(coinId);
4376
4543
  const now = this.nowMs();
4377
- return {
4378
- id: tokenId,
4379
- coinId,
4380
- symbol: registry.getSymbol(coinId),
4381
- name: registry.getName(coinId),
4382
- decimals: registry.getDecimals(coinId),
4383
- ...iconUrl !== null ? { iconUrl } : {},
4384
- amount: amount.toString(),
4385
- status: "transferring",
4386
- createdAt: now,
4387
- updatedAt: now,
4388
- lazy: true
4389
- };
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
+ );
4390
4551
  }
4391
4552
  track(op) {
4392
4553
  const settled = op.then(
@@ -4401,30 +4562,10 @@ var PaymentsFacade = class {
4401
4562
  this.track(op).catch(() => void 0);
4402
4563
  }
4403
4564
  };
4404
- function buildPayload(recipientPubkey, request, spend, spentStates) {
4405
- return {
4406
- v: 2,
4407
- recipient: recipientPubkey,
4408
- coinId: request.coinId,
4409
- amount: request.amount,
4410
- ...request.memo !== void 0 ? { memo: request.memo } : {},
4411
- direct: [...spend.plan.direct],
4412
- ...spend.plan.split !== void 0 ? {
4413
- split: {
4414
- tokenId: spend.plan.split.tokenId,
4415
- splitAmount: spend.plan.split.splitAmount.toString(),
4416
- remainderAmount: spend.plan.split.remainderAmount.toString()
4417
- }
4418
- } : {},
4419
- spentStates
4420
- };
4421
- }
4422
- function messageOf(err) {
4423
- return err instanceof Error ? err.message : String(err);
4424
- }
4425
4565
  // Annotate the CommonJS export names for ESM import in node:
4426
4566
  0 && (module.exports = {
4427
4567
  ATTENTION_MINT_UNRESOLVED,
4568
+ ATTENTION_RESEED_REJECTED,
4428
4569
  HEARTBEAT_CAP_MS,
4429
4570
  HEARTBEAT_SEED_MS,
4430
4571
  MAX_RESELECT,