@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
@@ -11170,7 +11170,10 @@ var STORE_KEYS = {
11170
11170
  shortfalls: "shortfalls",
11171
11171
  settlingLinks: "settling",
11172
11172
  streamCursor: (s) => `cursor:${s}`,
11173
- epochLatch: "epoch-latch"
11173
+ epochLatch: "epoch-latch",
11174
+ // §5.2 InventoryView durable overlays (#625/#679).
11175
+ suspectedSpent: "suspected-spent",
11176
+ knownSpends: "known-spends"
11174
11177
  };
11175
11178
 
11176
11179
  // modules/payments-v2/machine/journal.ts
@@ -11420,7 +11423,7 @@ var TransferMachine = class {
11420
11423
  }
11421
11424
  if (cls === null) {
11422
11425
  await this.applyCommitted(engine, plan.transferId, committed);
11423
- this.closeTail(plan.transferId, plan.payload);
11426
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
11424
11427
  return { committed, deliveryPending };
11425
11428
  }
11426
11429
  if (cls === "conflict" && committed.length > 0) {
@@ -11446,10 +11449,10 @@ var TransferMachine = class {
11446
11449
  }
11447
11450
  await this.applyCommitted(engine, r.transferId, committed);
11448
11451
  if (conflicts.length > 0) {
11449
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
11452
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
11450
11453
  }
11451
11454
  await this.completeIntent(r.transferId, r.payload);
11452
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11455
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11453
11456
  return { deliveryPending, partial: null };
11454
11457
  }
11455
11458
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -11631,7 +11634,7 @@ var TransferMachine = class {
11631
11634
  }
11632
11635
  return added;
11633
11636
  }
11634
- async settlePartial(r, committed, conflicts) {
11637
+ async settlePartial(engine, r, committed, conflicts) {
11635
11638
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
11636
11639
  const entry = {
11637
11640
  transferId: r.transferId,
@@ -11643,26 +11646,27 @@ var TransferMachine = class {
11643
11646
  };
11644
11647
  await this.stores.shortfalls.upsert(entry);
11645
11648
  await this.completeIntent(r.transferId, r.payload);
11646
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11649
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11647
11650
  return entry;
11648
11651
  }
11649
- closeTail(transferId, payload) {
11652
+ closeTail(engine, transferId, payload, committed) {
11650
11653
  void (async () => {
11651
11654
  try {
11652
11655
  await this.completeIntent(transferId, payload);
11653
11656
  } catch {
11654
11657
  return;
11655
11658
  }
11656
- await this.finishBookkeeping(transferId, payload, "sent");
11659
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
11657
11660
  })();
11658
11661
  }
11659
11662
  async completeIntent(transferId, payload) {
11660
11663
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
11661
11664
  await this.deps.intents.complete(transferId, signature);
11662
11665
  }
11663
- async finishBookkeeping(transferId, payload, phase) {
11666
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
11664
11667
  try {
11665
- await this.deps.recordHistory?.({ transferId, payload, phase });
11668
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
11669
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
11666
11670
  } catch {
11667
11671
  }
11668
11672
  try {
@@ -11670,6 +11674,26 @@ var TransferMachine = class {
11670
11674
  } catch {
11671
11675
  }
11672
11676
  }
11677
+ /**
11678
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
11679
+ * certified recipient blobs (summarize()'s committed set is the only source;
11680
+ * a journal-rehydrated resume leg has no source token, but every committed
11681
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
11682
+ */
11683
+ async settledAmount(engine, coinId, committed) {
11684
+ let total = 0n;
11685
+ for (const outcome of committed) {
11686
+ if (outcome.recipientBlob === void 0) continue;
11687
+ const token = await engine.decodeToken({
11688
+ v: TOKEN_BLOB_VERSION,
11689
+ network: 0,
11690
+ tokenId: "",
11691
+ token: outcome.recipientBlob
11692
+ });
11693
+ total += engine.balanceOf(token, coinId);
11694
+ }
11695
+ return total.toString();
11696
+ }
11673
11697
  async disposeOnFailure(transferId, committed, cls) {
11674
11698
  if (committed.length > 0 || cls === "keep-open") return;
11675
11699
  try {
@@ -11694,7 +11718,7 @@ function conflictAmount(engine, payload, op, source) {
11694
11718
  }
11695
11719
 
11696
11720
  // modules/payments-v2/machine/resume.ts
11697
- async function resumeAll(deps) {
11721
+ async function resumeAll(deps, options = {}) {
11698
11722
  const report = { resumed: [], conflicted: [], failed: [] };
11699
11723
  const stores = createMachineStores(deps.kv);
11700
11724
  const open = await deps.intents.listOpen();
@@ -11704,7 +11728,12 @@ async function resumeAll(deps) {
11704
11728
  } catch {
11705
11729
  return report;
11706
11730
  }
11707
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
11731
+ await dropStaleBackstops(
11732
+ stores,
11733
+ locals,
11734
+ new Set(open.map((i) => i.transferId)),
11735
+ options.isLocallyActive ?? (() => false)
11736
+ );
11708
11737
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
11709
11738
  const ctx = {
11710
11739
  deps,
@@ -11717,11 +11746,16 @@ async function resumeAll(deps) {
11717
11746
  }
11718
11747
  return report;
11719
11748
  }
11720
- async function dropStaleLocalAborts(stores, locals, openIds) {
11749
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
11721
11750
  for (const entry of locals.values()) {
11722
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
11751
+ if (openIds.has(entry.transferId)) continue;
11752
+ if (entry.disposition !== "open") {
11723
11753
  await stores.backstop.removeByKey(entry.transferId);
11754
+ continue;
11724
11755
  }
11756
+ if (isLocallyActive(entry.transferId)) continue;
11757
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
11758
+ await stores.backstop.removeByKey(entry.transferId);
11725
11759
  }
11726
11760
  }
11727
11761
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -11929,7 +11963,9 @@ var Converger = class {
11929
11963
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
11930
11964
  }
11931
11965
  async resumePass() {
11932
- const report = await resumeAll(this.filteredMachineDeps());
11966
+ const report = await resumeAll(this.filteredMachineDeps(), {
11967
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
11968
+ });
11933
11969
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
11934
11970
  await this.deps.reconcile({
11935
11971
  resumed: report.resumed,
@@ -12045,6 +12081,136 @@ function distinctOpCount(entries) {
12045
12081
  return new Set(entries.map((entry) => entry.opIndex)).size;
12046
12082
  }
12047
12083
 
12084
+ // modules/payments-v2/restore.ts
12085
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
12086
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
12087
+ async function reseedAndReset(deps) {
12088
+ for (const entry of await deps.stores.backstop.list()) {
12089
+ if (entry.disposition !== "open") continue;
12090
+ try {
12091
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
12092
+ if (entry.requiresSeedClose) {
12093
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
12094
+ }
12095
+ } catch (err) {
12096
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
12097
+ }
12098
+ }
12099
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
12100
+ await deps.fullRePull();
12101
+ }
12102
+ async function splitOpIndex(deps, envelope) {
12103
+ const payload = await deps.decryptPayload(envelope);
12104
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
12105
+ }
12106
+
12107
+ // modules/payments-v2/inventory/presentation.ts
12108
+ function toToken(tokenId, snapshot, asset, registry, flags) {
12109
+ const iconUrl = registry.getIconUrl(asset.coinId);
12110
+ return {
12111
+ id: tokenId,
12112
+ coinId: asset.coinId,
12113
+ symbol: registry.getSymbol(asset.coinId),
12114
+ name: registry.getName(asset.coinId),
12115
+ decimals: registry.getDecimals(asset.coinId),
12116
+ ...iconUrl !== null ? { iconUrl } : {},
12117
+ amount: asset.amount,
12118
+ status: flags.transferring ? "transferring" : "confirmed",
12119
+ createdAt: snapshot.createdAt,
12120
+ updatedAt: snapshot.updatedAt,
12121
+ lazy: true,
12122
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12123
+ };
12124
+ }
12125
+ function aggregateAssets(entries, isTransferring, registry) {
12126
+ const groups = /* @__PURE__ */ new Map();
12127
+ for (const [tokenId, entry] of entries) {
12128
+ const moving = isTransferring(tokenId);
12129
+ for (const asset of entry.assets) {
12130
+ let group = groups.get(asset.coinId);
12131
+ if (!group) {
12132
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12133
+ groups.set(asset.coinId, group);
12134
+ }
12135
+ if (moving) {
12136
+ group.transferring += BigInt(asset.amount);
12137
+ group.transferringCount += 1;
12138
+ } else {
12139
+ group.total += BigInt(asset.amount);
12140
+ group.count += 1;
12141
+ }
12142
+ }
12143
+ }
12144
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12145
+ }
12146
+ function toAsset(coinId, totals, registry) {
12147
+ const iconUrl = registry.getIconUrl(coinId);
12148
+ return {
12149
+ coinId,
12150
+ symbol: registry.getSymbol(coinId),
12151
+ name: registry.getName(coinId),
12152
+ decimals: registry.getDecimals(coinId),
12153
+ ...iconUrl !== null ? { iconUrl } : {},
12154
+ totalAmount: totals.total.toString(),
12155
+ tokenCount: totals.count,
12156
+ confirmedAmount: totals.total.toString(),
12157
+ unconfirmedAmount: "0",
12158
+ confirmedTokenCount: totals.count,
12159
+ unconfirmedTokenCount: 0,
12160
+ transferringTokenCount: totals.transferringCount,
12161
+ transferringAmount: totals.transferring.toString(),
12162
+ priceUsd: null,
12163
+ priceEur: null,
12164
+ change24h: null,
12165
+ fiatValueUsd: null,
12166
+ fiatValueEur: null
12167
+ };
12168
+ }
12169
+ async function withPrices(raw, registry, price) {
12170
+ try {
12171
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12172
+ const quotes = await price.getPrices(names);
12173
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12174
+ } catch {
12175
+ return raw;
12176
+ }
12177
+ }
12178
+ function priceOne(asset, quote) {
12179
+ if (!quote) return asset;
12180
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12181
+ return {
12182
+ ...asset,
12183
+ priceUsd: quote.priceUsd,
12184
+ priceEur: quote.priceEur ?? null,
12185
+ change24h: quote.change24h ?? null,
12186
+ fiatValueUsd: human * quote.priceUsd,
12187
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12188
+ };
12189
+ }
12190
+
12191
+ // modules/payments-v2/machine/payload.ts
12192
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12193
+ return {
12194
+ v: 2,
12195
+ recipient: recipientPubkey,
12196
+ coinId: request.coinId,
12197
+ amount: request.amount,
12198
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12199
+ direct: [...spend.plan.direct],
12200
+ ...spend.plan.split !== void 0 ? {
12201
+ split: {
12202
+ tokenId: spend.plan.split.tokenId,
12203
+ splitAmount: spend.plan.split.splitAmount.toString(),
12204
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12205
+ }
12206
+ } : {},
12207
+ spentStates
12208
+ };
12209
+ }
12210
+ function messageOf(err) {
12211
+ return err instanceof Error ? err.message : String(err);
12212
+ }
12213
+
12048
12214
  // modules/payments-v2/history/History.ts
12049
12215
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
12050
12216
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -12103,13 +12269,14 @@ var History = class {
12103
12269
  }
12104
12270
  // ── internals ────────────────────────────────────────────────────────────────
12105
12271
  async post(build) {
12272
+ const record = build();
12106
12273
  try {
12107
- await this.deps.client.postHistory([build()]);
12274
+ await this.deps.client.postHistory([record]);
12108
12275
  } catch (err) {
12109
12276
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
12110
12277
  return;
12111
12278
  }
12112
- this.deps.emit("history:updated");
12279
+ this.deps.emit("history:updated", this.toEntry(record));
12113
12280
  }
12114
12281
  ts(timestamp) {
12115
12282
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -12166,93 +12333,9 @@ var History = class {
12166
12333
  }
12167
12334
  };
12168
12335
 
12169
- // modules/payments-v2/inventory/presentation.ts
12170
- function toToken(tokenId, snapshot, asset, registry, flags) {
12171
- const iconUrl = registry.getIconUrl(asset.coinId);
12172
- return {
12173
- id: tokenId,
12174
- coinId: asset.coinId,
12175
- symbol: registry.getSymbol(asset.coinId),
12176
- name: registry.getName(asset.coinId),
12177
- decimals: registry.getDecimals(asset.coinId),
12178
- ...iconUrl !== null ? { iconUrl } : {},
12179
- amount: asset.amount,
12180
- status: flags.transferring ? "transferring" : "confirmed",
12181
- createdAt: snapshot.createdAt,
12182
- updatedAt: snapshot.updatedAt,
12183
- lazy: true,
12184
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12185
- };
12186
- }
12187
- function aggregateAssets(entries, isTransferring, registry) {
12188
- const groups = /* @__PURE__ */ new Map();
12189
- for (const [tokenId, entry] of entries) {
12190
- const moving = isTransferring(tokenId);
12191
- for (const asset of entry.assets) {
12192
- let group = groups.get(asset.coinId);
12193
- if (!group) {
12194
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12195
- groups.set(asset.coinId, group);
12196
- }
12197
- if (moving) {
12198
- group.transferring += BigInt(asset.amount);
12199
- group.transferringCount += 1;
12200
- } else {
12201
- group.total += BigInt(asset.amount);
12202
- group.count += 1;
12203
- }
12204
- }
12205
- }
12206
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12207
- }
12208
- function toAsset(coinId, totals, registry) {
12209
- const iconUrl = registry.getIconUrl(coinId);
12210
- return {
12211
- coinId,
12212
- symbol: registry.getSymbol(coinId),
12213
- name: registry.getName(coinId),
12214
- decimals: registry.getDecimals(coinId),
12215
- ...iconUrl !== null ? { iconUrl } : {},
12216
- totalAmount: totals.total.toString(),
12217
- tokenCount: totals.count,
12218
- confirmedAmount: totals.total.toString(),
12219
- unconfirmedAmount: "0",
12220
- confirmedTokenCount: totals.count,
12221
- unconfirmedTokenCount: 0,
12222
- transferringTokenCount: totals.transferringCount,
12223
- transferringAmount: totals.transferring.toString(),
12224
- priceUsd: null,
12225
- priceEur: null,
12226
- change24h: null,
12227
- fiatValueUsd: null,
12228
- fiatValueEur: null
12229
- };
12230
- }
12231
- async function withPrices(raw, registry, price) {
12232
- try {
12233
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12234
- const quotes = await price.getPrices(names);
12235
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12236
- } catch {
12237
- return raw;
12238
- }
12239
- }
12240
- function priceOne(asset, quote) {
12241
- if (!quote) return asset;
12242
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12243
- return {
12244
- ...asset,
12245
- priceUsd: quote.priceUsd,
12246
- priceEur: quote.priceEur ?? null,
12247
- change24h: quote.change24h ?? null,
12248
- fiatValueUsd: human * quote.priceUsd,
12249
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12250
- };
12251
- }
12252
-
12253
12336
  // modules/payments-v2/inventory/InventoryView.ts
12254
- var SUSPECTED_KEY = "suspected-spent";
12255
- var KNOWN_SPENDS_KEY = "known-spends";
12337
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
12338
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
12256
12339
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12257
12340
  function stateKey(tokenId, stateHash) {
12258
12341
  return `${tokenId}:${stateHash}`;
@@ -12556,19 +12639,24 @@ var Receive = class {
12556
12639
  const engine = deps.engine();
12557
12640
  const stored = [];
12558
12641
  const pending = [];
12559
- let epoch = "";
12642
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
12560
12643
  try {
12561
- epoch = deps.syncEpoch();
12562
12644
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
12563
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
12564
- for await (const entry of deps.delivery.incoming(since)) {
12565
- await this.processEntry(deps, engine, entry, pending, stored);
12566
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
12645
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
12646
+ for (let pass = 0; pass < 2; pass++) {
12647
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
12648
+ await this.processEntry(deps, engine, entry, pending, stored);
12649
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
12650
+ }
12651
+ await flushAcks(deps, pending, pageEpoch);
12652
+ const served = deps.delivery.incomingEpoch();
12653
+ if (basis === null || served === null || served === basis.syncEpoch) break;
12654
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
12655
+ basis = null;
12567
12656
  }
12568
- await flushAcks(deps, pending, epoch);
12569
12657
  } catch (err) {
12570
12658
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
12571
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
12659
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12572
12660
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12573
12661
  });
12574
12662
  }
@@ -12641,7 +12729,7 @@ async function announce(deps, entry, record) {
12641
12729
  receivedAt
12642
12730
  };
12643
12731
  }
12644
- async function flushAcks(deps, pending, epoch) {
12732
+ async function flushAcks(deps, pending, epochOf) {
12645
12733
  let lastAcked = null;
12646
12734
  try {
12647
12735
  while (pending.length > 0) {
@@ -12652,7 +12740,7 @@ async function flushAcks(deps, pending, epoch) {
12652
12740
  }
12653
12741
  } finally {
12654
12742
  if (lastAcked !== null) {
12655
- const record = { cursor: lastAcked, syncEpoch: epoch };
12743
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
12656
12744
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
12657
12745
  }
12658
12746
  }
@@ -12753,9 +12841,19 @@ var Requests = class {
12753
12841
  }
12754
12842
  });
12755
12843
  }
12844
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
12756
12845
  writeLink(requestId, transferId, committed) {
12757
12846
  return this.mutateJournal((m) => {
12758
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
12847
+ const prev = m.get(requestId);
12848
+ const same = prev !== void 0 && prev.transferId === transferId;
12849
+ const next = {
12850
+ requestId,
12851
+ transferId,
12852
+ committed: committed || same && prev.committed,
12853
+ createdAt: same ? prev.createdAt : this.now()
12854
+ };
12855
+ if (same && prev.committed === next.committed) return false;
12856
+ m.set(requestId, next);
12759
12857
  return true;
12760
12858
  });
12761
12859
  }
@@ -12908,23 +13006,46 @@ var Requests = class {
12908
13006
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
12909
13007
  if (transferId !== void 0) {
12910
13008
  const committed = error instanceof PartialSendConflictError;
12911
- await this.writeLink(id, transferId, committed);
12912
- this.setStatus(id, "settling");
13009
+ await this.settle(id, transferId, { committed, respond: false });
12913
13010
  } else {
12914
13011
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
12915
13012
  this.setStatus(id, "paid");
12916
13013
  }
12917
13014
  } else {
12918
- await this.clearLink(id);
12919
- this.setStatus(id, "pending");
13015
+ await this.revertPayable(id);
12920
13016
  }
12921
13017
  throw error;
12922
13018
  }
12923
- this.setStatus(id, "paid");
12924
- await this.respondPaid(id, result.id);
12925
- await this.clearLink(id);
13019
+ await this.settle(id, result.id, { committed: true, respond: true });
12926
13020
  return result;
12927
13021
  }
13022
+ /**
13023
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
13024
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
13025
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
13026
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
13027
+ * not a reload. Every path that binds a request to a transfer outcome funnels
13028
+ * through here — pay()'s clean success (respond now), pay()'s
13029
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
13030
+ * — so a failed respond always leaves the link + 'settling' and the next
13031
+ * reconcile pass (the committed-link override) retries the respond.
13032
+ */
13033
+ async settle(requestId, transferId, opts) {
13034
+ let journalDown = false;
13035
+ try {
13036
+ await this.writeLink(requestId, transferId, opts.committed);
13037
+ } catch (err) {
13038
+ if (!opts.respond) throw err;
13039
+ journalDown = true;
13040
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
13041
+ }
13042
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
13043
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
13044
+ return;
13045
+ }
13046
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
13047
+ this.setStatus(requestId, "paid");
13048
+ }
12928
13049
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
12929
13050
  async respondPaid(id, transferId) {
12930
13051
  try {
@@ -12960,32 +13081,32 @@ var Requests = class {
12960
13081
  let aborted = null;
12961
13082
  for (const [requestId, link] of [...this.journal]) {
12962
13083
  if (link.committed || resumed.has(link.transferId)) {
12963
- await this.resolvePaid(requestId, link.transferId);
13084
+ await this.resolvePaid(requestId, link);
12964
13085
  } else if (conflicted.has(link.transferId)) {
12965
13086
  await this.revertPayable(requestId);
12966
13087
  } else if (open.has(link.transferId)) {
12967
13088
  } else {
12968
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
13089
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
12969
13090
  }
12970
13091
  }
12971
13092
  }
12972
13093
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
12973
13094
  // a probe failure defers the link to the next session.
12974
- async reconcileUnaccounted(requestId, transferId, aborted) {
13095
+ async reconcileUnaccounted(requestId, link, aborted) {
12975
13096
  try {
12976
13097
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
12977
- if (aborted.has(transferId)) await this.revertPayable(requestId);
12978
- else await this.resolvePaid(requestId, transferId);
13098
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
13099
+ else await this.resolvePaid(requestId, link);
12979
13100
  } catch (err) {
12980
13101
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
12981
13102
  }
12982
13103
  return aborted;
12983
13104
  }
12984
- async resolvePaid(requestId, transferId) {
12985
- if (!await this.respondPaid(requestId, transferId)) return;
12986
- await this.clearLink(requestId);
12987
- this.setStatus(requestId, "paid");
13105
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
13106
+ resolvePaid(requestId, link) {
13107
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
12988
13108
  }
13109
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
12989
13110
  async revertPayable(requestId) {
12990
13111
  await this.clearLink(requestId);
12991
13112
  this.setStatus(requestId, "pending");
@@ -13309,11 +13430,12 @@ function composeFacadeParts(deps, hooks) {
13309
13430
  client: deps.client,
13310
13431
  fieldKey: deps.fieldKey,
13311
13432
  registry: deps.registry,
13312
- emit: (event) => deps.emit(event, {}),
13433
+ emit: (event, entry) => deps.emit(event, entry),
13313
13434
  ...deps.now !== void 0 ? { now: deps.now } : {},
13314
13435
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
13315
13436
  });
13316
13437
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13438
+ const machineStores = createMachineStores(deps.kv);
13317
13439
  const heldStates = /* @__PURE__ */ new Map();
13318
13440
  return {
13319
13441
  ownPubkeyBytes,
@@ -13321,12 +13443,29 @@ function composeFacadeParts(deps, hooks) {
13321
13443
  ledger,
13322
13444
  queue,
13323
13445
  historyStore,
13324
- machineStores: createMachineStores(deps.kv),
13446
+ machineStores,
13325
13447
  machineDeps,
13326
13448
  machine: new TransferMachine(machineDeps),
13327
13449
  heldStates,
13328
13450
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
13329
- requests: buildRequests(deps, hooks)
13451
+ requests: buildRequests(deps, hooks),
13452
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13453
+ };
13454
+ }
13455
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13456
+ return {
13457
+ stores: machineStores,
13458
+ kv: deps.kv,
13459
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
13460
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
13461
+ decryptPayload: machineDeps.decryptPayload,
13462
+ fullRePull: () => view.onEpochReset(),
13463
+ attention: (transferId, code, detail) => {
13464
+ deps.emit(
13465
+ "transfer:attention",
13466
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
13467
+ );
13468
+ }
13330
13469
  };
13331
13470
  }
13332
13471
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -13348,11 +13487,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
13348
13487
  ownPubkey: hexToBytes4(deps.ownPubkey),
13349
13488
  emit: deps.emit,
13350
13489
  now: deps.now ?? Date.now,
13351
- recordHistory: async ({ transferId, payload }) => {
13490
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
13352
13491
  await historyStore.recordSent({
13353
13492
  transferId,
13354
13493
  coinId: payload.coinId,
13355
- amount: payload.amount,
13494
+ // §5.9: the SETTLED amount (machine-computed from the certified
13495
+ // recipient blobs), never payload.amount — the plan.
13496
+ amount: committedAmount,
13356
13497
  recipientPubkey: payload.recipient,
13357
13498
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
13358
13499
  });
@@ -13379,7 +13520,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
13379
13520
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
13380
13521
  );
13381
13522
  },
13382
- syncEpoch: deps.syncEpoch ?? (() => ""),
13523
+ syncEpoch: deps.syncEpoch,
13383
13524
  ...deps.now !== void 0 ? { now: deps.now } : {}
13384
13525
  });
13385
13526
  }
@@ -13432,6 +13573,7 @@ var PaymentsFacade = class {
13432
13573
  this.receiveLoop = parts.receiveLoop;
13433
13574
  this.heldStates = parts.heldStates;
13434
13575
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13576
+ this.restoreDeps = parts.restoreDeps;
13435
13577
  this.requests = parts.requests;
13436
13578
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13437
13579
  this.heartbeat = new ConvergenceHeartbeat({
@@ -13460,6 +13602,7 @@ var PaymentsFacade = class {
13460
13602
  receiveLoop;
13461
13603
  heldStates;
13462
13604
  ownPubkeyBytes;
13605
+ restoreDeps;
13463
13606
  requests;
13464
13607
  currentEngine = null;
13465
13608
  pendingOps = /* @__PURE__ */ new Set();
@@ -13470,6 +13613,8 @@ var PaymentsFacade = class {
13470
13613
  converger;
13471
13614
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
13472
13615
  resumeFlight = new SingleFlight();
13616
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
13617
+ passChain = new SerialChain();
13473
13618
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13474
13619
  activeMoneyOps = /* @__PURE__ */ new Set();
13475
13620
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -13477,8 +13622,8 @@ var PaymentsFacade = class {
13477
13622
  if (this.started) return;
13478
13623
  this.started = true;
13479
13624
  this.heartbeat.start();
13480
- await this.deps.session.start();
13481
13625
  this.unsubscribers.push(
13626
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
13482
13627
  this.deps.session.subscribeStream("mailbox", () => {
13483
13628
  this.trackTail(this.receiveLoop.drainOnce());
13484
13629
  }),
@@ -13493,6 +13638,7 @@ var PaymentsFacade = class {
13493
13638
  if (status === "connected") this.heartbeat.recovered();
13494
13639
  });
13495
13640
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13641
+ await this.deps.session.start();
13496
13642
  this.trackTail(this.runConvergencePass());
13497
13643
  this.trackTail(this.seedHeldStates());
13498
13644
  await this.view.fullPull().catch(() => void 0);
@@ -13533,7 +13679,7 @@ var PaymentsFacade = class {
13533
13679
  }
13534
13680
  // ── money movement ─────────────────────────────────────────────────────────
13535
13681
  send(request) {
13536
- return this.track(this.sendWithPolicy(request));
13682
+ return this.track(this.sendOutcome(request));
13537
13683
  }
13538
13684
  async receive() {
13539
13685
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -13548,20 +13694,27 @@ var PaymentsFacade = class {
13548
13694
  if (!this.started) return;
13549
13695
  await this.track(this.runConvergencePass());
13550
13696
  }
13697
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
13698
+ async handleEpochChange(_newEpoch) {
13699
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
13700
+ await this.passChain.enqueue(() => this.convergeBody());
13701
+ }
13551
13702
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
13552
13703
  runConvergencePass() {
13553
- return this.resumeFlight.run(async () => {
13554
- const outcome = await this.converger.convergeOnce();
13555
- const pending = await this.converger.pendingWork().catch(() => true);
13556
- this.heartbeat.settle(outcome, pending);
13557
- });
13704
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
13705
+ }
13706
+ async convergeBody() {
13707
+ const outcome = await this.converger.convergeOnce();
13708
+ const pending = await this.converger.pendingWork().catch(() => true);
13709
+ this.heartbeat.settle(outcome, pending);
13558
13710
  }
13559
13711
  nowMs() {
13560
13712
  return (this.deps.now ?? Date.now)();
13561
13713
  }
13562
13714
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
13563
- async sendWithPolicy(request) {
13564
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
13715
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
13716
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
13717
+ async sendOutcome(request) {
13565
13718
  const run = {
13566
13719
  amount: request.amount,
13567
13720
  delivered: [],
@@ -13569,12 +13722,36 @@ var PaymentsFacade = class {
13569
13722
  tokenTransfers: [],
13570
13723
  settledShortfalls: [],
13571
13724
  firstPartialId: void 0,
13572
- pendingAny: false
13725
+ pendingAny: false,
13726
+ lastTransferId: ""
13573
13727
  };
13728
+ try {
13729
+ return await this.sendWithPolicy(request, run);
13730
+ } catch (err) {
13731
+ this.emitCleanFailure(err, run.lastTransferId);
13732
+ throw err;
13733
+ }
13734
+ }
13735
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
13736
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
13737
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
13738
+ emitCleanFailure(err, transferId) {
13739
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
13740
+ const failed = {
13741
+ id: transferId,
13742
+ status: "failed",
13743
+ tokens: [],
13744
+ tokenTransfers: [],
13745
+ error: messageOf(err)
13746
+ };
13747
+ this.deps.emit("transfer:updated", failed);
13748
+ }
13749
+ async sendWithPolicy(request, run) {
13750
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
13574
13751
  for (let attempt = 0; ; attempt++) {
13575
13752
  let ctx;
13576
13753
  try {
13577
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
13754
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
13578
13755
  } catch (err) {
13579
13756
  throw this.partialize(err, run);
13580
13757
  }
@@ -13619,11 +13796,8 @@ var PaymentsFacade = class {
13619
13796
  this.activeMoneyOps.delete(ctx.transferId);
13620
13797
  }
13621
13798
  }
13622
- /**
13623
- * Partial outcome (#677/#690): the shortfall is already durable (written by
13624
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
13625
- * the remainder under a NEW transferId — never the full amount.
13626
- */
13799
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
13800
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
13627
13801
  async consumePartial(ctx, disposition, run, attempt) {
13628
13802
  if (disposition.deliveryPending) {
13629
13803
  run.pendingAny = true;
@@ -13649,11 +13823,8 @@ var PaymentsFacade = class {
13649
13823
  run.amount = partial.remainingAmount;
13650
13824
  return null;
13651
13825
  }
13652
- /**
13653
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
13654
- * backstop still 'open' (committed>0) → converge via the same machine's
13655
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
13656
- */
13826
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
13827
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
13657
13828
  async disposeFailedAttempt(ctx, err) {
13658
13829
  if (isPossiblyCommittedSendOutcome(err)) {
13659
13830
  this.settleKeepOpen(ctx);
@@ -13702,8 +13873,9 @@ var PaymentsFacade = class {
13702
13873
  }
13703
13874
  return { kind: "rethrow", error: err };
13704
13875
  }
13705
- async planAndMaterialize(recipientPubkey, request) {
13876
+ async planAndMaterialize(recipientPubkey, request, run) {
13706
13877
  const transferId = this.newId();
13878
+ run.lastTransferId = transferId;
13707
13879
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
13708
13880
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
13709
13881
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -13835,11 +14007,7 @@ var PaymentsFacade = class {
13835
14007
  this.deps.emit("transfer:updated", result);
13836
14008
  return result;
13837
14009
  }
13838
- /**
13839
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
13840
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
13841
- * over the accumulated settled set — never bare, never a full-amount retry.
13842
- */
14010
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
13843
14011
  partialize(err, run) {
13844
14012
  if (run.delivered.length === 0) return err;
13845
14013
  return new PartialSendConflictError(
@@ -13991,22 +14159,14 @@ var PaymentsFacade = class {
13991
14159
  }
13992
14160
  }
13993
14161
  toUiToken(tokenId, coinId, amount) {
13994
- const registry = this.deps.registry;
13995
- const iconUrl = registry.getIconUrl(coinId);
13996
14162
  const now = this.nowMs();
13997
- return {
13998
- id: tokenId,
13999
- coinId,
14000
- symbol: registry.getSymbol(coinId),
14001
- name: registry.getName(coinId),
14002
- decimals: registry.getDecimals(coinId),
14003
- ...iconUrl !== null ? { iconUrl } : {},
14004
- amount: amount.toString(),
14005
- status: "transferring",
14006
- createdAt: now,
14007
- updatedAt: now,
14008
- lazy: true
14009
- };
14163
+ return toToken(
14164
+ tokenId,
14165
+ { assets: [], createdAt: now, updatedAt: now },
14166
+ { coinId, amount: amount.toString() },
14167
+ this.deps.registry,
14168
+ { transferring: true, suspectedSpent: false }
14169
+ );
14010
14170
  }
14011
14171
  track(op) {
14012
14172
  const settled = op.then(
@@ -14021,27 +14181,6 @@ var PaymentsFacade = class {
14021
14181
  this.track(op).catch(() => void 0);
14022
14182
  }
14023
14183
  };
14024
- function buildPayload(recipientPubkey, request, spend, spentStates) {
14025
- return {
14026
- v: 2,
14027
- recipient: recipientPubkey,
14028
- coinId: request.coinId,
14029
- amount: request.amount,
14030
- ...request.memo !== void 0 ? { memo: request.memo } : {},
14031
- direct: [...spend.plan.direct],
14032
- ...spend.plan.split !== void 0 ? {
14033
- split: {
14034
- tokenId: spend.plan.split.tokenId,
14035
- splitAmount: spend.plan.split.splitAmount.toString(),
14036
- remainderAmount: spend.plan.split.remainderAmount.toString()
14037
- }
14038
- } : {},
14039
- spentStates
14040
- };
14041
- }
14042
- function messageOf(err) {
14043
- return err instanceof Error ? err.message : String(err);
14044
- }
14045
14184
 
14046
14185
  // impl/wallet-api-v2/http.ts
14047
14186
  var WalletApiHttpError = class extends Error {
@@ -14387,6 +14526,7 @@ var WalletApiSession = class {
14387
14526
  epochLatch = null;
14388
14527
  reseedChain = Promise.resolve();
14389
14528
  reseedsPending = 0;
14529
+ epochHandlers = /* @__PURE__ */ new Set();
14390
14530
  handlers = /* @__PURE__ */ new Map();
14391
14531
  statusHandlers = /* @__PURE__ */ new Set();
14392
14532
  socket = null;
@@ -14489,6 +14629,18 @@ var WalletApiSession = class {
14489
14629
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
14490
14630
  }
14491
14631
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
14632
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
14633
+ currentEpoch() {
14634
+ return this.epochLatch ?? "";
14635
+ }
14636
+ /**
14637
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
14638
+ * the reseed chain, BEFORE any stream nudge resumes.
14639
+ */
14640
+ subscribeEpochChange(handler) {
14641
+ this.epochHandlers.add(handler);
14642
+ return () => this.epochHandlers.delete(handler);
14643
+ }
14492
14644
  noteEpoch(epoch) {
14493
14645
  if (this.epochLatch === epoch) return;
14494
14646
  const previous = this.epochLatch;
@@ -14502,7 +14654,7 @@ var WalletApiSession = class {
14502
14654
  }
14503
14655
  async runReseed(epoch, previous) {
14504
14656
  try {
14505
- await this.deps.onEpochChange(epoch);
14657
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
14506
14658
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
14507
14659
  } catch {
14508
14660
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -14764,6 +14916,7 @@ var WalletApiDeliveryPort = class {
14764
14916
  wake;
14765
14917
  deriveFn;
14766
14918
  seenChain = new SerialChain();
14919
+ lastIncomingEpoch = null;
14767
14920
  constructor(config) {
14768
14921
  this.client = config.client;
14769
14922
  this.kv = config.kv;
@@ -14858,6 +15011,7 @@ var WalletApiDeliveryPort = class {
14858
15011
  const seen = await this.readSeen();
14859
15012
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
14860
15013
  for (; ; ) {
15014
+ this.lastIncomingEpoch = String(page.syncEpoch);
14861
15015
  for (const entry of page.entries) {
14862
15016
  if (entry.status !== "unclaimed") continue;
14863
15017
  if (seen.has(entry.entryId)) continue;
@@ -14867,6 +15021,10 @@ var WalletApiDeliveryPort = class {
14867
15021
  page = await this.client.listMailbox(page.cursor);
14868
15022
  }
14869
15023
  }
15024
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
15025
+ incomingEpoch() {
15026
+ return this.lastIncomingEpoch;
15027
+ }
14870
15028
  toIncoming(entry) {
14871
15029
  const bundle = this.openEnvelope(entry);
14872
15030
  return {
@@ -14944,7 +15102,7 @@ var WalletApiSplitCheckpointStore = class {
14944
15102
  return utf8(`${transferId}:${String(opIndex)}`);
14945
15103
  }
14946
15104
  async put(transferId, opIndex, bytes) {
14947
- const { client, kv, fieldKey, signProgress } = this.config;
15105
+ const { kv, fieldKey } = this.config;
14948
15106
  const aad = this.aad(transferId, opIndex);
14949
15107
  const cacheKey = this.cacheKey(transferId, opIndex);
14950
15108
  let envelope = await kv.get(cacheKey);
@@ -14952,10 +15110,21 @@ var WalletApiSplitCheckpointStore = class {
14952
15110
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
14953
15111
  await kv.set(cacheKey, envelope);
14954
15112
  }
14955
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
14956
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
15113
+ const record = await this.post(transferId, opIndex, envelope);
14957
15114
  return this.open(record.payload, aad);
14958
15115
  }
15116
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
15117
+ async reseedCheckpoint(transferId, opIndex) {
15118
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
15119
+ if (envelope === null) return false;
15120
+ await this.post(transferId, opIndex, envelope);
15121
+ return true;
15122
+ }
15123
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
15124
+ async post(transferId, opIndex, envelope) {
15125
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
15126
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
15127
+ }
14959
15128
  async get(transferId, opIndex) {
14960
15129
  const records = await this.config.client.getProgress(transferId);
14961
15130
  const record = records.find((r) => r.opIndex === opIndex);
@@ -14978,7 +15147,7 @@ var WalletApiSplitCheckpointStore = class {
14978
15147
  };
14979
15148
 
14980
15149
  // core/payments-v2-wiring.ts
14981
- function resolvePaymentsV2Composition(walletApi) {
15150
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
14982
15151
  const source = walletApi;
14983
15152
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
14984
15153
  if (!source || network === null) {
@@ -14987,6 +15156,18 @@ function resolvePaymentsV2Composition(walletApi) {
14987
15156
  "INVALID_CONFIG"
14988
15157
  );
14989
15158
  }
15159
+ if (!(network in NETWORKS)) {
15160
+ throw new SphereError(
15161
+ `payments: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
15162
+ "INVALID_CONFIG"
15163
+ );
15164
+ }
15165
+ if (network !== sphereNetwork) {
15166
+ throw new SphereError(
15167
+ `payments: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
15168
+ "INVALID_CONFIG"
15169
+ );
15170
+ }
14990
15171
  if (typeof source.paymentsV2Transport === "function") {
14991
15172
  return { network, factory: source.paymentsV2Transport.bind(source) };
14992
15173
  }
@@ -15051,8 +15232,7 @@ function defaultPaymentsV2Transport(config) {
15051
15232
  deviceId: `${config.deviceId}:pv2`,
15052
15233
  kv: args.kv,
15053
15234
  webSocketFactory,
15054
- emitStatus: args.emitStatus,
15055
- onEpochChange: args.onEpochChange
15235
+ emitStatus: args.emitStatus
15056
15236
  });
15057
15237
  return { session, client: authedWireClient(session, client) };
15058
15238
  };
@@ -15122,8 +15302,7 @@ function composePaymentsV2(spec) {
15122
15302
  identity: identitySlice,
15123
15303
  network,
15124
15304
  kv,
15125
- emitStatus: (status) => host.emit("connection:status", { status }),
15126
- onEpochChange: async () => void 0
15305
+ emitStatus: (status) => host.emit("connection:status", { status })
15127
15306
  });
15128
15307
  const deps = {
15129
15308
  session: transport.session,
@@ -15153,7 +15332,10 @@ function composePaymentsV2(spec) {
15153
15332
  network,
15154
15333
  ownPubkey: identity.chainPubkey,
15155
15334
  ownNametag: host.nametag,
15156
- requestMemo: requestMemoCodec(identity.privateKey)
15335
+ requestMemo: requestMemoCodec(identity.privateKey),
15336
+ // §5.1: the session is the epoch authority — the facade registers its
15337
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
15338
+ syncEpoch: () => transport.session.currentEpoch()
15157
15339
  };
15158
15340
  return new PaymentsFacade(deps);
15159
15341
  }
@@ -15308,7 +15490,7 @@ var Sphere = class _Sphere {
15308
15490
  static async init(options) {
15309
15491
  if (options.debug) logger.configure({ debug: true });
15310
15492
  _Sphere.refuseRetiredModuleOptions(options);
15311
- resolvePaymentsV2Composition(options.walletApi);
15493
+ resolvePaymentsV2Composition(options.walletApi, options.network);
15312
15494
  _Sphere.configureTokenRegistry(options.storage, options.network);
15313
15495
  const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
15314
15496
  const market = _Sphere.resolveMarketConfig(options.market);
@@ -15450,7 +15632,7 @@ var Sphere = class _Sphere {
15450
15632
  static async create(options) {
15451
15633
  if (options.debug) logger.configure({ debug: true });
15452
15634
  _Sphere.refuseRetiredModuleOptions(options);
15453
- const composition = resolvePaymentsV2Composition(options.walletApi);
15635
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15454
15636
  if (!options.mnemonic || !_Sphere.validateMnemonic(options.mnemonic)) {
15455
15637
  throw new SphereError("Invalid mnemonic", "INVALID_IDENTITY");
15456
15638
  }
@@ -15518,7 +15700,7 @@ var Sphere = class _Sphere {
15518
15700
  static async load(options) {
15519
15701
  if (options.debug) logger.configure({ debug: true });
15520
15702
  _Sphere.refuseRetiredModuleOptions(options);
15521
- const composition = resolvePaymentsV2Composition(options.walletApi);
15703
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15522
15704
  if (!await _Sphere.exists(options.storage)) {
15523
15705
  throw new SphereError("No wallet found. Use Sphere.create() to create a new wallet.", "NOT_INITIALIZED");
15524
15706
  }
@@ -15572,7 +15754,7 @@ var Sphere = class _Sphere {
15572
15754
  static async import(options) {
15573
15755
  if (options.debug) logger.configure({ debug: true });
15574
15756
  _Sphere.refuseRetiredModuleOptions(options);
15575
- const composition = resolvePaymentsV2Composition(options.walletApi);
15757
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15576
15758
  if (!options.mnemonic && !options.masterKey) {
15577
15759
  throw new SphereError("Either mnemonic or masterKey is required", "INVALID_CONFIG");
15578
15760
  }