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

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
package/dist/index.cjs CHANGED
@@ -11228,7 +11228,10 @@ var STORE_KEYS = {
11228
11228
  shortfalls: "shortfalls",
11229
11229
  settlingLinks: "settling",
11230
11230
  streamCursor: (s) => `cursor:${s}`,
11231
- epochLatch: "epoch-latch"
11231
+ epochLatch: "epoch-latch",
11232
+ // §5.2 InventoryView durable overlays (#625/#679).
11233
+ suspectedSpent: "suspected-spent",
11234
+ knownSpends: "known-spends"
11232
11235
  };
11233
11236
 
11234
11237
  // modules/payments-v2/machine/journal.ts
@@ -11478,7 +11481,7 @@ var TransferMachine = class {
11478
11481
  }
11479
11482
  if (cls === null) {
11480
11483
  await this.applyCommitted(engine, plan.transferId, committed);
11481
- this.closeTail(plan.transferId, plan.payload);
11484
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
11482
11485
  return { committed, deliveryPending };
11483
11486
  }
11484
11487
  if (cls === "conflict" && committed.length > 0) {
@@ -11504,10 +11507,10 @@ var TransferMachine = class {
11504
11507
  }
11505
11508
  await this.applyCommitted(engine, r.transferId, committed);
11506
11509
  if (conflicts.length > 0) {
11507
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
11510
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
11508
11511
  }
11509
11512
  await this.completeIntent(r.transferId, r.payload);
11510
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11513
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11511
11514
  return { deliveryPending, partial: null };
11512
11515
  }
11513
11516
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -11689,7 +11692,7 @@ var TransferMachine = class {
11689
11692
  }
11690
11693
  return added;
11691
11694
  }
11692
- async settlePartial(r, committed, conflicts) {
11695
+ async settlePartial(engine, r, committed, conflicts) {
11693
11696
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
11694
11697
  const entry = {
11695
11698
  transferId: r.transferId,
@@ -11701,26 +11704,27 @@ var TransferMachine = class {
11701
11704
  };
11702
11705
  await this.stores.shortfalls.upsert(entry);
11703
11706
  await this.completeIntent(r.transferId, r.payload);
11704
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11707
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11705
11708
  return entry;
11706
11709
  }
11707
- closeTail(transferId, payload) {
11710
+ closeTail(engine, transferId, payload, committed) {
11708
11711
  void (async () => {
11709
11712
  try {
11710
11713
  await this.completeIntent(transferId, payload);
11711
11714
  } catch {
11712
11715
  return;
11713
11716
  }
11714
- await this.finishBookkeeping(transferId, payload, "sent");
11717
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
11715
11718
  })();
11716
11719
  }
11717
11720
  async completeIntent(transferId, payload) {
11718
11721
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
11719
11722
  await this.deps.intents.complete(transferId, signature);
11720
11723
  }
11721
- async finishBookkeeping(transferId, payload, phase) {
11724
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
11722
11725
  try {
11723
- await this.deps.recordHistory?.({ transferId, payload, phase });
11726
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
11727
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
11724
11728
  } catch {
11725
11729
  }
11726
11730
  try {
@@ -11728,6 +11732,26 @@ var TransferMachine = class {
11728
11732
  } catch {
11729
11733
  }
11730
11734
  }
11735
+ /**
11736
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
11737
+ * certified recipient blobs (summarize()'s committed set is the only source;
11738
+ * a journal-rehydrated resume leg has no source token, but every committed
11739
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
11740
+ */
11741
+ async settledAmount(engine, coinId, committed) {
11742
+ let total = 0n;
11743
+ for (const outcome of committed) {
11744
+ if (outcome.recipientBlob === void 0) continue;
11745
+ const token = await engine.decodeToken({
11746
+ v: TOKEN_BLOB_VERSION,
11747
+ network: 0,
11748
+ tokenId: "",
11749
+ token: outcome.recipientBlob
11750
+ });
11751
+ total += engine.balanceOf(token, coinId);
11752
+ }
11753
+ return total.toString();
11754
+ }
11731
11755
  async disposeOnFailure(transferId, committed, cls) {
11732
11756
  if (committed.length > 0 || cls === "keep-open") return;
11733
11757
  try {
@@ -11752,7 +11776,7 @@ function conflictAmount(engine, payload, op, source) {
11752
11776
  }
11753
11777
 
11754
11778
  // modules/payments-v2/machine/resume.ts
11755
- async function resumeAll(deps) {
11779
+ async function resumeAll(deps, options = {}) {
11756
11780
  const report = { resumed: [], conflicted: [], failed: [] };
11757
11781
  const stores = createMachineStores(deps.kv);
11758
11782
  const open = await deps.intents.listOpen();
@@ -11762,7 +11786,12 @@ async function resumeAll(deps) {
11762
11786
  } catch {
11763
11787
  return report;
11764
11788
  }
11765
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
11789
+ await dropStaleBackstops(
11790
+ stores,
11791
+ locals,
11792
+ new Set(open.map((i) => i.transferId)),
11793
+ options.isLocallyActive ?? (() => false)
11794
+ );
11766
11795
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
11767
11796
  const ctx = {
11768
11797
  deps,
@@ -11775,11 +11804,16 @@ async function resumeAll(deps) {
11775
11804
  }
11776
11805
  return report;
11777
11806
  }
11778
- async function dropStaleLocalAborts(stores, locals, openIds) {
11807
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
11779
11808
  for (const entry of locals.values()) {
11780
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
11809
+ if (openIds.has(entry.transferId)) continue;
11810
+ if (entry.disposition !== "open") {
11781
11811
  await stores.backstop.removeByKey(entry.transferId);
11812
+ continue;
11782
11813
  }
11814
+ if (isLocallyActive(entry.transferId)) continue;
11815
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
11816
+ await stores.backstop.removeByKey(entry.transferId);
11783
11817
  }
11784
11818
  }
11785
11819
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -11987,7 +12021,9 @@ var Converger = class {
11987
12021
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
11988
12022
  }
11989
12023
  async resumePass() {
11990
- const report = await resumeAll(this.filteredMachineDeps());
12024
+ const report = await resumeAll(this.filteredMachineDeps(), {
12025
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
12026
+ });
11991
12027
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
11992
12028
  await this.deps.reconcile({
11993
12029
  resumed: report.resumed,
@@ -12103,6 +12139,136 @@ function distinctOpCount(entries) {
12103
12139
  return new Set(entries.map((entry) => entry.opIndex)).size;
12104
12140
  }
12105
12141
 
12142
+ // modules/payments-v2/restore.ts
12143
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
12144
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
12145
+ async function reseedAndReset(deps) {
12146
+ for (const entry of await deps.stores.backstop.list()) {
12147
+ if (entry.disposition !== "open") continue;
12148
+ try {
12149
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
12150
+ if (entry.requiresSeedClose) {
12151
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
12152
+ }
12153
+ } catch (err) {
12154
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
12155
+ }
12156
+ }
12157
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
12158
+ await deps.fullRePull();
12159
+ }
12160
+ async function splitOpIndex(deps, envelope) {
12161
+ const payload = await deps.decryptPayload(envelope);
12162
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
12163
+ }
12164
+
12165
+ // modules/payments-v2/inventory/presentation.ts
12166
+ function toToken(tokenId, snapshot, asset, registry, flags) {
12167
+ const iconUrl = registry.getIconUrl(asset.coinId);
12168
+ return {
12169
+ id: tokenId,
12170
+ coinId: asset.coinId,
12171
+ symbol: registry.getSymbol(asset.coinId),
12172
+ name: registry.getName(asset.coinId),
12173
+ decimals: registry.getDecimals(asset.coinId),
12174
+ ...iconUrl !== null ? { iconUrl } : {},
12175
+ amount: asset.amount,
12176
+ status: flags.transferring ? "transferring" : "confirmed",
12177
+ createdAt: snapshot.createdAt,
12178
+ updatedAt: snapshot.updatedAt,
12179
+ lazy: true,
12180
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12181
+ };
12182
+ }
12183
+ function aggregateAssets(entries, isTransferring, registry) {
12184
+ const groups = /* @__PURE__ */ new Map();
12185
+ for (const [tokenId, entry] of entries) {
12186
+ const moving = isTransferring(tokenId);
12187
+ for (const asset of entry.assets) {
12188
+ let group = groups.get(asset.coinId);
12189
+ if (!group) {
12190
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12191
+ groups.set(asset.coinId, group);
12192
+ }
12193
+ if (moving) {
12194
+ group.transferring += BigInt(asset.amount);
12195
+ group.transferringCount += 1;
12196
+ } else {
12197
+ group.total += BigInt(asset.amount);
12198
+ group.count += 1;
12199
+ }
12200
+ }
12201
+ }
12202
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12203
+ }
12204
+ function toAsset(coinId, totals, registry) {
12205
+ const iconUrl = registry.getIconUrl(coinId);
12206
+ return {
12207
+ coinId,
12208
+ symbol: registry.getSymbol(coinId),
12209
+ name: registry.getName(coinId),
12210
+ decimals: registry.getDecimals(coinId),
12211
+ ...iconUrl !== null ? { iconUrl } : {},
12212
+ totalAmount: totals.total.toString(),
12213
+ tokenCount: totals.count,
12214
+ confirmedAmount: totals.total.toString(),
12215
+ unconfirmedAmount: "0",
12216
+ confirmedTokenCount: totals.count,
12217
+ unconfirmedTokenCount: 0,
12218
+ transferringTokenCount: totals.transferringCount,
12219
+ transferringAmount: totals.transferring.toString(),
12220
+ priceUsd: null,
12221
+ priceEur: null,
12222
+ change24h: null,
12223
+ fiatValueUsd: null,
12224
+ fiatValueEur: null
12225
+ };
12226
+ }
12227
+ async function withPrices(raw, registry, price) {
12228
+ try {
12229
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12230
+ const quotes = await price.getPrices(names);
12231
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12232
+ } catch {
12233
+ return raw;
12234
+ }
12235
+ }
12236
+ function priceOne(asset, quote) {
12237
+ if (!quote) return asset;
12238
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12239
+ return {
12240
+ ...asset,
12241
+ priceUsd: quote.priceUsd,
12242
+ priceEur: quote.priceEur ?? null,
12243
+ change24h: quote.change24h ?? null,
12244
+ fiatValueUsd: human * quote.priceUsd,
12245
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12246
+ };
12247
+ }
12248
+
12249
+ // modules/payments-v2/machine/payload.ts
12250
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12251
+ return {
12252
+ v: 2,
12253
+ recipient: recipientPubkey,
12254
+ coinId: request.coinId,
12255
+ amount: request.amount,
12256
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12257
+ direct: [...spend.plan.direct],
12258
+ ...spend.plan.split !== void 0 ? {
12259
+ split: {
12260
+ tokenId: spend.plan.split.tokenId,
12261
+ splitAmount: spend.plan.split.splitAmount.toString(),
12262
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12263
+ }
12264
+ } : {},
12265
+ spentStates
12266
+ };
12267
+ }
12268
+ function messageOf(err) {
12269
+ return err instanceof Error ? err.message : String(err);
12270
+ }
12271
+
12106
12272
  // modules/payments-v2/history/History.ts
12107
12273
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
12108
12274
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -12161,13 +12327,14 @@ var History = class {
12161
12327
  }
12162
12328
  // ── internals ────────────────────────────────────────────────────────────────
12163
12329
  async post(build) {
12330
+ const record = build();
12164
12331
  try {
12165
- await this.deps.client.postHistory([build()]);
12332
+ await this.deps.client.postHistory([record]);
12166
12333
  } catch (err) {
12167
12334
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
12168
12335
  return;
12169
12336
  }
12170
- this.deps.emit("history:updated");
12337
+ this.deps.emit("history:updated", this.toEntry(record));
12171
12338
  }
12172
12339
  ts(timestamp) {
12173
12340
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -12224,93 +12391,9 @@ var History = class {
12224
12391
  }
12225
12392
  };
12226
12393
 
12227
- // modules/payments-v2/inventory/presentation.ts
12228
- function toToken(tokenId, snapshot, asset, registry, flags) {
12229
- const iconUrl = registry.getIconUrl(asset.coinId);
12230
- return {
12231
- id: tokenId,
12232
- coinId: asset.coinId,
12233
- symbol: registry.getSymbol(asset.coinId),
12234
- name: registry.getName(asset.coinId),
12235
- decimals: registry.getDecimals(asset.coinId),
12236
- ...iconUrl !== null ? { iconUrl } : {},
12237
- amount: asset.amount,
12238
- status: flags.transferring ? "transferring" : "confirmed",
12239
- createdAt: snapshot.createdAt,
12240
- updatedAt: snapshot.updatedAt,
12241
- lazy: true,
12242
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12243
- };
12244
- }
12245
- function aggregateAssets(entries, isTransferring, registry) {
12246
- const groups = /* @__PURE__ */ new Map();
12247
- for (const [tokenId, entry] of entries) {
12248
- const moving = isTransferring(tokenId);
12249
- for (const asset of entry.assets) {
12250
- let group = groups.get(asset.coinId);
12251
- if (!group) {
12252
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12253
- groups.set(asset.coinId, group);
12254
- }
12255
- if (moving) {
12256
- group.transferring += BigInt(asset.amount);
12257
- group.transferringCount += 1;
12258
- } else {
12259
- group.total += BigInt(asset.amount);
12260
- group.count += 1;
12261
- }
12262
- }
12263
- }
12264
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12265
- }
12266
- function toAsset(coinId, totals, registry) {
12267
- const iconUrl = registry.getIconUrl(coinId);
12268
- return {
12269
- coinId,
12270
- symbol: registry.getSymbol(coinId),
12271
- name: registry.getName(coinId),
12272
- decimals: registry.getDecimals(coinId),
12273
- ...iconUrl !== null ? { iconUrl } : {},
12274
- totalAmount: totals.total.toString(),
12275
- tokenCount: totals.count,
12276
- confirmedAmount: totals.total.toString(),
12277
- unconfirmedAmount: "0",
12278
- confirmedTokenCount: totals.count,
12279
- unconfirmedTokenCount: 0,
12280
- transferringTokenCount: totals.transferringCount,
12281
- transferringAmount: totals.transferring.toString(),
12282
- priceUsd: null,
12283
- priceEur: null,
12284
- change24h: null,
12285
- fiatValueUsd: null,
12286
- fiatValueEur: null
12287
- };
12288
- }
12289
- async function withPrices(raw, registry, price) {
12290
- try {
12291
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12292
- const quotes = await price.getPrices(names);
12293
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12294
- } catch {
12295
- return raw;
12296
- }
12297
- }
12298
- function priceOne(asset, quote) {
12299
- if (!quote) return asset;
12300
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12301
- return {
12302
- ...asset,
12303
- priceUsd: quote.priceUsd,
12304
- priceEur: quote.priceEur ?? null,
12305
- change24h: quote.change24h ?? null,
12306
- fiatValueUsd: human * quote.priceUsd,
12307
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12308
- };
12309
- }
12310
-
12311
12394
  // modules/payments-v2/inventory/InventoryView.ts
12312
- var SUSPECTED_KEY = "suspected-spent";
12313
- var KNOWN_SPENDS_KEY = "known-spends";
12395
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
12396
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
12314
12397
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12315
12398
  function stateKey(tokenId, stateHash) {
12316
12399
  return `${tokenId}:${stateHash}`;
@@ -12614,19 +12697,24 @@ var Receive = class {
12614
12697
  const engine = deps.engine();
12615
12698
  const stored = [];
12616
12699
  const pending = [];
12617
- let epoch = "";
12700
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
12618
12701
  try {
12619
- epoch = deps.syncEpoch();
12620
12702
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
12621
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
12622
- for await (const entry of deps.delivery.incoming(since)) {
12623
- await this.processEntry(deps, engine, entry, pending, stored);
12624
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
12703
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
12704
+ for (let pass = 0; pass < 2; pass++) {
12705
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
12706
+ await this.processEntry(deps, engine, entry, pending, stored);
12707
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
12708
+ }
12709
+ await flushAcks(deps, pending, pageEpoch);
12710
+ const served = deps.delivery.incomingEpoch();
12711
+ if (basis === null || served === null || served === basis.syncEpoch) break;
12712
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
12713
+ basis = null;
12625
12714
  }
12626
- await flushAcks(deps, pending, epoch);
12627
12715
  } catch (err) {
12628
12716
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
12629
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
12717
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12630
12718
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12631
12719
  });
12632
12720
  }
@@ -12699,7 +12787,7 @@ async function announce(deps, entry, record) {
12699
12787
  receivedAt
12700
12788
  };
12701
12789
  }
12702
- async function flushAcks(deps, pending, epoch) {
12790
+ async function flushAcks(deps, pending, epochOf) {
12703
12791
  let lastAcked = null;
12704
12792
  try {
12705
12793
  while (pending.length > 0) {
@@ -12710,7 +12798,7 @@ async function flushAcks(deps, pending, epoch) {
12710
12798
  }
12711
12799
  } finally {
12712
12800
  if (lastAcked !== null) {
12713
- const record = { cursor: lastAcked, syncEpoch: epoch };
12801
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
12714
12802
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
12715
12803
  }
12716
12804
  }
@@ -12811,9 +12899,19 @@ var Requests = class {
12811
12899
  }
12812
12900
  });
12813
12901
  }
12902
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
12814
12903
  writeLink(requestId, transferId, committed) {
12815
12904
  return this.mutateJournal((m) => {
12816
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
12905
+ const prev = m.get(requestId);
12906
+ const same = prev !== void 0 && prev.transferId === transferId;
12907
+ const next = {
12908
+ requestId,
12909
+ transferId,
12910
+ committed: committed || same && prev.committed,
12911
+ createdAt: same ? prev.createdAt : this.now()
12912
+ };
12913
+ if (same && prev.committed === next.committed) return false;
12914
+ m.set(requestId, next);
12817
12915
  return true;
12818
12916
  });
12819
12917
  }
@@ -12966,23 +13064,46 @@ var Requests = class {
12966
13064
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
12967
13065
  if (transferId !== void 0) {
12968
13066
  const committed = error instanceof PartialSendConflictError;
12969
- await this.writeLink(id, transferId, committed);
12970
- this.setStatus(id, "settling");
13067
+ await this.settle(id, transferId, { committed, respond: false });
12971
13068
  } else {
12972
13069
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
12973
13070
  this.setStatus(id, "paid");
12974
13071
  }
12975
13072
  } else {
12976
- await this.clearLink(id);
12977
- this.setStatus(id, "pending");
13073
+ await this.revertPayable(id);
12978
13074
  }
12979
13075
  throw error;
12980
13076
  }
12981
- this.setStatus(id, "paid");
12982
- await this.respondPaid(id, result.id);
12983
- await this.clearLink(id);
13077
+ await this.settle(id, result.id, { committed: true, respond: true });
12984
13078
  return result;
12985
13079
  }
13080
+ /**
13081
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
13082
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
13083
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
13084
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
13085
+ * not a reload. Every path that binds a request to a transfer outcome funnels
13086
+ * through here — pay()'s clean success (respond now), pay()'s
13087
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
13088
+ * — so a failed respond always leaves the link + 'settling' and the next
13089
+ * reconcile pass (the committed-link override) retries the respond.
13090
+ */
13091
+ async settle(requestId, transferId, opts) {
13092
+ let journalDown = false;
13093
+ try {
13094
+ await this.writeLink(requestId, transferId, opts.committed);
13095
+ } catch (err) {
13096
+ if (!opts.respond) throw err;
13097
+ journalDown = true;
13098
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
13099
+ }
13100
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
13101
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
13102
+ return;
13103
+ }
13104
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
13105
+ this.setStatus(requestId, "paid");
13106
+ }
12986
13107
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
12987
13108
  async respondPaid(id, transferId) {
12988
13109
  try {
@@ -13018,32 +13139,32 @@ var Requests = class {
13018
13139
  let aborted = null;
13019
13140
  for (const [requestId, link] of [...this.journal]) {
13020
13141
  if (link.committed || resumed.has(link.transferId)) {
13021
- await this.resolvePaid(requestId, link.transferId);
13142
+ await this.resolvePaid(requestId, link);
13022
13143
  } else if (conflicted.has(link.transferId)) {
13023
13144
  await this.revertPayable(requestId);
13024
13145
  } else if (open.has(link.transferId)) {
13025
13146
  } else {
13026
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
13147
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
13027
13148
  }
13028
13149
  }
13029
13150
  }
13030
13151
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
13031
13152
  // a probe failure defers the link to the next session.
13032
- async reconcileUnaccounted(requestId, transferId, aborted) {
13153
+ async reconcileUnaccounted(requestId, link, aborted) {
13033
13154
  try {
13034
13155
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
13035
- if (aborted.has(transferId)) await this.revertPayable(requestId);
13036
- else await this.resolvePaid(requestId, transferId);
13156
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
13157
+ else await this.resolvePaid(requestId, link);
13037
13158
  } catch (err) {
13038
13159
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
13039
13160
  }
13040
13161
  return aborted;
13041
13162
  }
13042
- async resolvePaid(requestId, transferId) {
13043
- if (!await this.respondPaid(requestId, transferId)) return;
13044
- await this.clearLink(requestId);
13045
- this.setStatus(requestId, "paid");
13163
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
13164
+ resolvePaid(requestId, link) {
13165
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
13046
13166
  }
13167
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
13047
13168
  async revertPayable(requestId) {
13048
13169
  await this.clearLink(requestId);
13049
13170
  this.setStatus(requestId, "pending");
@@ -13367,11 +13488,12 @@ function composeFacadeParts(deps, hooks) {
13367
13488
  client: deps.client,
13368
13489
  fieldKey: deps.fieldKey,
13369
13490
  registry: deps.registry,
13370
- emit: (event) => deps.emit(event, {}),
13491
+ emit: (event, entry) => deps.emit(event, entry),
13371
13492
  ...deps.now !== void 0 ? { now: deps.now } : {},
13372
13493
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
13373
13494
  });
13374
13495
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13496
+ const machineStores = createMachineStores(deps.kv);
13375
13497
  const heldStates = /* @__PURE__ */ new Map();
13376
13498
  return {
13377
13499
  ownPubkeyBytes,
@@ -13379,12 +13501,29 @@ function composeFacadeParts(deps, hooks) {
13379
13501
  ledger,
13380
13502
  queue,
13381
13503
  historyStore,
13382
- machineStores: createMachineStores(deps.kv),
13504
+ machineStores,
13383
13505
  machineDeps,
13384
13506
  machine: new TransferMachine(machineDeps),
13385
13507
  heldStates,
13386
13508
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
13387
- requests: buildRequests(deps, hooks)
13509
+ requests: buildRequests(deps, hooks),
13510
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13511
+ };
13512
+ }
13513
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13514
+ return {
13515
+ stores: machineStores,
13516
+ kv: deps.kv,
13517
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
13518
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
13519
+ decryptPayload: machineDeps.decryptPayload,
13520
+ fullRePull: () => view.onEpochReset(),
13521
+ attention: (transferId, code, detail) => {
13522
+ deps.emit(
13523
+ "transfer:attention",
13524
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
13525
+ );
13526
+ }
13388
13527
  };
13389
13528
  }
13390
13529
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -13406,11 +13545,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
13406
13545
  ownPubkey: hexToBytes4(deps.ownPubkey),
13407
13546
  emit: deps.emit,
13408
13547
  now: deps.now ?? Date.now,
13409
- recordHistory: async ({ transferId, payload }) => {
13548
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
13410
13549
  await historyStore.recordSent({
13411
13550
  transferId,
13412
13551
  coinId: payload.coinId,
13413
- amount: payload.amount,
13552
+ // §5.9: the SETTLED amount (machine-computed from the certified
13553
+ // recipient blobs), never payload.amount — the plan.
13554
+ amount: committedAmount,
13414
13555
  recipientPubkey: payload.recipient,
13415
13556
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
13416
13557
  });
@@ -13437,7 +13578,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
13437
13578
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
13438
13579
  );
13439
13580
  },
13440
- syncEpoch: deps.syncEpoch ?? (() => ""),
13581
+ syncEpoch: deps.syncEpoch,
13441
13582
  ...deps.now !== void 0 ? { now: deps.now } : {}
13442
13583
  });
13443
13584
  }
@@ -13490,6 +13631,7 @@ var PaymentsFacade = class {
13490
13631
  this.receiveLoop = parts.receiveLoop;
13491
13632
  this.heldStates = parts.heldStates;
13492
13633
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13634
+ this.restoreDeps = parts.restoreDeps;
13493
13635
  this.requests = parts.requests;
13494
13636
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13495
13637
  this.heartbeat = new ConvergenceHeartbeat({
@@ -13518,6 +13660,7 @@ var PaymentsFacade = class {
13518
13660
  receiveLoop;
13519
13661
  heldStates;
13520
13662
  ownPubkeyBytes;
13663
+ restoreDeps;
13521
13664
  requests;
13522
13665
  currentEngine = null;
13523
13666
  pendingOps = /* @__PURE__ */ new Set();
@@ -13528,6 +13671,8 @@ var PaymentsFacade = class {
13528
13671
  converger;
13529
13672
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
13530
13673
  resumeFlight = new SingleFlight();
13674
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
13675
+ passChain = new SerialChain();
13531
13676
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13532
13677
  activeMoneyOps = /* @__PURE__ */ new Set();
13533
13678
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -13535,8 +13680,8 @@ var PaymentsFacade = class {
13535
13680
  if (this.started) return;
13536
13681
  this.started = true;
13537
13682
  this.heartbeat.start();
13538
- await this.deps.session.start();
13539
13683
  this.unsubscribers.push(
13684
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
13540
13685
  this.deps.session.subscribeStream("mailbox", () => {
13541
13686
  this.trackTail(this.receiveLoop.drainOnce());
13542
13687
  }),
@@ -13551,6 +13696,7 @@ var PaymentsFacade = class {
13551
13696
  if (status === "connected") this.heartbeat.recovered();
13552
13697
  });
13553
13698
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13699
+ await this.deps.session.start();
13554
13700
  this.trackTail(this.runConvergencePass());
13555
13701
  this.trackTail(this.seedHeldStates());
13556
13702
  await this.view.fullPull().catch(() => void 0);
@@ -13591,7 +13737,7 @@ var PaymentsFacade = class {
13591
13737
  }
13592
13738
  // ── money movement ─────────────────────────────────────────────────────────
13593
13739
  send(request) {
13594
- return this.track(this.sendWithPolicy(request));
13740
+ return this.track(this.sendOutcome(request));
13595
13741
  }
13596
13742
  async receive() {
13597
13743
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -13606,20 +13752,27 @@ var PaymentsFacade = class {
13606
13752
  if (!this.started) return;
13607
13753
  await this.track(this.runConvergencePass());
13608
13754
  }
13755
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
13756
+ async handleEpochChange(_newEpoch) {
13757
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
13758
+ await this.passChain.enqueue(() => this.convergeBody());
13759
+ }
13609
13760
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
13610
13761
  runConvergencePass() {
13611
- return this.resumeFlight.run(async () => {
13612
- const outcome = await this.converger.convergeOnce();
13613
- const pending = await this.converger.pendingWork().catch(() => true);
13614
- this.heartbeat.settle(outcome, pending);
13615
- });
13762
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
13763
+ }
13764
+ async convergeBody() {
13765
+ const outcome = await this.converger.convergeOnce();
13766
+ const pending = await this.converger.pendingWork().catch(() => true);
13767
+ this.heartbeat.settle(outcome, pending);
13616
13768
  }
13617
13769
  nowMs() {
13618
13770
  return (this.deps.now ?? Date.now)();
13619
13771
  }
13620
13772
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
13621
- async sendWithPolicy(request) {
13622
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
13773
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
13774
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
13775
+ async sendOutcome(request) {
13623
13776
  const run = {
13624
13777
  amount: request.amount,
13625
13778
  delivered: [],
@@ -13627,12 +13780,36 @@ var PaymentsFacade = class {
13627
13780
  tokenTransfers: [],
13628
13781
  settledShortfalls: [],
13629
13782
  firstPartialId: void 0,
13630
- pendingAny: false
13783
+ pendingAny: false,
13784
+ lastTransferId: ""
13631
13785
  };
13786
+ try {
13787
+ return await this.sendWithPolicy(request, run);
13788
+ } catch (err) {
13789
+ this.emitCleanFailure(err, run.lastTransferId);
13790
+ throw err;
13791
+ }
13792
+ }
13793
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
13794
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
13795
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
13796
+ emitCleanFailure(err, transferId) {
13797
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
13798
+ const failed = {
13799
+ id: transferId,
13800
+ status: "failed",
13801
+ tokens: [],
13802
+ tokenTransfers: [],
13803
+ error: messageOf(err)
13804
+ };
13805
+ this.deps.emit("transfer:updated", failed);
13806
+ }
13807
+ async sendWithPolicy(request, run) {
13808
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
13632
13809
  for (let attempt = 0; ; attempt++) {
13633
13810
  let ctx;
13634
13811
  try {
13635
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
13812
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
13636
13813
  } catch (err) {
13637
13814
  throw this.partialize(err, run);
13638
13815
  }
@@ -13677,11 +13854,8 @@ var PaymentsFacade = class {
13677
13854
  this.activeMoneyOps.delete(ctx.transferId);
13678
13855
  }
13679
13856
  }
13680
- /**
13681
- * Partial outcome (#677/#690): the shortfall is already durable (written by
13682
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
13683
- * the remainder under a NEW transferId — never the full amount.
13684
- */
13857
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
13858
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
13685
13859
  async consumePartial(ctx, disposition, run, attempt) {
13686
13860
  if (disposition.deliveryPending) {
13687
13861
  run.pendingAny = true;
@@ -13707,11 +13881,8 @@ var PaymentsFacade = class {
13707
13881
  run.amount = partial.remainingAmount;
13708
13882
  return null;
13709
13883
  }
13710
- /**
13711
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
13712
- * backstop still 'open' (committed>0) → converge via the same machine's
13713
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
13714
- */
13884
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
13885
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
13715
13886
  async disposeFailedAttempt(ctx, err) {
13716
13887
  if (isPossiblyCommittedSendOutcome(err)) {
13717
13888
  this.settleKeepOpen(ctx);
@@ -13760,8 +13931,9 @@ var PaymentsFacade = class {
13760
13931
  }
13761
13932
  return { kind: "rethrow", error: err };
13762
13933
  }
13763
- async planAndMaterialize(recipientPubkey, request) {
13934
+ async planAndMaterialize(recipientPubkey, request, run) {
13764
13935
  const transferId = this.newId();
13936
+ run.lastTransferId = transferId;
13765
13937
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
13766
13938
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
13767
13939
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -13893,11 +14065,7 @@ var PaymentsFacade = class {
13893
14065
  this.deps.emit("transfer:updated", result);
13894
14066
  return result;
13895
14067
  }
13896
- /**
13897
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
13898
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
13899
- * over the accumulated settled set — never bare, never a full-amount retry.
13900
- */
14068
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
13901
14069
  partialize(err, run) {
13902
14070
  if (run.delivered.length === 0) return err;
13903
14071
  return new PartialSendConflictError(
@@ -14049,22 +14217,14 @@ var PaymentsFacade = class {
14049
14217
  }
14050
14218
  }
14051
14219
  toUiToken(tokenId, coinId, amount) {
14052
- const registry = this.deps.registry;
14053
- const iconUrl = registry.getIconUrl(coinId);
14054
14220
  const now = this.nowMs();
14055
- return {
14056
- id: tokenId,
14057
- coinId,
14058
- symbol: registry.getSymbol(coinId),
14059
- name: registry.getName(coinId),
14060
- decimals: registry.getDecimals(coinId),
14061
- ...iconUrl !== null ? { iconUrl } : {},
14062
- amount: amount.toString(),
14063
- status: "transferring",
14064
- createdAt: now,
14065
- updatedAt: now,
14066
- lazy: true
14067
- };
14221
+ return toToken(
14222
+ tokenId,
14223
+ { assets: [], createdAt: now, updatedAt: now },
14224
+ { coinId, amount: amount.toString() },
14225
+ this.deps.registry,
14226
+ { transferring: true, suspectedSpent: false }
14227
+ );
14068
14228
  }
14069
14229
  track(op) {
14070
14230
  const settled = op.then(
@@ -14079,27 +14239,6 @@ var PaymentsFacade = class {
14079
14239
  this.track(op).catch(() => void 0);
14080
14240
  }
14081
14241
  };
14082
- function buildPayload(recipientPubkey, request, spend, spentStates) {
14083
- return {
14084
- v: 2,
14085
- recipient: recipientPubkey,
14086
- coinId: request.coinId,
14087
- amount: request.amount,
14088
- ...request.memo !== void 0 ? { memo: request.memo } : {},
14089
- direct: [...spend.plan.direct],
14090
- ...spend.plan.split !== void 0 ? {
14091
- split: {
14092
- tokenId: spend.plan.split.tokenId,
14093
- splitAmount: spend.plan.split.splitAmount.toString(),
14094
- remainderAmount: spend.plan.split.remainderAmount.toString()
14095
- }
14096
- } : {},
14097
- spentStates
14098
- };
14099
- }
14100
- function messageOf(err) {
14101
- return err instanceof Error ? err.message : String(err);
14102
- }
14103
14242
 
14104
14243
  // impl/wallet-api-v2/http.ts
14105
14244
  var WalletApiHttpError = class extends Error {
@@ -14445,6 +14584,7 @@ var WalletApiSession = class {
14445
14584
  epochLatch = null;
14446
14585
  reseedChain = Promise.resolve();
14447
14586
  reseedsPending = 0;
14587
+ epochHandlers = /* @__PURE__ */ new Set();
14448
14588
  handlers = /* @__PURE__ */ new Map();
14449
14589
  statusHandlers = /* @__PURE__ */ new Set();
14450
14590
  socket = null;
@@ -14547,6 +14687,18 @@ var WalletApiSession = class {
14547
14687
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
14548
14688
  }
14549
14689
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
14690
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
14691
+ currentEpoch() {
14692
+ return this.epochLatch ?? "";
14693
+ }
14694
+ /**
14695
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
14696
+ * the reseed chain, BEFORE any stream nudge resumes.
14697
+ */
14698
+ subscribeEpochChange(handler) {
14699
+ this.epochHandlers.add(handler);
14700
+ return () => this.epochHandlers.delete(handler);
14701
+ }
14550
14702
  noteEpoch(epoch) {
14551
14703
  if (this.epochLatch === epoch) return;
14552
14704
  const previous = this.epochLatch;
@@ -14560,7 +14712,7 @@ var WalletApiSession = class {
14560
14712
  }
14561
14713
  async runReseed(epoch, previous) {
14562
14714
  try {
14563
- await this.deps.onEpochChange(epoch);
14715
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
14564
14716
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
14565
14717
  } catch {
14566
14718
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -14822,6 +14974,7 @@ var WalletApiDeliveryPort = class {
14822
14974
  wake;
14823
14975
  deriveFn;
14824
14976
  seenChain = new SerialChain();
14977
+ lastIncomingEpoch = null;
14825
14978
  constructor(config) {
14826
14979
  this.client = config.client;
14827
14980
  this.kv = config.kv;
@@ -14916,6 +15069,7 @@ var WalletApiDeliveryPort = class {
14916
15069
  const seen = await this.readSeen();
14917
15070
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
14918
15071
  for (; ; ) {
15072
+ this.lastIncomingEpoch = String(page.syncEpoch);
14919
15073
  for (const entry of page.entries) {
14920
15074
  if (entry.status !== "unclaimed") continue;
14921
15075
  if (seen.has(entry.entryId)) continue;
@@ -14925,6 +15079,10 @@ var WalletApiDeliveryPort = class {
14925
15079
  page = await this.client.listMailbox(page.cursor);
14926
15080
  }
14927
15081
  }
15082
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
15083
+ incomingEpoch() {
15084
+ return this.lastIncomingEpoch;
15085
+ }
14928
15086
  toIncoming(entry) {
14929
15087
  const bundle = this.openEnvelope(entry);
14930
15088
  return {
@@ -15002,7 +15160,7 @@ var WalletApiSplitCheckpointStore = class {
15002
15160
  return utf8(`${transferId}:${String(opIndex)}`);
15003
15161
  }
15004
15162
  async put(transferId, opIndex, bytes) {
15005
- const { client, kv, fieldKey, signProgress } = this.config;
15163
+ const { kv, fieldKey } = this.config;
15006
15164
  const aad = this.aad(transferId, opIndex);
15007
15165
  const cacheKey = this.cacheKey(transferId, opIndex);
15008
15166
  let envelope = await kv.get(cacheKey);
@@ -15010,10 +15168,21 @@ var WalletApiSplitCheckpointStore = class {
15010
15168
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
15011
15169
  await kv.set(cacheKey, envelope);
15012
15170
  }
15013
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
15014
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
15171
+ const record = await this.post(transferId, opIndex, envelope);
15015
15172
  return this.open(record.payload, aad);
15016
15173
  }
15174
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
15175
+ async reseedCheckpoint(transferId, opIndex) {
15176
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
15177
+ if (envelope === null) return false;
15178
+ await this.post(transferId, opIndex, envelope);
15179
+ return true;
15180
+ }
15181
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
15182
+ async post(transferId, opIndex, envelope) {
15183
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
15184
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
15185
+ }
15017
15186
  async get(transferId, opIndex) {
15018
15187
  const records = await this.config.client.getProgress(transferId);
15019
15188
  const record = records.find((r) => r.opIndex === opIndex);
@@ -15036,7 +15205,7 @@ var WalletApiSplitCheckpointStore = class {
15036
15205
  };
15037
15206
 
15038
15207
  // core/payments-v2-wiring.ts
15039
- function resolvePaymentsV2Composition(walletApi) {
15208
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
15040
15209
  const source = walletApi;
15041
15210
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
15042
15211
  if (!source || network === null) {
@@ -15045,6 +15214,18 @@ function resolvePaymentsV2Composition(walletApi) {
15045
15214
  "INVALID_CONFIG"
15046
15215
  );
15047
15216
  }
15217
+ if (!(network in NETWORKS)) {
15218
+ throw new SphereError(
15219
+ `payments: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
15220
+ "INVALID_CONFIG"
15221
+ );
15222
+ }
15223
+ if (network !== sphereNetwork) {
15224
+ throw new SphereError(
15225
+ `payments: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
15226
+ "INVALID_CONFIG"
15227
+ );
15228
+ }
15048
15229
  if (typeof source.paymentsV2Transport === "function") {
15049
15230
  return { network, factory: source.paymentsV2Transport.bind(source) };
15050
15231
  }
@@ -15109,8 +15290,7 @@ function defaultPaymentsV2Transport(config) {
15109
15290
  deviceId: `${config.deviceId}:pv2`,
15110
15291
  kv: args.kv,
15111
15292
  webSocketFactory,
15112
- emitStatus: args.emitStatus,
15113
- onEpochChange: args.onEpochChange
15293
+ emitStatus: args.emitStatus
15114
15294
  });
15115
15295
  return { session, client: authedWireClient(session, client) };
15116
15296
  };
@@ -15180,8 +15360,7 @@ function composePaymentsV2(spec) {
15180
15360
  identity: identitySlice,
15181
15361
  network,
15182
15362
  kv,
15183
- emitStatus: (status) => host.emit("connection:status", { status }),
15184
- onEpochChange: async () => void 0
15363
+ emitStatus: (status) => host.emit("connection:status", { status })
15185
15364
  });
15186
15365
  const deps = {
15187
15366
  session: transport.session,
@@ -15211,7 +15390,10 @@ function composePaymentsV2(spec) {
15211
15390
  network,
15212
15391
  ownPubkey: identity.chainPubkey,
15213
15392
  ownNametag: host.nametag,
15214
- requestMemo: requestMemoCodec(identity.privateKey)
15393
+ requestMemo: requestMemoCodec(identity.privateKey),
15394
+ // §5.1: the session is the epoch authority — the facade registers its
15395
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
15396
+ syncEpoch: () => transport.session.currentEpoch()
15215
15397
  };
15216
15398
  return new PaymentsFacade(deps);
15217
15399
  }
@@ -15366,7 +15548,7 @@ var Sphere = class _Sphere {
15366
15548
  static async init(options) {
15367
15549
  if (options.debug) logger.configure({ debug: true });
15368
15550
  _Sphere.refuseRetiredModuleOptions(options);
15369
- resolvePaymentsV2Composition(options.walletApi);
15551
+ resolvePaymentsV2Composition(options.walletApi, options.network);
15370
15552
  _Sphere.configureTokenRegistry(options.storage, options.network);
15371
15553
  const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
15372
15554
  const market = _Sphere.resolveMarketConfig(options.market);
@@ -15508,7 +15690,7 @@ var Sphere = class _Sphere {
15508
15690
  static async create(options) {
15509
15691
  if (options.debug) logger.configure({ debug: true });
15510
15692
  _Sphere.refuseRetiredModuleOptions(options);
15511
- const composition = resolvePaymentsV2Composition(options.walletApi);
15693
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15512
15694
  if (!options.mnemonic || !_Sphere.validateMnemonic(options.mnemonic)) {
15513
15695
  throw new SphereError("Invalid mnemonic", "INVALID_IDENTITY");
15514
15696
  }
@@ -15576,7 +15758,7 @@ var Sphere = class _Sphere {
15576
15758
  static async load(options) {
15577
15759
  if (options.debug) logger.configure({ debug: true });
15578
15760
  _Sphere.refuseRetiredModuleOptions(options);
15579
- const composition = resolvePaymentsV2Composition(options.walletApi);
15761
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15580
15762
  if (!await _Sphere.exists(options.storage)) {
15581
15763
  throw new SphereError("No wallet found. Use Sphere.create() to create a new wallet.", "NOT_INITIALIZED");
15582
15764
  }
@@ -15630,7 +15812,7 @@ var Sphere = class _Sphere {
15630
15812
  static async import(options) {
15631
15813
  if (options.debug) logger.configure({ debug: true });
15632
15814
  _Sphere.refuseRetiredModuleOptions(options);
15633
- const composition = resolvePaymentsV2Composition(options.walletApi);
15815
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15634
15816
  if (!options.mnemonic && !options.masterKey) {
15635
15817
  throw new SphereError("Either mnemonic or masterKey is required", "INVALID_CONFIG");
15636
15818
  }