@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
@@ -11084,7 +11084,10 @@ var STORE_KEYS = {
11084
11084
  shortfalls: "shortfalls",
11085
11085
  settlingLinks: "settling",
11086
11086
  streamCursor: (s) => `cursor:${s}`,
11087
- epochLatch: "epoch-latch"
11087
+ epochLatch: "epoch-latch",
11088
+ // §5.2 InventoryView durable overlays (#625/#679).
11089
+ suspectedSpent: "suspected-spent",
11090
+ knownSpends: "known-spends"
11088
11091
  };
11089
11092
 
11090
11093
  // modules/payments-v2/machine/journal.ts
@@ -11334,7 +11337,7 @@ var TransferMachine = class {
11334
11337
  }
11335
11338
  if (cls === null) {
11336
11339
  await this.applyCommitted(engine, plan.transferId, committed);
11337
- this.closeTail(plan.transferId, plan.payload);
11340
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
11338
11341
  return { committed, deliveryPending };
11339
11342
  }
11340
11343
  if (cls === "conflict" && committed.length > 0) {
@@ -11360,10 +11363,10 @@ var TransferMachine = class {
11360
11363
  }
11361
11364
  await this.applyCommitted(engine, r.transferId, committed);
11362
11365
  if (conflicts.length > 0) {
11363
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
11366
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
11364
11367
  }
11365
11368
  await this.completeIntent(r.transferId, r.payload);
11366
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11369
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11367
11370
  return { deliveryPending, partial: null };
11368
11371
  }
11369
11372
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -11545,7 +11548,7 @@ var TransferMachine = class {
11545
11548
  }
11546
11549
  return added;
11547
11550
  }
11548
- async settlePartial(r, committed, conflicts) {
11551
+ async settlePartial(engine, r, committed, conflicts) {
11549
11552
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
11550
11553
  const entry = {
11551
11554
  transferId: r.transferId,
@@ -11557,26 +11560,27 @@ var TransferMachine = class {
11557
11560
  };
11558
11561
  await this.stores.shortfalls.upsert(entry);
11559
11562
  await this.completeIntent(r.transferId, r.payload);
11560
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11563
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11561
11564
  return entry;
11562
11565
  }
11563
- closeTail(transferId, payload) {
11566
+ closeTail(engine, transferId, payload, committed) {
11564
11567
  void (async () => {
11565
11568
  try {
11566
11569
  await this.completeIntent(transferId, payload);
11567
11570
  } catch {
11568
11571
  return;
11569
11572
  }
11570
- await this.finishBookkeeping(transferId, payload, "sent");
11573
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
11571
11574
  })();
11572
11575
  }
11573
11576
  async completeIntent(transferId, payload) {
11574
11577
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
11575
11578
  await this.deps.intents.complete(transferId, signature);
11576
11579
  }
11577
- async finishBookkeeping(transferId, payload, phase) {
11580
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
11578
11581
  try {
11579
- await this.deps.recordHistory?.({ transferId, payload, phase });
11582
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
11583
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
11580
11584
  } catch {
11581
11585
  }
11582
11586
  try {
@@ -11584,6 +11588,26 @@ var TransferMachine = class {
11584
11588
  } catch {
11585
11589
  }
11586
11590
  }
11591
+ /**
11592
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
11593
+ * certified recipient blobs (summarize()'s committed set is the only source;
11594
+ * a journal-rehydrated resume leg has no source token, but every committed
11595
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
11596
+ */
11597
+ async settledAmount(engine, coinId, committed) {
11598
+ let total = 0n;
11599
+ for (const outcome of committed) {
11600
+ if (outcome.recipientBlob === void 0) continue;
11601
+ const token = await engine.decodeToken({
11602
+ v: TOKEN_BLOB_VERSION,
11603
+ network: 0,
11604
+ tokenId: "",
11605
+ token: outcome.recipientBlob
11606
+ });
11607
+ total += engine.balanceOf(token, coinId);
11608
+ }
11609
+ return total.toString();
11610
+ }
11587
11611
  async disposeOnFailure(transferId, committed, cls) {
11588
11612
  if (committed.length > 0 || cls === "keep-open") return;
11589
11613
  try {
@@ -11608,7 +11632,7 @@ function conflictAmount(engine, payload, op, source) {
11608
11632
  }
11609
11633
 
11610
11634
  // modules/payments-v2/machine/resume.ts
11611
- async function resumeAll(deps) {
11635
+ async function resumeAll(deps, options = {}) {
11612
11636
  const report = { resumed: [], conflicted: [], failed: [] };
11613
11637
  const stores = createMachineStores(deps.kv);
11614
11638
  const open = await deps.intents.listOpen();
@@ -11618,7 +11642,12 @@ async function resumeAll(deps) {
11618
11642
  } catch {
11619
11643
  return report;
11620
11644
  }
11621
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
11645
+ await dropStaleBackstops(
11646
+ stores,
11647
+ locals,
11648
+ new Set(open.map((i) => i.transferId)),
11649
+ options.isLocallyActive ?? (() => false)
11650
+ );
11622
11651
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
11623
11652
  const ctx = {
11624
11653
  deps,
@@ -11631,11 +11660,16 @@ async function resumeAll(deps) {
11631
11660
  }
11632
11661
  return report;
11633
11662
  }
11634
- async function dropStaleLocalAborts(stores, locals, openIds) {
11663
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
11635
11664
  for (const entry of locals.values()) {
11636
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
11665
+ if (openIds.has(entry.transferId)) continue;
11666
+ if (entry.disposition !== "open") {
11637
11667
  await stores.backstop.removeByKey(entry.transferId);
11668
+ continue;
11638
11669
  }
11670
+ if (isLocallyActive(entry.transferId)) continue;
11671
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
11672
+ await stores.backstop.removeByKey(entry.transferId);
11639
11673
  }
11640
11674
  }
11641
11675
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -11843,7 +11877,9 @@ var Converger = class {
11843
11877
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
11844
11878
  }
11845
11879
  async resumePass() {
11846
- const report = await resumeAll(this.filteredMachineDeps());
11880
+ const report = await resumeAll(this.filteredMachineDeps(), {
11881
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
11882
+ });
11847
11883
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
11848
11884
  await this.deps.reconcile({
11849
11885
  resumed: report.resumed,
@@ -11959,6 +11995,136 @@ function distinctOpCount(entries) {
11959
11995
  return new Set(entries.map((entry) => entry.opIndex)).size;
11960
11996
  }
11961
11997
 
11998
+ // modules/payments-v2/restore.ts
11999
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
12000
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
12001
+ async function reseedAndReset(deps) {
12002
+ for (const entry of await deps.stores.backstop.list()) {
12003
+ if (entry.disposition !== "open") continue;
12004
+ try {
12005
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
12006
+ if (entry.requiresSeedClose) {
12007
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
12008
+ }
12009
+ } catch (err) {
12010
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
12011
+ }
12012
+ }
12013
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
12014
+ await deps.fullRePull();
12015
+ }
12016
+ async function splitOpIndex(deps, envelope) {
12017
+ const payload = await deps.decryptPayload(envelope);
12018
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
12019
+ }
12020
+
12021
+ // modules/payments-v2/inventory/presentation.ts
12022
+ function toToken(tokenId, snapshot, asset, registry, flags) {
12023
+ const iconUrl = registry.getIconUrl(asset.coinId);
12024
+ return {
12025
+ id: tokenId,
12026
+ coinId: asset.coinId,
12027
+ symbol: registry.getSymbol(asset.coinId),
12028
+ name: registry.getName(asset.coinId),
12029
+ decimals: registry.getDecimals(asset.coinId),
12030
+ ...iconUrl !== null ? { iconUrl } : {},
12031
+ amount: asset.amount,
12032
+ status: flags.transferring ? "transferring" : "confirmed",
12033
+ createdAt: snapshot.createdAt,
12034
+ updatedAt: snapshot.updatedAt,
12035
+ lazy: true,
12036
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12037
+ };
12038
+ }
12039
+ function aggregateAssets(entries, isTransferring, registry) {
12040
+ const groups = /* @__PURE__ */ new Map();
12041
+ for (const [tokenId, entry] of entries) {
12042
+ const moving = isTransferring(tokenId);
12043
+ for (const asset of entry.assets) {
12044
+ let group = groups.get(asset.coinId);
12045
+ if (!group) {
12046
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12047
+ groups.set(asset.coinId, group);
12048
+ }
12049
+ if (moving) {
12050
+ group.transferring += BigInt(asset.amount);
12051
+ group.transferringCount += 1;
12052
+ } else {
12053
+ group.total += BigInt(asset.amount);
12054
+ group.count += 1;
12055
+ }
12056
+ }
12057
+ }
12058
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12059
+ }
12060
+ function toAsset(coinId, totals, registry) {
12061
+ const iconUrl = registry.getIconUrl(coinId);
12062
+ return {
12063
+ coinId,
12064
+ symbol: registry.getSymbol(coinId),
12065
+ name: registry.getName(coinId),
12066
+ decimals: registry.getDecimals(coinId),
12067
+ ...iconUrl !== null ? { iconUrl } : {},
12068
+ totalAmount: totals.total.toString(),
12069
+ tokenCount: totals.count,
12070
+ confirmedAmount: totals.total.toString(),
12071
+ unconfirmedAmount: "0",
12072
+ confirmedTokenCount: totals.count,
12073
+ unconfirmedTokenCount: 0,
12074
+ transferringTokenCount: totals.transferringCount,
12075
+ transferringAmount: totals.transferring.toString(),
12076
+ priceUsd: null,
12077
+ priceEur: null,
12078
+ change24h: null,
12079
+ fiatValueUsd: null,
12080
+ fiatValueEur: null
12081
+ };
12082
+ }
12083
+ async function withPrices(raw, registry, price) {
12084
+ try {
12085
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12086
+ const quotes = await price.getPrices(names);
12087
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12088
+ } catch {
12089
+ return raw;
12090
+ }
12091
+ }
12092
+ function priceOne(asset, quote) {
12093
+ if (!quote) return asset;
12094
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12095
+ return {
12096
+ ...asset,
12097
+ priceUsd: quote.priceUsd,
12098
+ priceEur: quote.priceEur ?? null,
12099
+ change24h: quote.change24h ?? null,
12100
+ fiatValueUsd: human * quote.priceUsd,
12101
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12102
+ };
12103
+ }
12104
+
12105
+ // modules/payments-v2/machine/payload.ts
12106
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12107
+ return {
12108
+ v: 2,
12109
+ recipient: recipientPubkey,
12110
+ coinId: request.coinId,
12111
+ amount: request.amount,
12112
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12113
+ direct: [...spend.plan.direct],
12114
+ ...spend.plan.split !== void 0 ? {
12115
+ split: {
12116
+ tokenId: spend.plan.split.tokenId,
12117
+ splitAmount: spend.plan.split.splitAmount.toString(),
12118
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12119
+ }
12120
+ } : {},
12121
+ spentStates
12122
+ };
12123
+ }
12124
+ function messageOf(err) {
12125
+ return err instanceof Error ? err.message : String(err);
12126
+ }
12127
+
11962
12128
  // modules/payments-v2/history/History.ts
11963
12129
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
11964
12130
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -12017,13 +12183,14 @@ var History = class {
12017
12183
  }
12018
12184
  // ── internals ────────────────────────────────────────────────────────────────
12019
12185
  async post(build) {
12186
+ const record = build();
12020
12187
  try {
12021
- await this.deps.client.postHistory([build()]);
12188
+ await this.deps.client.postHistory([record]);
12022
12189
  } catch (err) {
12023
12190
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
12024
12191
  return;
12025
12192
  }
12026
- this.deps.emit("history:updated");
12193
+ this.deps.emit("history:updated", this.toEntry(record));
12027
12194
  }
12028
12195
  ts(timestamp) {
12029
12196
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -12080,93 +12247,9 @@ var History = class {
12080
12247
  }
12081
12248
  };
12082
12249
 
12083
- // modules/payments-v2/inventory/presentation.ts
12084
- function toToken(tokenId, snapshot, asset, registry, flags) {
12085
- const iconUrl = registry.getIconUrl(asset.coinId);
12086
- return {
12087
- id: tokenId,
12088
- coinId: asset.coinId,
12089
- symbol: registry.getSymbol(asset.coinId),
12090
- name: registry.getName(asset.coinId),
12091
- decimals: registry.getDecimals(asset.coinId),
12092
- ...iconUrl !== null ? { iconUrl } : {},
12093
- amount: asset.amount,
12094
- status: flags.transferring ? "transferring" : "confirmed",
12095
- createdAt: snapshot.createdAt,
12096
- updatedAt: snapshot.updatedAt,
12097
- lazy: true,
12098
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12099
- };
12100
- }
12101
- function aggregateAssets(entries, isTransferring, registry) {
12102
- const groups = /* @__PURE__ */ new Map();
12103
- for (const [tokenId, entry] of entries) {
12104
- const moving = isTransferring(tokenId);
12105
- for (const asset of entry.assets) {
12106
- let group = groups.get(asset.coinId);
12107
- if (!group) {
12108
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12109
- groups.set(asset.coinId, group);
12110
- }
12111
- if (moving) {
12112
- group.transferring += BigInt(asset.amount);
12113
- group.transferringCount += 1;
12114
- } else {
12115
- group.total += BigInt(asset.amount);
12116
- group.count += 1;
12117
- }
12118
- }
12119
- }
12120
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12121
- }
12122
- function toAsset(coinId, totals, registry) {
12123
- const iconUrl = registry.getIconUrl(coinId);
12124
- return {
12125
- coinId,
12126
- symbol: registry.getSymbol(coinId),
12127
- name: registry.getName(coinId),
12128
- decimals: registry.getDecimals(coinId),
12129
- ...iconUrl !== null ? { iconUrl } : {},
12130
- totalAmount: totals.total.toString(),
12131
- tokenCount: totals.count,
12132
- confirmedAmount: totals.total.toString(),
12133
- unconfirmedAmount: "0",
12134
- confirmedTokenCount: totals.count,
12135
- unconfirmedTokenCount: 0,
12136
- transferringTokenCount: totals.transferringCount,
12137
- transferringAmount: totals.transferring.toString(),
12138
- priceUsd: null,
12139
- priceEur: null,
12140
- change24h: null,
12141
- fiatValueUsd: null,
12142
- fiatValueEur: null
12143
- };
12144
- }
12145
- async function withPrices(raw, registry, price) {
12146
- try {
12147
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12148
- const quotes = await price.getPrices(names);
12149
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12150
- } catch {
12151
- return raw;
12152
- }
12153
- }
12154
- function priceOne(asset, quote) {
12155
- if (!quote) return asset;
12156
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12157
- return {
12158
- ...asset,
12159
- priceUsd: quote.priceUsd,
12160
- priceEur: quote.priceEur ?? null,
12161
- change24h: quote.change24h ?? null,
12162
- fiatValueUsd: human * quote.priceUsd,
12163
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12164
- };
12165
- }
12166
-
12167
12250
  // modules/payments-v2/inventory/InventoryView.ts
12168
- var SUSPECTED_KEY = "suspected-spent";
12169
- var KNOWN_SPENDS_KEY = "known-spends";
12251
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
12252
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
12170
12253
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12171
12254
  function stateKey(tokenId, stateHash) {
12172
12255
  return `${tokenId}:${stateHash}`;
@@ -12470,19 +12553,24 @@ var Receive = class {
12470
12553
  const engine = deps.engine();
12471
12554
  const stored = [];
12472
12555
  const pending = [];
12473
- let epoch = "";
12556
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
12474
12557
  try {
12475
- epoch = deps.syncEpoch();
12476
12558
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
12477
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
12478
- for await (const entry of deps.delivery.incoming(since)) {
12479
- await this.processEntry(deps, engine, entry, pending, stored);
12480
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
12559
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
12560
+ for (let pass = 0; pass < 2; pass++) {
12561
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
12562
+ await this.processEntry(deps, engine, entry, pending, stored);
12563
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
12564
+ }
12565
+ await flushAcks(deps, pending, pageEpoch);
12566
+ const served = deps.delivery.incomingEpoch();
12567
+ if (basis === null || served === null || served === basis.syncEpoch) break;
12568
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
12569
+ basis = null;
12481
12570
  }
12482
- await flushAcks(deps, pending, epoch);
12483
12571
  } catch (err) {
12484
12572
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
12485
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
12573
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12486
12574
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12487
12575
  });
12488
12576
  }
@@ -12555,7 +12643,7 @@ async function announce(deps, entry, record) {
12555
12643
  receivedAt
12556
12644
  };
12557
12645
  }
12558
- async function flushAcks(deps, pending, epoch) {
12646
+ async function flushAcks(deps, pending, epochOf) {
12559
12647
  let lastAcked = null;
12560
12648
  try {
12561
12649
  while (pending.length > 0) {
@@ -12566,7 +12654,7 @@ async function flushAcks(deps, pending, epoch) {
12566
12654
  }
12567
12655
  } finally {
12568
12656
  if (lastAcked !== null) {
12569
- const record = { cursor: lastAcked, syncEpoch: epoch };
12657
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
12570
12658
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
12571
12659
  }
12572
12660
  }
@@ -12667,9 +12755,19 @@ var Requests = class {
12667
12755
  }
12668
12756
  });
12669
12757
  }
12758
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
12670
12759
  writeLink(requestId, transferId, committed) {
12671
12760
  return this.mutateJournal((m) => {
12672
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
12761
+ const prev = m.get(requestId);
12762
+ const same = prev !== void 0 && prev.transferId === transferId;
12763
+ const next = {
12764
+ requestId,
12765
+ transferId,
12766
+ committed: committed || same && prev.committed,
12767
+ createdAt: same ? prev.createdAt : this.now()
12768
+ };
12769
+ if (same && prev.committed === next.committed) return false;
12770
+ m.set(requestId, next);
12673
12771
  return true;
12674
12772
  });
12675
12773
  }
@@ -12822,23 +12920,46 @@ var Requests = class {
12822
12920
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
12823
12921
  if (transferId !== void 0) {
12824
12922
  const committed = error instanceof PartialSendConflictError;
12825
- await this.writeLink(id, transferId, committed);
12826
- this.setStatus(id, "settling");
12923
+ await this.settle(id, transferId, { committed, respond: false });
12827
12924
  } else {
12828
12925
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
12829
12926
  this.setStatus(id, "paid");
12830
12927
  }
12831
12928
  } else {
12832
- await this.clearLink(id);
12833
- this.setStatus(id, "pending");
12929
+ await this.revertPayable(id);
12834
12930
  }
12835
12931
  throw error;
12836
12932
  }
12837
- this.setStatus(id, "paid");
12838
- await this.respondPaid(id, result.id);
12839
- await this.clearLink(id);
12933
+ await this.settle(id, result.id, { committed: true, respond: true });
12840
12934
  return result;
12841
12935
  }
12936
+ /**
12937
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
12938
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
12939
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
12940
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
12941
+ * not a reload. Every path that binds a request to a transfer outcome funnels
12942
+ * through here — pay()'s clean success (respond now), pay()'s
12943
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
12944
+ * — so a failed respond always leaves the link + 'settling' and the next
12945
+ * reconcile pass (the committed-link override) retries the respond.
12946
+ */
12947
+ async settle(requestId, transferId, opts) {
12948
+ let journalDown = false;
12949
+ try {
12950
+ await this.writeLink(requestId, transferId, opts.committed);
12951
+ } catch (err) {
12952
+ if (!opts.respond) throw err;
12953
+ journalDown = true;
12954
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
12955
+ }
12956
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
12957
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
12958
+ return;
12959
+ }
12960
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
12961
+ this.setStatus(requestId, "paid");
12962
+ }
12842
12963
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
12843
12964
  async respondPaid(id, transferId) {
12844
12965
  try {
@@ -12874,32 +12995,32 @@ var Requests = class {
12874
12995
  let aborted = null;
12875
12996
  for (const [requestId, link] of [...this.journal]) {
12876
12997
  if (link.committed || resumed.has(link.transferId)) {
12877
- await this.resolvePaid(requestId, link.transferId);
12998
+ await this.resolvePaid(requestId, link);
12878
12999
  } else if (conflicted.has(link.transferId)) {
12879
13000
  await this.revertPayable(requestId);
12880
13001
  } else if (open.has(link.transferId)) {
12881
13002
  } else {
12882
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
13003
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
12883
13004
  }
12884
13005
  }
12885
13006
  }
12886
13007
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
12887
13008
  // a probe failure defers the link to the next session.
12888
- async reconcileUnaccounted(requestId, transferId, aborted) {
13009
+ async reconcileUnaccounted(requestId, link, aborted) {
12889
13010
  try {
12890
13011
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
12891
- if (aborted.has(transferId)) await this.revertPayable(requestId);
12892
- else await this.resolvePaid(requestId, transferId);
13012
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
13013
+ else await this.resolvePaid(requestId, link);
12893
13014
  } catch (err) {
12894
13015
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
12895
13016
  }
12896
13017
  return aborted;
12897
13018
  }
12898
- async resolvePaid(requestId, transferId) {
12899
- if (!await this.respondPaid(requestId, transferId)) return;
12900
- await this.clearLink(requestId);
12901
- this.setStatus(requestId, "paid");
13019
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
13020
+ resolvePaid(requestId, link) {
13021
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
12902
13022
  }
13023
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
12903
13024
  async revertPayable(requestId) {
12904
13025
  await this.clearLink(requestId);
12905
13026
  this.setStatus(requestId, "pending");
@@ -13223,11 +13344,12 @@ function composeFacadeParts(deps, hooks) {
13223
13344
  client: deps.client,
13224
13345
  fieldKey: deps.fieldKey,
13225
13346
  registry: deps.registry,
13226
- emit: (event) => deps.emit(event, {}),
13347
+ emit: (event, entry) => deps.emit(event, entry),
13227
13348
  ...deps.now !== void 0 ? { now: deps.now } : {},
13228
13349
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
13229
13350
  });
13230
13351
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13352
+ const machineStores = createMachineStores(deps.kv);
13231
13353
  const heldStates = /* @__PURE__ */ new Map();
13232
13354
  return {
13233
13355
  ownPubkeyBytes,
@@ -13235,12 +13357,29 @@ function composeFacadeParts(deps, hooks) {
13235
13357
  ledger,
13236
13358
  queue,
13237
13359
  historyStore,
13238
- machineStores: createMachineStores(deps.kv),
13360
+ machineStores,
13239
13361
  machineDeps,
13240
13362
  machine: new TransferMachine(machineDeps),
13241
13363
  heldStates,
13242
13364
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
13243
- requests: buildRequests(deps, hooks)
13365
+ requests: buildRequests(deps, hooks),
13366
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13367
+ };
13368
+ }
13369
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13370
+ return {
13371
+ stores: machineStores,
13372
+ kv: deps.kv,
13373
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
13374
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
13375
+ decryptPayload: machineDeps.decryptPayload,
13376
+ fullRePull: () => view.onEpochReset(),
13377
+ attention: (transferId, code, detail) => {
13378
+ deps.emit(
13379
+ "transfer:attention",
13380
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
13381
+ );
13382
+ }
13244
13383
  };
13245
13384
  }
13246
13385
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -13262,11 +13401,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
13262
13401
  ownPubkey: hexToBytes4(deps.ownPubkey),
13263
13402
  emit: deps.emit,
13264
13403
  now: deps.now ?? Date.now,
13265
- recordHistory: async ({ transferId, payload }) => {
13404
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
13266
13405
  await historyStore.recordSent({
13267
13406
  transferId,
13268
13407
  coinId: payload.coinId,
13269
- amount: payload.amount,
13408
+ // §5.9: the SETTLED amount (machine-computed from the certified
13409
+ // recipient blobs), never payload.amount — the plan.
13410
+ amount: committedAmount,
13270
13411
  recipientPubkey: payload.recipient,
13271
13412
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
13272
13413
  });
@@ -13293,7 +13434,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
13293
13434
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
13294
13435
  );
13295
13436
  },
13296
- syncEpoch: deps.syncEpoch ?? (() => ""),
13437
+ syncEpoch: deps.syncEpoch,
13297
13438
  ...deps.now !== void 0 ? { now: deps.now } : {}
13298
13439
  });
13299
13440
  }
@@ -13346,6 +13487,7 @@ var PaymentsFacade = class {
13346
13487
  this.receiveLoop = parts.receiveLoop;
13347
13488
  this.heldStates = parts.heldStates;
13348
13489
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13490
+ this.restoreDeps = parts.restoreDeps;
13349
13491
  this.requests = parts.requests;
13350
13492
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13351
13493
  this.heartbeat = new ConvergenceHeartbeat({
@@ -13374,6 +13516,7 @@ var PaymentsFacade = class {
13374
13516
  receiveLoop;
13375
13517
  heldStates;
13376
13518
  ownPubkeyBytes;
13519
+ restoreDeps;
13377
13520
  requests;
13378
13521
  currentEngine = null;
13379
13522
  pendingOps = /* @__PURE__ */ new Set();
@@ -13384,6 +13527,8 @@ var PaymentsFacade = class {
13384
13527
  converger;
13385
13528
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
13386
13529
  resumeFlight = new SingleFlight();
13530
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
13531
+ passChain = new SerialChain();
13387
13532
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13388
13533
  activeMoneyOps = /* @__PURE__ */ new Set();
13389
13534
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -13391,8 +13536,8 @@ var PaymentsFacade = class {
13391
13536
  if (this.started) return;
13392
13537
  this.started = true;
13393
13538
  this.heartbeat.start();
13394
- await this.deps.session.start();
13395
13539
  this.unsubscribers.push(
13540
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
13396
13541
  this.deps.session.subscribeStream("mailbox", () => {
13397
13542
  this.trackTail(this.receiveLoop.drainOnce());
13398
13543
  }),
@@ -13407,6 +13552,7 @@ var PaymentsFacade = class {
13407
13552
  if (status === "connected") this.heartbeat.recovered();
13408
13553
  });
13409
13554
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13555
+ await this.deps.session.start();
13410
13556
  this.trackTail(this.runConvergencePass());
13411
13557
  this.trackTail(this.seedHeldStates());
13412
13558
  await this.view.fullPull().catch(() => void 0);
@@ -13447,7 +13593,7 @@ var PaymentsFacade = class {
13447
13593
  }
13448
13594
  // ── money movement ─────────────────────────────────────────────────────────
13449
13595
  send(request) {
13450
- return this.track(this.sendWithPolicy(request));
13596
+ return this.track(this.sendOutcome(request));
13451
13597
  }
13452
13598
  async receive() {
13453
13599
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -13462,20 +13608,27 @@ var PaymentsFacade = class {
13462
13608
  if (!this.started) return;
13463
13609
  await this.track(this.runConvergencePass());
13464
13610
  }
13611
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
13612
+ async handleEpochChange(_newEpoch) {
13613
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
13614
+ await this.passChain.enqueue(() => this.convergeBody());
13615
+ }
13465
13616
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
13466
13617
  runConvergencePass() {
13467
- return this.resumeFlight.run(async () => {
13468
- const outcome = await this.converger.convergeOnce();
13469
- const pending = await this.converger.pendingWork().catch(() => true);
13470
- this.heartbeat.settle(outcome, pending);
13471
- });
13618
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
13619
+ }
13620
+ async convergeBody() {
13621
+ const outcome = await this.converger.convergeOnce();
13622
+ const pending = await this.converger.pendingWork().catch(() => true);
13623
+ this.heartbeat.settle(outcome, pending);
13472
13624
  }
13473
13625
  nowMs() {
13474
13626
  return (this.deps.now ?? Date.now)();
13475
13627
  }
13476
13628
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
13477
- async sendWithPolicy(request) {
13478
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
13629
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
13630
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
13631
+ async sendOutcome(request) {
13479
13632
  const run = {
13480
13633
  amount: request.amount,
13481
13634
  delivered: [],
@@ -13483,12 +13636,36 @@ var PaymentsFacade = class {
13483
13636
  tokenTransfers: [],
13484
13637
  settledShortfalls: [],
13485
13638
  firstPartialId: void 0,
13486
- pendingAny: false
13639
+ pendingAny: false,
13640
+ lastTransferId: ""
13487
13641
  };
13642
+ try {
13643
+ return await this.sendWithPolicy(request, run);
13644
+ } catch (err) {
13645
+ this.emitCleanFailure(err, run.lastTransferId);
13646
+ throw err;
13647
+ }
13648
+ }
13649
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
13650
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
13651
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
13652
+ emitCleanFailure(err, transferId) {
13653
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
13654
+ const failed = {
13655
+ id: transferId,
13656
+ status: "failed",
13657
+ tokens: [],
13658
+ tokenTransfers: [],
13659
+ error: messageOf(err)
13660
+ };
13661
+ this.deps.emit("transfer:updated", failed);
13662
+ }
13663
+ async sendWithPolicy(request, run) {
13664
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
13488
13665
  for (let attempt = 0; ; attempt++) {
13489
13666
  let ctx;
13490
13667
  try {
13491
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
13668
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
13492
13669
  } catch (err) {
13493
13670
  throw this.partialize(err, run);
13494
13671
  }
@@ -13533,11 +13710,8 @@ var PaymentsFacade = class {
13533
13710
  this.activeMoneyOps.delete(ctx.transferId);
13534
13711
  }
13535
13712
  }
13536
- /**
13537
- * Partial outcome (#677/#690): the shortfall is already durable (written by
13538
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
13539
- * the remainder under a NEW transferId — never the full amount.
13540
- */
13713
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
13714
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
13541
13715
  async consumePartial(ctx, disposition, run, attempt) {
13542
13716
  if (disposition.deliveryPending) {
13543
13717
  run.pendingAny = true;
@@ -13563,11 +13737,8 @@ var PaymentsFacade = class {
13563
13737
  run.amount = partial.remainingAmount;
13564
13738
  return null;
13565
13739
  }
13566
- /**
13567
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
13568
- * backstop still 'open' (committed>0) → converge via the same machine's
13569
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
13570
- */
13740
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
13741
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
13571
13742
  async disposeFailedAttempt(ctx, err) {
13572
13743
  if (isPossiblyCommittedSendOutcome(err)) {
13573
13744
  this.settleKeepOpen(ctx);
@@ -13616,8 +13787,9 @@ var PaymentsFacade = class {
13616
13787
  }
13617
13788
  return { kind: "rethrow", error: err };
13618
13789
  }
13619
- async planAndMaterialize(recipientPubkey, request) {
13790
+ async planAndMaterialize(recipientPubkey, request, run) {
13620
13791
  const transferId = this.newId();
13792
+ run.lastTransferId = transferId;
13621
13793
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
13622
13794
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
13623
13795
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -13749,11 +13921,7 @@ var PaymentsFacade = class {
13749
13921
  this.deps.emit("transfer:updated", result);
13750
13922
  return result;
13751
13923
  }
13752
- /**
13753
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
13754
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
13755
- * over the accumulated settled set — never bare, never a full-amount retry.
13756
- */
13924
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
13757
13925
  partialize(err, run) {
13758
13926
  if (run.delivered.length === 0) return err;
13759
13927
  return new PartialSendConflictError(
@@ -13905,22 +14073,14 @@ var PaymentsFacade = class {
13905
14073
  }
13906
14074
  }
13907
14075
  toUiToken(tokenId, coinId, amount) {
13908
- const registry = this.deps.registry;
13909
- const iconUrl = registry.getIconUrl(coinId);
13910
14076
  const now = this.nowMs();
13911
- return {
13912
- id: tokenId,
13913
- coinId,
13914
- symbol: registry.getSymbol(coinId),
13915
- name: registry.getName(coinId),
13916
- decimals: registry.getDecimals(coinId),
13917
- ...iconUrl !== null ? { iconUrl } : {},
13918
- amount: amount.toString(),
13919
- status: "transferring",
13920
- createdAt: now,
13921
- updatedAt: now,
13922
- lazy: true
13923
- };
14077
+ return toToken(
14078
+ tokenId,
14079
+ { assets: [], createdAt: now, updatedAt: now },
14080
+ { coinId, amount: amount.toString() },
14081
+ this.deps.registry,
14082
+ { transferring: true, suspectedSpent: false }
14083
+ );
13924
14084
  }
13925
14085
  track(op) {
13926
14086
  const settled = op.then(
@@ -13935,27 +14095,6 @@ var PaymentsFacade = class {
13935
14095
  this.track(op).catch(() => void 0);
13936
14096
  }
13937
14097
  };
13938
- function buildPayload(recipientPubkey, request, spend, spentStates) {
13939
- return {
13940
- v: 2,
13941
- recipient: recipientPubkey,
13942
- coinId: request.coinId,
13943
- amount: request.amount,
13944
- ...request.memo !== void 0 ? { memo: request.memo } : {},
13945
- direct: [...spend.plan.direct],
13946
- ...spend.plan.split !== void 0 ? {
13947
- split: {
13948
- tokenId: spend.plan.split.tokenId,
13949
- splitAmount: spend.plan.split.splitAmount.toString(),
13950
- remainderAmount: spend.plan.split.remainderAmount.toString()
13951
- }
13952
- } : {},
13953
- spentStates
13954
- };
13955
- }
13956
- function messageOf(err) {
13957
- return err instanceof Error ? err.message : String(err);
13958
- }
13959
14098
 
13960
14099
  // impl/wallet-api-v2/http.ts
13961
14100
  var WalletApiHttpError = class extends Error {
@@ -14301,6 +14440,7 @@ var WalletApiSession = class {
14301
14440
  epochLatch = null;
14302
14441
  reseedChain = Promise.resolve();
14303
14442
  reseedsPending = 0;
14443
+ epochHandlers = /* @__PURE__ */ new Set();
14304
14444
  handlers = /* @__PURE__ */ new Map();
14305
14445
  statusHandlers = /* @__PURE__ */ new Set();
14306
14446
  socket = null;
@@ -14403,6 +14543,18 @@ var WalletApiSession = class {
14403
14543
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
14404
14544
  }
14405
14545
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
14546
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
14547
+ currentEpoch() {
14548
+ return this.epochLatch ?? "";
14549
+ }
14550
+ /**
14551
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
14552
+ * the reseed chain, BEFORE any stream nudge resumes.
14553
+ */
14554
+ subscribeEpochChange(handler) {
14555
+ this.epochHandlers.add(handler);
14556
+ return () => this.epochHandlers.delete(handler);
14557
+ }
14406
14558
  noteEpoch(epoch) {
14407
14559
  if (this.epochLatch === epoch) return;
14408
14560
  const previous = this.epochLatch;
@@ -14416,7 +14568,7 @@ var WalletApiSession = class {
14416
14568
  }
14417
14569
  async runReseed(epoch, previous) {
14418
14570
  try {
14419
- await this.deps.onEpochChange(epoch);
14571
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
14420
14572
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
14421
14573
  } catch {
14422
14574
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -14678,6 +14830,7 @@ var WalletApiDeliveryPort = class {
14678
14830
  wake;
14679
14831
  deriveFn;
14680
14832
  seenChain = new SerialChain();
14833
+ lastIncomingEpoch = null;
14681
14834
  constructor(config) {
14682
14835
  this.client = config.client;
14683
14836
  this.kv = config.kv;
@@ -14772,6 +14925,7 @@ var WalletApiDeliveryPort = class {
14772
14925
  const seen = await this.readSeen();
14773
14926
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
14774
14927
  for (; ; ) {
14928
+ this.lastIncomingEpoch = String(page.syncEpoch);
14775
14929
  for (const entry of page.entries) {
14776
14930
  if (entry.status !== "unclaimed") continue;
14777
14931
  if (seen.has(entry.entryId)) continue;
@@ -14781,6 +14935,10 @@ var WalletApiDeliveryPort = class {
14781
14935
  page = await this.client.listMailbox(page.cursor);
14782
14936
  }
14783
14937
  }
14938
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
14939
+ incomingEpoch() {
14940
+ return this.lastIncomingEpoch;
14941
+ }
14784
14942
  toIncoming(entry) {
14785
14943
  const bundle = this.openEnvelope(entry);
14786
14944
  return {
@@ -14858,7 +15016,7 @@ var WalletApiSplitCheckpointStore = class {
14858
15016
  return utf8(`${transferId}:${String(opIndex)}`);
14859
15017
  }
14860
15018
  async put(transferId, opIndex, bytes) {
14861
- const { client, kv, fieldKey, signProgress } = this.config;
15019
+ const { kv, fieldKey } = this.config;
14862
15020
  const aad = this.aad(transferId, opIndex);
14863
15021
  const cacheKey = this.cacheKey(transferId, opIndex);
14864
15022
  let envelope = await kv.get(cacheKey);
@@ -14866,10 +15024,21 @@ var WalletApiSplitCheckpointStore = class {
14866
15024
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
14867
15025
  await kv.set(cacheKey, envelope);
14868
15026
  }
14869
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
14870
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
15027
+ const record = await this.post(transferId, opIndex, envelope);
14871
15028
  return this.open(record.payload, aad);
14872
15029
  }
15030
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
15031
+ async reseedCheckpoint(transferId, opIndex) {
15032
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
15033
+ if (envelope === null) return false;
15034
+ await this.post(transferId, opIndex, envelope);
15035
+ return true;
15036
+ }
15037
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
15038
+ async post(transferId, opIndex, envelope) {
15039
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
15040
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
15041
+ }
14873
15042
  async get(transferId, opIndex) {
14874
15043
  const records = await this.config.client.getProgress(transferId);
14875
15044
  const record = records.find((r) => r.opIndex === opIndex);
@@ -14892,7 +15061,7 @@ var WalletApiSplitCheckpointStore = class {
14892
15061
  };
14893
15062
 
14894
15063
  // core/payments-v2-wiring.ts
14895
- function resolvePaymentsV2Composition(walletApi) {
15064
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
14896
15065
  const source = walletApi;
14897
15066
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
14898
15067
  if (!source || network === null) {
@@ -14901,6 +15070,18 @@ function resolvePaymentsV2Composition(walletApi) {
14901
15070
  "INVALID_CONFIG"
14902
15071
  );
14903
15072
  }
15073
+ if (!(network in NETWORKS)) {
15074
+ throw new SphereError(
15075
+ `payments: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
15076
+ "INVALID_CONFIG"
15077
+ );
15078
+ }
15079
+ if (network !== sphereNetwork) {
15080
+ throw new SphereError(
15081
+ `payments: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
15082
+ "INVALID_CONFIG"
15083
+ );
15084
+ }
14904
15085
  if (typeof source.paymentsV2Transport === "function") {
14905
15086
  return { network, factory: source.paymentsV2Transport.bind(source) };
14906
15087
  }
@@ -14965,8 +15146,7 @@ function defaultPaymentsV2Transport(config) {
14965
15146
  deviceId: `${config.deviceId}:pv2`,
14966
15147
  kv: args.kv,
14967
15148
  webSocketFactory,
14968
- emitStatus: args.emitStatus,
14969
- onEpochChange: args.onEpochChange
15149
+ emitStatus: args.emitStatus
14970
15150
  });
14971
15151
  return { session, client: authedWireClient(session, client) };
14972
15152
  };
@@ -15036,8 +15216,7 @@ function composePaymentsV2(spec) {
15036
15216
  identity: identitySlice,
15037
15217
  network,
15038
15218
  kv,
15039
- emitStatus: (status) => host.emit("connection:status", { status }),
15040
- onEpochChange: async () => void 0
15219
+ emitStatus: (status) => host.emit("connection:status", { status })
15041
15220
  });
15042
15221
  const deps = {
15043
15222
  session: transport.session,
@@ -15067,7 +15246,10 @@ function composePaymentsV2(spec) {
15067
15246
  network,
15068
15247
  ownPubkey: identity.chainPubkey,
15069
15248
  ownNametag: host.nametag,
15070
- requestMemo: requestMemoCodec(identity.privateKey)
15249
+ requestMemo: requestMemoCodec(identity.privateKey),
15250
+ // §5.1: the session is the epoch authority — the facade registers its
15251
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
15252
+ syncEpoch: () => transport.session.currentEpoch()
15071
15253
  };
15072
15254
  return new PaymentsFacade(deps);
15073
15255
  }
@@ -15222,7 +15404,7 @@ var Sphere = class _Sphere {
15222
15404
  static async init(options) {
15223
15405
  if (options.debug) logger.configure({ debug: true });
15224
15406
  _Sphere.refuseRetiredModuleOptions(options);
15225
- resolvePaymentsV2Composition(options.walletApi);
15407
+ resolvePaymentsV2Composition(options.walletApi, options.network);
15226
15408
  _Sphere.configureTokenRegistry(options.storage, options.network);
15227
15409
  const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
15228
15410
  const market = _Sphere.resolveMarketConfig(options.market);
@@ -15364,7 +15546,7 @@ var Sphere = class _Sphere {
15364
15546
  static async create(options) {
15365
15547
  if (options.debug) logger.configure({ debug: true });
15366
15548
  _Sphere.refuseRetiredModuleOptions(options);
15367
- const composition = resolvePaymentsV2Composition(options.walletApi);
15549
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15368
15550
  if (!options.mnemonic || !_Sphere.validateMnemonic(options.mnemonic)) {
15369
15551
  throw new SphereError("Invalid mnemonic", "INVALID_IDENTITY");
15370
15552
  }
@@ -15432,7 +15614,7 @@ var Sphere = class _Sphere {
15432
15614
  static async load(options) {
15433
15615
  if (options.debug) logger.configure({ debug: true });
15434
15616
  _Sphere.refuseRetiredModuleOptions(options);
15435
- const composition = resolvePaymentsV2Composition(options.walletApi);
15617
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15436
15618
  if (!await _Sphere.exists(options.storage)) {
15437
15619
  throw new SphereError("No wallet found. Use Sphere.create() to create a new wallet.", "NOT_INITIALIZED");
15438
15620
  }
@@ -15486,7 +15668,7 @@ var Sphere = class _Sphere {
15486
15668
  static async import(options) {
15487
15669
  if (options.debug) logger.configure({ debug: true });
15488
15670
  _Sphere.refuseRetiredModuleOptions(options);
15489
- const composition = resolvePaymentsV2Composition(options.walletApi);
15671
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15490
15672
  if (!options.mnemonic && !options.masterKey) {
15491
15673
  throw new SphereError("Either mnemonic or masterKey is required", "INVALID_CONFIG");
15492
15674
  }