@unicitylabs/sphere-sdk 0.14.0-dev.6 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,10 @@ var STORE_KEYS = {
23
23
  shortfalls: "shortfalls",
24
24
  settlingLinks: "settling",
25
25
  streamCursor: (s) => `cursor:${s}`,
26
- epochLatch: "epoch-latch"
26
+ epochLatch: "epoch-latch",
27
+ // §5.2 InventoryView durable overlays (#625/#679).
28
+ suspectedSpent: "suspected-spent",
29
+ knownSpends: "known-spends"
27
30
  };
28
31
 
29
32
  // node_modules/@noble/hashes/utils.js
@@ -815,7 +818,7 @@ var TransferMachine = class {
815
818
  }
816
819
  if (cls === null) {
817
820
  await this.applyCommitted(engine, plan.transferId, committed);
818
- this.closeTail(plan.transferId, plan.payload);
821
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
819
822
  return { committed, deliveryPending };
820
823
  }
821
824
  if (cls === "conflict" && committed.length > 0) {
@@ -841,10 +844,10 @@ var TransferMachine = class {
841
844
  }
842
845
  await this.applyCommitted(engine, r.transferId, committed);
843
846
  if (conflicts.length > 0) {
844
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
847
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
845
848
  }
846
849
  await this.completeIntent(r.transferId, r.payload);
847
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
850
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
848
851
  return { deliveryPending, partial: null };
849
852
  }
850
853
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -1026,7 +1029,7 @@ var TransferMachine = class {
1026
1029
  }
1027
1030
  return added;
1028
1031
  }
1029
- async settlePartial(r, committed, conflicts) {
1032
+ async settlePartial(engine, r, committed, conflicts) {
1030
1033
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
1031
1034
  const entry = {
1032
1035
  transferId: r.transferId,
@@ -1038,26 +1041,27 @@ var TransferMachine = class {
1038
1041
  };
1039
1042
  await this.stores.shortfalls.upsert(entry);
1040
1043
  await this.completeIntent(r.transferId, r.payload);
1041
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
1044
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
1042
1045
  return entry;
1043
1046
  }
1044
- closeTail(transferId, payload) {
1047
+ closeTail(engine, transferId, payload, committed) {
1045
1048
  void (async () => {
1046
1049
  try {
1047
1050
  await this.completeIntent(transferId, payload);
1048
1051
  } catch {
1049
1052
  return;
1050
1053
  }
1051
- await this.finishBookkeeping(transferId, payload, "sent");
1054
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
1052
1055
  })();
1053
1056
  }
1054
1057
  async completeIntent(transferId, payload) {
1055
1058
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
1056
1059
  await this.deps.intents.complete(transferId, signature);
1057
1060
  }
1058
- async finishBookkeeping(transferId, payload, phase) {
1061
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
1059
1062
  try {
1060
- await this.deps.recordHistory?.({ transferId, payload, phase });
1063
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
1064
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
1061
1065
  } catch {
1062
1066
  }
1063
1067
  try {
@@ -1065,6 +1069,26 @@ var TransferMachine = class {
1065
1069
  } catch {
1066
1070
  }
1067
1071
  }
1072
+ /**
1073
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
1074
+ * certified recipient blobs (summarize()'s committed set is the only source;
1075
+ * a journal-rehydrated resume leg has no source token, but every committed
1076
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
1077
+ */
1078
+ async settledAmount(engine, coinId, committed) {
1079
+ let total = 0n;
1080
+ for (const outcome of committed) {
1081
+ if (outcome.recipientBlob === void 0) continue;
1082
+ const token = await engine.decodeToken({
1083
+ v: TOKEN_BLOB_VERSION,
1084
+ network: 0,
1085
+ tokenId: "",
1086
+ token: outcome.recipientBlob
1087
+ });
1088
+ total += engine.balanceOf(token, coinId);
1089
+ }
1090
+ return total.toString();
1091
+ }
1068
1092
  async disposeOnFailure(transferId, committed, cls) {
1069
1093
  if (committed.length > 0 || cls === "keep-open") return;
1070
1094
  try {
@@ -1089,7 +1113,7 @@ function conflictAmount(engine, payload, op, source) {
1089
1113
  }
1090
1114
 
1091
1115
  // modules/payments-v2/machine/resume.ts
1092
- async function resumeAll(deps) {
1116
+ async function resumeAll(deps, options = {}) {
1093
1117
  const report = { resumed: [], conflicted: [], failed: [] };
1094
1118
  const stores = createMachineStores(deps.kv);
1095
1119
  const open = await deps.intents.listOpen();
@@ -1099,7 +1123,12 @@ async function resumeAll(deps) {
1099
1123
  } catch {
1100
1124
  return report;
1101
1125
  }
1102
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
1126
+ await dropStaleBackstops(
1127
+ stores,
1128
+ locals,
1129
+ new Set(open.map((i) => i.transferId)),
1130
+ options.isLocallyActive ?? (() => false)
1131
+ );
1103
1132
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
1104
1133
  const ctx = {
1105
1134
  deps,
@@ -1112,11 +1141,16 @@ async function resumeAll(deps) {
1112
1141
  }
1113
1142
  return report;
1114
1143
  }
1115
- async function dropStaleLocalAborts(stores, locals, openIds) {
1144
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
1116
1145
  for (const entry of locals.values()) {
1117
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
1146
+ if (openIds.has(entry.transferId)) continue;
1147
+ if (entry.disposition !== "open") {
1118
1148
  await stores.backstop.removeByKey(entry.transferId);
1149
+ continue;
1119
1150
  }
1151
+ if (isLocallyActive(entry.transferId)) continue;
1152
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
1153
+ await stores.backstop.removeByKey(entry.transferId);
1120
1154
  }
1121
1155
  }
1122
1156
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -1324,7 +1358,9 @@ var Converger = class {
1324
1358
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
1325
1359
  }
1326
1360
  async resumePass() {
1327
- const report = await resumeAll(this.filteredMachineDeps());
1361
+ const report = await resumeAll(this.filteredMachineDeps(), {
1362
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
1363
+ });
1328
1364
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
1329
1365
  await this.deps.reconcile({
1330
1366
  resumed: report.resumed,
@@ -1440,6 +1476,136 @@ function distinctOpCount(entries) {
1440
1476
  return new Set(entries.map((entry) => entry.opIndex)).size;
1441
1477
  }
1442
1478
 
1479
+ // modules/payments-v2/restore.ts
1480
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
1481
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
1482
+ async function reseedAndReset(deps) {
1483
+ for (const entry of await deps.stores.backstop.list()) {
1484
+ if (entry.disposition !== "open") continue;
1485
+ try {
1486
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
1487
+ if (entry.requiresSeedClose) {
1488
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
1489
+ }
1490
+ } catch (err) {
1491
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
1492
+ }
1493
+ }
1494
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
1495
+ await deps.fullRePull();
1496
+ }
1497
+ async function splitOpIndex(deps, envelope) {
1498
+ const payload = await deps.decryptPayload(envelope);
1499
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
1500
+ }
1501
+
1502
+ // modules/payments-v2/inventory/presentation.ts
1503
+ function toToken(tokenId, snapshot, asset, registry, flags) {
1504
+ const iconUrl = registry.getIconUrl(asset.coinId);
1505
+ return {
1506
+ id: tokenId,
1507
+ coinId: asset.coinId,
1508
+ symbol: registry.getSymbol(asset.coinId),
1509
+ name: registry.getName(asset.coinId),
1510
+ decimals: registry.getDecimals(asset.coinId),
1511
+ ...iconUrl !== null ? { iconUrl } : {},
1512
+ amount: asset.amount,
1513
+ status: flags.transferring ? "transferring" : "confirmed",
1514
+ createdAt: snapshot.createdAt,
1515
+ updatedAt: snapshot.updatedAt,
1516
+ lazy: true,
1517
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
1518
+ };
1519
+ }
1520
+ function aggregateAssets(entries, isTransferring, registry) {
1521
+ const groups = /* @__PURE__ */ new Map();
1522
+ for (const [tokenId, entry] of entries) {
1523
+ const moving = isTransferring(tokenId);
1524
+ for (const asset of entry.assets) {
1525
+ let group = groups.get(asset.coinId);
1526
+ if (!group) {
1527
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
1528
+ groups.set(asset.coinId, group);
1529
+ }
1530
+ if (moving) {
1531
+ group.transferring += BigInt(asset.amount);
1532
+ group.transferringCount += 1;
1533
+ } else {
1534
+ group.total += BigInt(asset.amount);
1535
+ group.count += 1;
1536
+ }
1537
+ }
1538
+ }
1539
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
1540
+ }
1541
+ function toAsset(coinId, totals, registry) {
1542
+ const iconUrl = registry.getIconUrl(coinId);
1543
+ return {
1544
+ coinId,
1545
+ symbol: registry.getSymbol(coinId),
1546
+ name: registry.getName(coinId),
1547
+ decimals: registry.getDecimals(coinId),
1548
+ ...iconUrl !== null ? { iconUrl } : {},
1549
+ totalAmount: totals.total.toString(),
1550
+ tokenCount: totals.count,
1551
+ confirmedAmount: totals.total.toString(),
1552
+ unconfirmedAmount: "0",
1553
+ confirmedTokenCount: totals.count,
1554
+ unconfirmedTokenCount: 0,
1555
+ transferringTokenCount: totals.transferringCount,
1556
+ transferringAmount: totals.transferring.toString(),
1557
+ priceUsd: null,
1558
+ priceEur: null,
1559
+ change24h: null,
1560
+ fiatValueUsd: null,
1561
+ fiatValueEur: null
1562
+ };
1563
+ }
1564
+ async function withPrices(raw, registry, price) {
1565
+ try {
1566
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
1567
+ const quotes = await price.getPrices(names);
1568
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
1569
+ } catch {
1570
+ return raw;
1571
+ }
1572
+ }
1573
+ function priceOne(asset, quote) {
1574
+ if (!quote) return asset;
1575
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
1576
+ return {
1577
+ ...asset,
1578
+ priceUsd: quote.priceUsd,
1579
+ priceEur: quote.priceEur ?? null,
1580
+ change24h: quote.change24h ?? null,
1581
+ fiatValueUsd: human * quote.priceUsd,
1582
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
1583
+ };
1584
+ }
1585
+
1586
+ // modules/payments-v2/machine/payload.ts
1587
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
1588
+ return {
1589
+ v: 2,
1590
+ recipient: recipientPubkey,
1591
+ coinId: request.coinId,
1592
+ amount: request.amount,
1593
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
1594
+ direct: [...spend.plan.direct],
1595
+ ...spend.plan.split !== void 0 ? {
1596
+ split: {
1597
+ tokenId: spend.plan.split.tokenId,
1598
+ splitAmount: spend.plan.split.splitAmount.toString(),
1599
+ remainderAmount: spend.plan.split.remainderAmount.toString()
1600
+ }
1601
+ } : {},
1602
+ spentStates
1603
+ };
1604
+ }
1605
+ function messageOf(err) {
1606
+ return err instanceof Error ? err.message : String(err);
1607
+ }
1608
+
1443
1609
  // node_modules/@noble/ciphers/utils.js
1444
1610
  function isBytes2(a) {
1445
1611
  return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
@@ -2348,13 +2514,14 @@ var History = class {
2348
2514
  }
2349
2515
  // ── internals ────────────────────────────────────────────────────────────────
2350
2516
  async post(build) {
2517
+ const record = build();
2351
2518
  try {
2352
- await this.deps.client.postHistory([build()]);
2519
+ await this.deps.client.postHistory([record]);
2353
2520
  } catch (err) {
2354
2521
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
2355
2522
  return;
2356
2523
  }
2357
- this.deps.emit("history:updated");
2524
+ this.deps.emit("history:updated", this.toEntry(record));
2358
2525
  }
2359
2526
  ts(timestamp) {
2360
2527
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -2411,93 +2578,9 @@ var History = class {
2411
2578
  }
2412
2579
  };
2413
2580
 
2414
- // modules/payments-v2/inventory/presentation.ts
2415
- function toToken(tokenId, snapshot, asset, registry, flags) {
2416
- const iconUrl = registry.getIconUrl(asset.coinId);
2417
- return {
2418
- id: tokenId,
2419
- coinId: asset.coinId,
2420
- symbol: registry.getSymbol(asset.coinId),
2421
- name: registry.getName(asset.coinId),
2422
- decimals: registry.getDecimals(asset.coinId),
2423
- ...iconUrl !== null ? { iconUrl } : {},
2424
- amount: asset.amount,
2425
- status: flags.transferring ? "transferring" : "confirmed",
2426
- createdAt: snapshot.createdAt,
2427
- updatedAt: snapshot.updatedAt,
2428
- lazy: true,
2429
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
2430
- };
2431
- }
2432
- function aggregateAssets(entries, isTransferring, registry) {
2433
- const groups = /* @__PURE__ */ new Map();
2434
- for (const [tokenId, entry] of entries) {
2435
- const moving = isTransferring(tokenId);
2436
- for (const asset of entry.assets) {
2437
- let group = groups.get(asset.coinId);
2438
- if (!group) {
2439
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
2440
- groups.set(asset.coinId, group);
2441
- }
2442
- if (moving) {
2443
- group.transferring += BigInt(asset.amount);
2444
- group.transferringCount += 1;
2445
- } else {
2446
- group.total += BigInt(asset.amount);
2447
- group.count += 1;
2448
- }
2449
- }
2450
- }
2451
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
2452
- }
2453
- function toAsset(coinId, totals, registry) {
2454
- const iconUrl = registry.getIconUrl(coinId);
2455
- return {
2456
- coinId,
2457
- symbol: registry.getSymbol(coinId),
2458
- name: registry.getName(coinId),
2459
- decimals: registry.getDecimals(coinId),
2460
- ...iconUrl !== null ? { iconUrl } : {},
2461
- totalAmount: totals.total.toString(),
2462
- tokenCount: totals.count,
2463
- confirmedAmount: totals.total.toString(),
2464
- unconfirmedAmount: "0",
2465
- confirmedTokenCount: totals.count,
2466
- unconfirmedTokenCount: 0,
2467
- transferringTokenCount: totals.transferringCount,
2468
- transferringAmount: totals.transferring.toString(),
2469
- priceUsd: null,
2470
- priceEur: null,
2471
- change24h: null,
2472
- fiatValueUsd: null,
2473
- fiatValueEur: null
2474
- };
2475
- }
2476
- async function withPrices(raw, registry, price) {
2477
- try {
2478
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
2479
- const quotes = await price.getPrices(names);
2480
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
2481
- } catch {
2482
- return raw;
2483
- }
2484
- }
2485
- function priceOne(asset, quote) {
2486
- if (!quote) return asset;
2487
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
2488
- return {
2489
- ...asset,
2490
- priceUsd: quote.priceUsd,
2491
- priceEur: quote.priceEur ?? null,
2492
- change24h: quote.change24h ?? null,
2493
- fiatValueUsd: human * quote.priceUsd,
2494
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
2495
- };
2496
- }
2497
-
2498
2581
  // modules/payments-v2/inventory/InventoryView.ts
2499
- var SUSPECTED_KEY = "suspected-spent";
2500
- var KNOWN_SPENDS_KEY = "known-spends";
2582
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
2583
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
2501
2584
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
2502
2585
  function stateKey(tokenId, stateHash) {
2503
2586
  return `${tokenId}:${stateHash}`;
@@ -2893,19 +2976,24 @@ var Receive = class {
2893
2976
  const engine = deps.engine();
2894
2977
  const stored = [];
2895
2978
  const pending = [];
2896
- let epoch = "";
2979
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
2897
2980
  try {
2898
- epoch = deps.syncEpoch();
2899
2981
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
2900
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
2901
- for await (const entry of deps.delivery.incoming(since)) {
2902
- await this.processEntry(deps, engine, entry, pending, stored);
2903
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
2982
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
2983
+ for (let pass = 0; pass < 2; pass++) {
2984
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
2985
+ await this.processEntry(deps, engine, entry, pending, stored);
2986
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
2987
+ }
2988
+ await flushAcks(deps, pending, pageEpoch);
2989
+ const served = deps.delivery.incomingEpoch();
2990
+ if (basis === null || served === null || served === basis.syncEpoch) break;
2991
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
2992
+ basis = null;
2904
2993
  }
2905
- await flushAcks(deps, pending, epoch);
2906
2994
  } catch (err) {
2907
2995
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
2908
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
2996
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
2909
2997
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
2910
2998
  });
2911
2999
  }
@@ -2978,7 +3066,7 @@ async function announce(deps, entry, record) {
2978
3066
  receivedAt
2979
3067
  };
2980
3068
  }
2981
- async function flushAcks(deps, pending, epoch) {
3069
+ async function flushAcks(deps, pending, epochOf) {
2982
3070
  let lastAcked = null;
2983
3071
  try {
2984
3072
  while (pending.length > 0) {
@@ -2989,7 +3077,7 @@ async function flushAcks(deps, pending, epoch) {
2989
3077
  }
2990
3078
  } finally {
2991
3079
  if (lastAcked !== null) {
2992
- const record = { cursor: lastAcked, syncEpoch: epoch };
3080
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
2993
3081
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
2994
3082
  }
2995
3083
  }
@@ -3090,9 +3178,19 @@ var Requests = class {
3090
3178
  }
3091
3179
  });
3092
3180
  }
3181
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
3093
3182
  writeLink(requestId, transferId, committed) {
3094
3183
  return this.mutateJournal((m) => {
3095
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
3184
+ const prev = m.get(requestId);
3185
+ const same = prev !== void 0 && prev.transferId === transferId;
3186
+ const next = {
3187
+ requestId,
3188
+ transferId,
3189
+ committed: committed || same && prev.committed,
3190
+ createdAt: same ? prev.createdAt : this.now()
3191
+ };
3192
+ if (same && prev.committed === next.committed) return false;
3193
+ m.set(requestId, next);
3096
3194
  return true;
3097
3195
  });
3098
3196
  }
@@ -3245,23 +3343,46 @@ var Requests = class {
3245
3343
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
3246
3344
  if (transferId !== void 0) {
3247
3345
  const committed = error instanceof PartialSendConflictError;
3248
- await this.writeLink(id, transferId, committed);
3249
- this.setStatus(id, "settling");
3346
+ await this.settle(id, transferId, { committed, respond: false });
3250
3347
  } else {
3251
3348
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
3252
3349
  this.setStatus(id, "paid");
3253
3350
  }
3254
3351
  } else {
3255
- await this.clearLink(id);
3256
- this.setStatus(id, "pending");
3352
+ await this.revertPayable(id);
3257
3353
  }
3258
3354
  throw error;
3259
3355
  }
3260
- this.setStatus(id, "paid");
3261
- await this.respondPaid(id, result.id);
3262
- await this.clearLink(id);
3356
+ await this.settle(id, result.id, { committed: true, respond: true });
3263
3357
  return result;
3264
3358
  }
3359
+ /**
3360
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
3361
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
3362
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
3363
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
3364
+ * not a reload. Every path that binds a request to a transfer outcome funnels
3365
+ * through here — pay()'s clean success (respond now), pay()'s
3366
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
3367
+ * — so a failed respond always leaves the link + 'settling' and the next
3368
+ * reconcile pass (the committed-link override) retries the respond.
3369
+ */
3370
+ async settle(requestId, transferId, opts) {
3371
+ let journalDown = false;
3372
+ try {
3373
+ await this.writeLink(requestId, transferId, opts.committed);
3374
+ } catch (err) {
3375
+ if (!opts.respond) throw err;
3376
+ journalDown = true;
3377
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
3378
+ }
3379
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
3380
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
3381
+ return;
3382
+ }
3383
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
3384
+ this.setStatus(requestId, "paid");
3385
+ }
3265
3386
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
3266
3387
  async respondPaid(id, transferId) {
3267
3388
  try {
@@ -3297,32 +3418,32 @@ var Requests = class {
3297
3418
  let aborted = null;
3298
3419
  for (const [requestId, link] of [...this.journal]) {
3299
3420
  if (link.committed || resumed.has(link.transferId)) {
3300
- await this.resolvePaid(requestId, link.transferId);
3421
+ await this.resolvePaid(requestId, link);
3301
3422
  } else if (conflicted.has(link.transferId)) {
3302
3423
  await this.revertPayable(requestId);
3303
3424
  } else if (open.has(link.transferId)) {
3304
3425
  } else {
3305
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
3426
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
3306
3427
  }
3307
3428
  }
3308
3429
  }
3309
3430
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
3310
3431
  // a probe failure defers the link to the next session.
3311
- async reconcileUnaccounted(requestId, transferId, aborted) {
3432
+ async reconcileUnaccounted(requestId, link, aborted) {
3312
3433
  try {
3313
3434
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
3314
- if (aborted.has(transferId)) await this.revertPayable(requestId);
3315
- else await this.resolvePaid(requestId, transferId);
3435
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
3436
+ else await this.resolvePaid(requestId, link);
3316
3437
  } catch (err) {
3317
3438
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
3318
3439
  }
3319
3440
  return aborted;
3320
3441
  }
3321
- async resolvePaid(requestId, transferId) {
3322
- if (!await this.respondPaid(requestId, transferId)) return;
3323
- await this.clearLink(requestId);
3324
- this.setStatus(requestId, "paid");
3442
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
3443
+ resolvePaid(requestId, link) {
3444
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
3325
3445
  }
3446
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
3326
3447
  async revertPayable(requestId) {
3327
3448
  await this.clearLink(requestId);
3328
3449
  this.setStatus(requestId, "pending");
@@ -3646,11 +3767,12 @@ function composeFacadeParts(deps, hooks) {
3646
3767
  client: deps.client,
3647
3768
  fieldKey: deps.fieldKey,
3648
3769
  registry: deps.registry,
3649
- emit: (event) => deps.emit(event, {}),
3770
+ emit: (event, entry) => deps.emit(event, entry),
3650
3771
  ...deps.now !== void 0 ? { now: deps.now } : {},
3651
3772
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
3652
3773
  });
3653
3774
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
3775
+ const machineStores = createMachineStores(deps.kv);
3654
3776
  const heldStates = /* @__PURE__ */ new Map();
3655
3777
  return {
3656
3778
  ownPubkeyBytes,
@@ -3658,12 +3780,29 @@ function composeFacadeParts(deps, hooks) {
3658
3780
  ledger,
3659
3781
  queue,
3660
3782
  historyStore,
3661
- machineStores: createMachineStores(deps.kv),
3783
+ machineStores,
3662
3784
  machineDeps,
3663
3785
  machine: new TransferMachine(machineDeps),
3664
3786
  heldStates,
3665
3787
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
3666
- requests: buildRequests(deps, hooks)
3788
+ requests: buildRequests(deps, hooks),
3789
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3790
+ };
3791
+ }
3792
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3793
+ return {
3794
+ stores: machineStores,
3795
+ kv: deps.kv,
3796
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
3797
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
3798
+ decryptPayload: machineDeps.decryptPayload,
3799
+ fullRePull: () => view.onEpochReset(),
3800
+ attention: (transferId, code, detail) => {
3801
+ deps.emit(
3802
+ "transfer:attention",
3803
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
3804
+ );
3805
+ }
3667
3806
  };
3668
3807
  }
3669
3808
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -3685,11 +3824,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
3685
3824
  ownPubkey: hexToBytes(deps.ownPubkey),
3686
3825
  emit: deps.emit,
3687
3826
  now: deps.now ?? Date.now,
3688
- recordHistory: async ({ transferId, payload }) => {
3827
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
3689
3828
  await historyStore.recordSent({
3690
3829
  transferId,
3691
3830
  coinId: payload.coinId,
3692
- amount: payload.amount,
3831
+ // §5.9: the SETTLED amount (machine-computed from the certified
3832
+ // recipient blobs), never payload.amount — the plan.
3833
+ amount: committedAmount,
3693
3834
  recipientPubkey: payload.recipient,
3694
3835
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
3695
3836
  });
@@ -3716,7 +3857,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
3716
3857
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
3717
3858
  );
3718
3859
  },
3719
- syncEpoch: deps.syncEpoch ?? (() => ""),
3860
+ syncEpoch: deps.syncEpoch,
3720
3861
  ...deps.now !== void 0 ? { now: deps.now } : {}
3721
3862
  });
3722
3863
  }
@@ -3769,6 +3910,7 @@ var PaymentsFacade = class {
3769
3910
  this.receiveLoop = parts.receiveLoop;
3770
3911
  this.heldStates = parts.heldStates;
3771
3912
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3913
+ this.restoreDeps = parts.restoreDeps;
3772
3914
  this.requests = parts.requests;
3773
3915
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3774
3916
  this.heartbeat = new ConvergenceHeartbeat({
@@ -3797,6 +3939,7 @@ var PaymentsFacade = class {
3797
3939
  receiveLoop;
3798
3940
  heldStates;
3799
3941
  ownPubkeyBytes;
3942
+ restoreDeps;
3800
3943
  requests;
3801
3944
  currentEngine = null;
3802
3945
  pendingOps = /* @__PURE__ */ new Set();
@@ -3807,6 +3950,8 @@ var PaymentsFacade = class {
3807
3950
  converger;
3808
3951
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
3809
3952
  resumeFlight = new SingleFlight();
3953
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
3954
+ passChain = new SerialChain();
3810
3955
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3811
3956
  activeMoneyOps = /* @__PURE__ */ new Set();
3812
3957
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -3814,8 +3959,8 @@ var PaymentsFacade = class {
3814
3959
  if (this.started) return;
3815
3960
  this.started = true;
3816
3961
  this.heartbeat.start();
3817
- await this.deps.session.start();
3818
3962
  this.unsubscribers.push(
3963
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
3819
3964
  this.deps.session.subscribeStream("mailbox", () => {
3820
3965
  this.trackTail(this.receiveLoop.drainOnce());
3821
3966
  }),
@@ -3830,6 +3975,7 @@ var PaymentsFacade = class {
3830
3975
  if (status === "connected") this.heartbeat.recovered();
3831
3976
  });
3832
3977
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
3978
+ await this.deps.session.start();
3833
3979
  this.trackTail(this.runConvergencePass());
3834
3980
  this.trackTail(this.seedHeldStates());
3835
3981
  await this.view.fullPull().catch(() => void 0);
@@ -3870,7 +4016,7 @@ var PaymentsFacade = class {
3870
4016
  }
3871
4017
  // ── money movement ─────────────────────────────────────────────────────────
3872
4018
  send(request) {
3873
- return this.track(this.sendWithPolicy(request));
4019
+ return this.track(this.sendOutcome(request));
3874
4020
  }
3875
4021
  async receive() {
3876
4022
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -3885,20 +4031,27 @@ var PaymentsFacade = class {
3885
4031
  if (!this.started) return;
3886
4032
  await this.track(this.runConvergencePass());
3887
4033
  }
4034
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
4035
+ async handleEpochChange(_newEpoch) {
4036
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
4037
+ await this.passChain.enqueue(() => this.convergeBody());
4038
+ }
3888
4039
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
3889
4040
  runConvergencePass() {
3890
- return this.resumeFlight.run(async () => {
3891
- const outcome = await this.converger.convergeOnce();
3892
- const pending = await this.converger.pendingWork().catch(() => true);
3893
- this.heartbeat.settle(outcome, pending);
3894
- });
4041
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
4042
+ }
4043
+ async convergeBody() {
4044
+ const outcome = await this.converger.convergeOnce();
4045
+ const pending = await this.converger.pendingWork().catch(() => true);
4046
+ this.heartbeat.settle(outcome, pending);
3895
4047
  }
3896
4048
  nowMs() {
3897
4049
  return (this.deps.now ?? Date.now)();
3898
4050
  }
3899
4051
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
3900
- async sendWithPolicy(request) {
3901
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
4052
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
4053
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
4054
+ async sendOutcome(request) {
3902
4055
  const run = {
3903
4056
  amount: request.amount,
3904
4057
  delivered: [],
@@ -3906,12 +4059,36 @@ var PaymentsFacade = class {
3906
4059
  tokenTransfers: [],
3907
4060
  settledShortfalls: [],
3908
4061
  firstPartialId: void 0,
3909
- pendingAny: false
4062
+ pendingAny: false,
4063
+ lastTransferId: ""
4064
+ };
4065
+ try {
4066
+ return await this.sendWithPolicy(request, run);
4067
+ } catch (err) {
4068
+ this.emitCleanFailure(err, run.lastTransferId);
4069
+ throw err;
4070
+ }
4071
+ }
4072
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
4073
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
4074
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
4075
+ emitCleanFailure(err, transferId) {
4076
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
4077
+ const failed = {
4078
+ id: transferId,
4079
+ status: "failed",
4080
+ tokens: [],
4081
+ tokenTransfers: [],
4082
+ error: messageOf(err)
3910
4083
  };
4084
+ this.deps.emit("transfer:updated", failed);
4085
+ }
4086
+ async sendWithPolicy(request, run) {
4087
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
3911
4088
  for (let attempt = 0; ; attempt++) {
3912
4089
  let ctx;
3913
4090
  try {
3914
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
4091
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
3915
4092
  } catch (err) {
3916
4093
  throw this.partialize(err, run);
3917
4094
  }
@@ -3956,11 +4133,8 @@ var PaymentsFacade = class {
3956
4133
  this.activeMoneyOps.delete(ctx.transferId);
3957
4134
  }
3958
4135
  }
3959
- /**
3960
- * Partial outcome (#677/#690): the shortfall is already durable (written by
3961
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
3962
- * the remainder under a NEW transferId — never the full amount.
3963
- */
4136
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
4137
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
3964
4138
  async consumePartial(ctx, disposition, run, attempt) {
3965
4139
  if (disposition.deliveryPending) {
3966
4140
  run.pendingAny = true;
@@ -3986,11 +4160,8 @@ var PaymentsFacade = class {
3986
4160
  run.amount = partial.remainingAmount;
3987
4161
  return null;
3988
4162
  }
3989
- /**
3990
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
3991
- * backstop still 'open' (committed>0) → converge via the same machine's
3992
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
3993
- */
4163
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
4164
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
3994
4165
  async disposeFailedAttempt(ctx, err) {
3995
4166
  if (isPossiblyCommittedSendOutcome(err)) {
3996
4167
  this.settleKeepOpen(ctx);
@@ -4039,8 +4210,9 @@ var PaymentsFacade = class {
4039
4210
  }
4040
4211
  return { kind: "rethrow", error: err };
4041
4212
  }
4042
- async planAndMaterialize(recipientPubkey, request) {
4213
+ async planAndMaterialize(recipientPubkey, request, run) {
4043
4214
  const transferId = this.newId();
4215
+ run.lastTransferId = transferId;
4044
4216
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
4045
4217
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
4046
4218
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -4172,11 +4344,7 @@ var PaymentsFacade = class {
4172
4344
  this.deps.emit("transfer:updated", result);
4173
4345
  return result;
4174
4346
  }
4175
- /**
4176
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
4177
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
4178
- * over the accumulated settled set — never bare, never a full-amount retry.
4179
- */
4347
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
4180
4348
  partialize(err, run) {
4181
4349
  if (run.delivered.length === 0) return err;
4182
4350
  return new PartialSendConflictError(
@@ -4328,22 +4496,14 @@ var PaymentsFacade = class {
4328
4496
  }
4329
4497
  }
4330
4498
  toUiToken(tokenId, coinId, amount) {
4331
- const registry = this.deps.registry;
4332
- const iconUrl = registry.getIconUrl(coinId);
4333
4499
  const now = this.nowMs();
4334
- return {
4335
- id: tokenId,
4336
- coinId,
4337
- symbol: registry.getSymbol(coinId),
4338
- name: registry.getName(coinId),
4339
- decimals: registry.getDecimals(coinId),
4340
- ...iconUrl !== null ? { iconUrl } : {},
4341
- amount: amount.toString(),
4342
- status: "transferring",
4343
- createdAt: now,
4344
- updatedAt: now,
4345
- lazy: true
4346
- };
4500
+ return toToken(
4501
+ tokenId,
4502
+ { assets: [], createdAt: now, updatedAt: now },
4503
+ { coinId, amount: amount.toString() },
4504
+ this.deps.registry,
4505
+ { transferring: true, suspectedSpent: false }
4506
+ );
4347
4507
  }
4348
4508
  track(op) {
4349
4509
  const settled = op.then(
@@ -4358,29 +4518,9 @@ var PaymentsFacade = class {
4358
4518
  this.track(op).catch(() => void 0);
4359
4519
  }
4360
4520
  };
4361
- function buildPayload(recipientPubkey, request, spend, spentStates) {
4362
- return {
4363
- v: 2,
4364
- recipient: recipientPubkey,
4365
- coinId: request.coinId,
4366
- amount: request.amount,
4367
- ...request.memo !== void 0 ? { memo: request.memo } : {},
4368
- direct: [...spend.plan.direct],
4369
- ...spend.plan.split !== void 0 ? {
4370
- split: {
4371
- tokenId: spend.plan.split.tokenId,
4372
- splitAmount: spend.plan.split.splitAmount.toString(),
4373
- remainderAmount: spend.plan.split.remainderAmount.toString()
4374
- }
4375
- } : {},
4376
- spentStates
4377
- };
4378
- }
4379
- function messageOf(err) {
4380
- return err instanceof Error ? err.message : String(err);
4381
- }
4382
4521
  export {
4383
4522
  ATTENTION_MINT_UNRESOLVED,
4523
+ ATTENTION_RESEED_REJECTED,
4384
4524
  HEARTBEAT_CAP_MS,
4385
4525
  HEARTBEAT_SEED_MS,
4386
4526
  MAX_RESELECT,