@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
package/dist/index.js CHANGED
@@ -11100,7 +11100,10 @@ var STORE_KEYS = {
11100
11100
  shortfalls: "shortfalls",
11101
11101
  settlingLinks: "settling",
11102
11102
  streamCursor: (s) => `cursor:${s}`,
11103
- epochLatch: "epoch-latch"
11103
+ epochLatch: "epoch-latch",
11104
+ // §5.2 InventoryView durable overlays (#625/#679).
11105
+ suspectedSpent: "suspected-spent",
11106
+ knownSpends: "known-spends"
11104
11107
  };
11105
11108
 
11106
11109
  // modules/payments-v2/machine/journal.ts
@@ -11350,7 +11353,7 @@ var TransferMachine = class {
11350
11353
  }
11351
11354
  if (cls === null) {
11352
11355
  await this.applyCommitted(engine, plan.transferId, committed);
11353
- this.closeTail(plan.transferId, plan.payload);
11356
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
11354
11357
  return { committed, deliveryPending };
11355
11358
  }
11356
11359
  if (cls === "conflict" && committed.length > 0) {
@@ -11376,10 +11379,10 @@ var TransferMachine = class {
11376
11379
  }
11377
11380
  await this.applyCommitted(engine, r.transferId, committed);
11378
11381
  if (conflicts.length > 0) {
11379
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
11382
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
11380
11383
  }
11381
11384
  await this.completeIntent(r.transferId, r.payload);
11382
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11385
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11383
11386
  return { deliveryPending, partial: null };
11384
11387
  }
11385
11388
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -11561,7 +11564,7 @@ var TransferMachine = class {
11561
11564
  }
11562
11565
  return added;
11563
11566
  }
11564
- async settlePartial(r, committed, conflicts) {
11567
+ async settlePartial(engine, r, committed, conflicts) {
11565
11568
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
11566
11569
  const entry = {
11567
11570
  transferId: r.transferId,
@@ -11573,26 +11576,27 @@ var TransferMachine = class {
11573
11576
  };
11574
11577
  await this.stores.shortfalls.upsert(entry);
11575
11578
  await this.completeIntent(r.transferId, r.payload);
11576
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
11579
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
11577
11580
  return entry;
11578
11581
  }
11579
- closeTail(transferId, payload) {
11582
+ closeTail(engine, transferId, payload, committed) {
11580
11583
  void (async () => {
11581
11584
  try {
11582
11585
  await this.completeIntent(transferId, payload);
11583
11586
  } catch {
11584
11587
  return;
11585
11588
  }
11586
- await this.finishBookkeeping(transferId, payload, "sent");
11589
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
11587
11590
  })();
11588
11591
  }
11589
11592
  async completeIntent(transferId, payload) {
11590
11593
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
11591
11594
  await this.deps.intents.complete(transferId, signature);
11592
11595
  }
11593
- async finishBookkeeping(transferId, payload, phase) {
11596
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
11594
11597
  try {
11595
- await this.deps.recordHistory?.({ transferId, payload, phase });
11598
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
11599
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
11596
11600
  } catch {
11597
11601
  }
11598
11602
  try {
@@ -11600,6 +11604,26 @@ var TransferMachine = class {
11600
11604
  } catch {
11601
11605
  }
11602
11606
  }
11607
+ /**
11608
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
11609
+ * certified recipient blobs (summarize()'s committed set is the only source;
11610
+ * a journal-rehydrated resume leg has no source token, but every committed
11611
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
11612
+ */
11613
+ async settledAmount(engine, coinId, committed) {
11614
+ let total = 0n;
11615
+ for (const outcome of committed) {
11616
+ if (outcome.recipientBlob === void 0) continue;
11617
+ const token = await engine.decodeToken({
11618
+ v: TOKEN_BLOB_VERSION,
11619
+ network: 0,
11620
+ tokenId: "",
11621
+ token: outcome.recipientBlob
11622
+ });
11623
+ total += engine.balanceOf(token, coinId);
11624
+ }
11625
+ return total.toString();
11626
+ }
11603
11627
  async disposeOnFailure(transferId, committed, cls) {
11604
11628
  if (committed.length > 0 || cls === "keep-open") return;
11605
11629
  try {
@@ -11624,7 +11648,7 @@ function conflictAmount(engine, payload, op, source) {
11624
11648
  }
11625
11649
 
11626
11650
  // modules/payments-v2/machine/resume.ts
11627
- async function resumeAll(deps) {
11651
+ async function resumeAll(deps, options = {}) {
11628
11652
  const report = { resumed: [], conflicted: [], failed: [] };
11629
11653
  const stores = createMachineStores(deps.kv);
11630
11654
  const open = await deps.intents.listOpen();
@@ -11634,7 +11658,12 @@ async function resumeAll(deps) {
11634
11658
  } catch {
11635
11659
  return report;
11636
11660
  }
11637
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
11661
+ await dropStaleBackstops(
11662
+ stores,
11663
+ locals,
11664
+ new Set(open.map((i) => i.transferId)),
11665
+ options.isLocallyActive ?? (() => false)
11666
+ );
11638
11667
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
11639
11668
  const ctx = {
11640
11669
  deps,
@@ -11647,11 +11676,16 @@ async function resumeAll(deps) {
11647
11676
  }
11648
11677
  return report;
11649
11678
  }
11650
- async function dropStaleLocalAborts(stores, locals, openIds) {
11679
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
11651
11680
  for (const entry of locals.values()) {
11652
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
11681
+ if (openIds.has(entry.transferId)) continue;
11682
+ if (entry.disposition !== "open") {
11653
11683
  await stores.backstop.removeByKey(entry.transferId);
11684
+ continue;
11654
11685
  }
11686
+ if (isLocallyActive(entry.transferId)) continue;
11687
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
11688
+ await stores.backstop.removeByKey(entry.transferId);
11655
11689
  }
11656
11690
  }
11657
11691
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -11859,7 +11893,9 @@ var Converger = class {
11859
11893
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
11860
11894
  }
11861
11895
  async resumePass() {
11862
- const report = await resumeAll(this.filteredMachineDeps());
11896
+ const report = await resumeAll(this.filteredMachineDeps(), {
11897
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
11898
+ });
11863
11899
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
11864
11900
  await this.deps.reconcile({
11865
11901
  resumed: report.resumed,
@@ -11975,6 +12011,136 @@ function distinctOpCount(entries) {
11975
12011
  return new Set(entries.map((entry) => entry.opIndex)).size;
11976
12012
  }
11977
12013
 
12014
+ // modules/payments-v2/restore.ts
12015
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
12016
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
12017
+ async function reseedAndReset(deps) {
12018
+ for (const entry of await deps.stores.backstop.list()) {
12019
+ if (entry.disposition !== "open") continue;
12020
+ try {
12021
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
12022
+ if (entry.requiresSeedClose) {
12023
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
12024
+ }
12025
+ } catch (err) {
12026
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
12027
+ }
12028
+ }
12029
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
12030
+ await deps.fullRePull();
12031
+ }
12032
+ async function splitOpIndex(deps, envelope) {
12033
+ const payload = await deps.decryptPayload(envelope);
12034
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
12035
+ }
12036
+
12037
+ // modules/payments-v2/inventory/presentation.ts
12038
+ function toToken(tokenId, snapshot, asset, registry, flags) {
12039
+ const iconUrl = registry.getIconUrl(asset.coinId);
12040
+ return {
12041
+ id: tokenId,
12042
+ coinId: asset.coinId,
12043
+ symbol: registry.getSymbol(asset.coinId),
12044
+ name: registry.getName(asset.coinId),
12045
+ decimals: registry.getDecimals(asset.coinId),
12046
+ ...iconUrl !== null ? { iconUrl } : {},
12047
+ amount: asset.amount,
12048
+ status: flags.transferring ? "transferring" : "confirmed",
12049
+ createdAt: snapshot.createdAt,
12050
+ updatedAt: snapshot.updatedAt,
12051
+ lazy: true,
12052
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12053
+ };
12054
+ }
12055
+ function aggregateAssets(entries, isTransferring, registry) {
12056
+ const groups = /* @__PURE__ */ new Map();
12057
+ for (const [tokenId, entry] of entries) {
12058
+ const moving = isTransferring(tokenId);
12059
+ for (const asset of entry.assets) {
12060
+ let group = groups.get(asset.coinId);
12061
+ if (!group) {
12062
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12063
+ groups.set(asset.coinId, group);
12064
+ }
12065
+ if (moving) {
12066
+ group.transferring += BigInt(asset.amount);
12067
+ group.transferringCount += 1;
12068
+ } else {
12069
+ group.total += BigInt(asset.amount);
12070
+ group.count += 1;
12071
+ }
12072
+ }
12073
+ }
12074
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12075
+ }
12076
+ function toAsset(coinId, totals, registry) {
12077
+ const iconUrl = registry.getIconUrl(coinId);
12078
+ return {
12079
+ coinId,
12080
+ symbol: registry.getSymbol(coinId),
12081
+ name: registry.getName(coinId),
12082
+ decimals: registry.getDecimals(coinId),
12083
+ ...iconUrl !== null ? { iconUrl } : {},
12084
+ totalAmount: totals.total.toString(),
12085
+ tokenCount: totals.count,
12086
+ confirmedAmount: totals.total.toString(),
12087
+ unconfirmedAmount: "0",
12088
+ confirmedTokenCount: totals.count,
12089
+ unconfirmedTokenCount: 0,
12090
+ transferringTokenCount: totals.transferringCount,
12091
+ transferringAmount: totals.transferring.toString(),
12092
+ priceUsd: null,
12093
+ priceEur: null,
12094
+ change24h: null,
12095
+ fiatValueUsd: null,
12096
+ fiatValueEur: null
12097
+ };
12098
+ }
12099
+ async function withPrices(raw, registry, price) {
12100
+ try {
12101
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12102
+ const quotes = await price.getPrices(names);
12103
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12104
+ } catch {
12105
+ return raw;
12106
+ }
12107
+ }
12108
+ function priceOne(asset, quote) {
12109
+ if (!quote) return asset;
12110
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12111
+ return {
12112
+ ...asset,
12113
+ priceUsd: quote.priceUsd,
12114
+ priceEur: quote.priceEur ?? null,
12115
+ change24h: quote.change24h ?? null,
12116
+ fiatValueUsd: human * quote.priceUsd,
12117
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12118
+ };
12119
+ }
12120
+
12121
+ // modules/payments-v2/machine/payload.ts
12122
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
12123
+ return {
12124
+ v: 2,
12125
+ recipient: recipientPubkey,
12126
+ coinId: request.coinId,
12127
+ amount: request.amount,
12128
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
12129
+ direct: [...spend.plan.direct],
12130
+ ...spend.plan.split !== void 0 ? {
12131
+ split: {
12132
+ tokenId: spend.plan.split.tokenId,
12133
+ splitAmount: spend.plan.split.splitAmount.toString(),
12134
+ remainderAmount: spend.plan.split.remainderAmount.toString()
12135
+ }
12136
+ } : {},
12137
+ spentStates
12138
+ };
12139
+ }
12140
+ function messageOf(err) {
12141
+ return err instanceof Error ? err.message : String(err);
12142
+ }
12143
+
11978
12144
  // modules/payments-v2/history/History.ts
11979
12145
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
11980
12146
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -12033,13 +12199,14 @@ var History = class {
12033
12199
  }
12034
12200
  // ── internals ────────────────────────────────────────────────────────────────
12035
12201
  async post(build) {
12202
+ const record = build();
12036
12203
  try {
12037
- await this.deps.client.postHistory([build()]);
12204
+ await this.deps.client.postHistory([record]);
12038
12205
  } catch (err) {
12039
12206
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
12040
12207
  return;
12041
12208
  }
12042
- this.deps.emit("history:updated");
12209
+ this.deps.emit("history:updated", this.toEntry(record));
12043
12210
  }
12044
12211
  ts(timestamp) {
12045
12212
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -12096,93 +12263,9 @@ var History = class {
12096
12263
  }
12097
12264
  };
12098
12265
 
12099
- // modules/payments-v2/inventory/presentation.ts
12100
- function toToken(tokenId, snapshot, asset, registry, flags) {
12101
- const iconUrl = registry.getIconUrl(asset.coinId);
12102
- return {
12103
- id: tokenId,
12104
- coinId: asset.coinId,
12105
- symbol: registry.getSymbol(asset.coinId),
12106
- name: registry.getName(asset.coinId),
12107
- decimals: registry.getDecimals(asset.coinId),
12108
- ...iconUrl !== null ? { iconUrl } : {},
12109
- amount: asset.amount,
12110
- status: flags.transferring ? "transferring" : "confirmed",
12111
- createdAt: snapshot.createdAt,
12112
- updatedAt: snapshot.updatedAt,
12113
- lazy: true,
12114
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
12115
- };
12116
- }
12117
- function aggregateAssets(entries, isTransferring, registry) {
12118
- const groups = /* @__PURE__ */ new Map();
12119
- for (const [tokenId, entry] of entries) {
12120
- const moving = isTransferring(tokenId);
12121
- for (const asset of entry.assets) {
12122
- let group = groups.get(asset.coinId);
12123
- if (!group) {
12124
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
12125
- groups.set(asset.coinId, group);
12126
- }
12127
- if (moving) {
12128
- group.transferring += BigInt(asset.amount);
12129
- group.transferringCount += 1;
12130
- } else {
12131
- group.total += BigInt(asset.amount);
12132
- group.count += 1;
12133
- }
12134
- }
12135
- }
12136
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
12137
- }
12138
- function toAsset(coinId, totals, registry) {
12139
- const iconUrl = registry.getIconUrl(coinId);
12140
- return {
12141
- coinId,
12142
- symbol: registry.getSymbol(coinId),
12143
- name: registry.getName(coinId),
12144
- decimals: registry.getDecimals(coinId),
12145
- ...iconUrl !== null ? { iconUrl } : {},
12146
- totalAmount: totals.total.toString(),
12147
- tokenCount: totals.count,
12148
- confirmedAmount: totals.total.toString(),
12149
- unconfirmedAmount: "0",
12150
- confirmedTokenCount: totals.count,
12151
- unconfirmedTokenCount: 0,
12152
- transferringTokenCount: totals.transferringCount,
12153
- transferringAmount: totals.transferring.toString(),
12154
- priceUsd: null,
12155
- priceEur: null,
12156
- change24h: null,
12157
- fiatValueUsd: null,
12158
- fiatValueEur: null
12159
- };
12160
- }
12161
- async function withPrices(raw, registry, price) {
12162
- try {
12163
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12164
- const quotes = await price.getPrices(names);
12165
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12166
- } catch {
12167
- return raw;
12168
- }
12169
- }
12170
- function priceOne(asset, quote) {
12171
- if (!quote) return asset;
12172
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
12173
- return {
12174
- ...asset,
12175
- priceUsd: quote.priceUsd,
12176
- priceEur: quote.priceEur ?? null,
12177
- change24h: quote.change24h ?? null,
12178
- fiatValueUsd: human * quote.priceUsd,
12179
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
12180
- };
12181
- }
12182
-
12183
12266
  // modules/payments-v2/inventory/InventoryView.ts
12184
- var SUSPECTED_KEY = "suspected-spent";
12185
- var KNOWN_SPENDS_KEY = "known-spends";
12267
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
12268
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
12186
12269
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
12187
12270
  function stateKey(tokenId, stateHash) {
12188
12271
  return `${tokenId}:${stateHash}`;
@@ -12486,19 +12569,24 @@ var Receive = class {
12486
12569
  const engine = deps.engine();
12487
12570
  const stored = [];
12488
12571
  const pending = [];
12489
- let epoch = "";
12572
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
12490
12573
  try {
12491
- epoch = deps.syncEpoch();
12492
12574
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
12493
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
12494
- for await (const entry of deps.delivery.incoming(since)) {
12495
- await this.processEntry(deps, engine, entry, pending, stored);
12496
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
12575
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
12576
+ for (let pass = 0; pass < 2; pass++) {
12577
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
12578
+ await this.processEntry(deps, engine, entry, pending, stored);
12579
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
12580
+ }
12581
+ await flushAcks(deps, pending, pageEpoch);
12582
+ const served = deps.delivery.incomingEpoch();
12583
+ if (basis === null || served === null || served === basis.syncEpoch) break;
12584
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
12585
+ basis = null;
12497
12586
  }
12498
- await flushAcks(deps, pending, epoch);
12499
12587
  } catch (err) {
12500
12588
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
12501
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
12589
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12502
12590
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12503
12591
  });
12504
12592
  }
@@ -12571,7 +12659,7 @@ async function announce(deps, entry, record) {
12571
12659
  receivedAt
12572
12660
  };
12573
12661
  }
12574
- async function flushAcks(deps, pending, epoch) {
12662
+ async function flushAcks(deps, pending, epochOf) {
12575
12663
  let lastAcked = null;
12576
12664
  try {
12577
12665
  while (pending.length > 0) {
@@ -12582,7 +12670,7 @@ async function flushAcks(deps, pending, epoch) {
12582
12670
  }
12583
12671
  } finally {
12584
12672
  if (lastAcked !== null) {
12585
- const record = { cursor: lastAcked, syncEpoch: epoch };
12673
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
12586
12674
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
12587
12675
  }
12588
12676
  }
@@ -12683,9 +12771,19 @@ var Requests = class {
12683
12771
  }
12684
12772
  });
12685
12773
  }
12774
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
12686
12775
  writeLink(requestId, transferId, committed) {
12687
12776
  return this.mutateJournal((m) => {
12688
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
12777
+ const prev = m.get(requestId);
12778
+ const same = prev !== void 0 && prev.transferId === transferId;
12779
+ const next = {
12780
+ requestId,
12781
+ transferId,
12782
+ committed: committed || same && prev.committed,
12783
+ createdAt: same ? prev.createdAt : this.now()
12784
+ };
12785
+ if (same && prev.committed === next.committed) return false;
12786
+ m.set(requestId, next);
12689
12787
  return true;
12690
12788
  });
12691
12789
  }
@@ -12838,23 +12936,46 @@ var Requests = class {
12838
12936
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
12839
12937
  if (transferId !== void 0) {
12840
12938
  const committed = error instanceof PartialSendConflictError;
12841
- await this.writeLink(id, transferId, committed);
12842
- this.setStatus(id, "settling");
12939
+ await this.settle(id, transferId, { committed, respond: false });
12843
12940
  } else {
12844
12941
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
12845
12942
  this.setStatus(id, "paid");
12846
12943
  }
12847
12944
  } else {
12848
- await this.clearLink(id);
12849
- this.setStatus(id, "pending");
12945
+ await this.revertPayable(id);
12850
12946
  }
12851
12947
  throw error;
12852
12948
  }
12853
- this.setStatus(id, "paid");
12854
- await this.respondPaid(id, result.id);
12855
- await this.clearLink(id);
12949
+ await this.settle(id, result.id, { committed: true, respond: true });
12856
12950
  return result;
12857
12951
  }
12952
+ /**
12953
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
12954
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
12955
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
12956
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
12957
+ * not a reload. Every path that binds a request to a transfer outcome funnels
12958
+ * through here — pay()'s clean success (respond now), pay()'s
12959
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
12960
+ * — so a failed respond always leaves the link + 'settling' and the next
12961
+ * reconcile pass (the committed-link override) retries the respond.
12962
+ */
12963
+ async settle(requestId, transferId, opts) {
12964
+ let journalDown = false;
12965
+ try {
12966
+ await this.writeLink(requestId, transferId, opts.committed);
12967
+ } catch (err) {
12968
+ if (!opts.respond) throw err;
12969
+ journalDown = true;
12970
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
12971
+ }
12972
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
12973
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
12974
+ return;
12975
+ }
12976
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
12977
+ this.setStatus(requestId, "paid");
12978
+ }
12858
12979
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
12859
12980
  async respondPaid(id, transferId) {
12860
12981
  try {
@@ -12890,32 +13011,32 @@ var Requests = class {
12890
13011
  let aborted = null;
12891
13012
  for (const [requestId, link] of [...this.journal]) {
12892
13013
  if (link.committed || resumed.has(link.transferId)) {
12893
- await this.resolvePaid(requestId, link.transferId);
13014
+ await this.resolvePaid(requestId, link);
12894
13015
  } else if (conflicted.has(link.transferId)) {
12895
13016
  await this.revertPayable(requestId);
12896
13017
  } else if (open.has(link.transferId)) {
12897
13018
  } else {
12898
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
13019
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
12899
13020
  }
12900
13021
  }
12901
13022
  }
12902
13023
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
12903
13024
  // a probe failure defers the link to the next session.
12904
- async reconcileUnaccounted(requestId, transferId, aborted) {
13025
+ async reconcileUnaccounted(requestId, link, aborted) {
12905
13026
  try {
12906
13027
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
12907
- if (aborted.has(transferId)) await this.revertPayable(requestId);
12908
- else await this.resolvePaid(requestId, transferId);
13028
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
13029
+ else await this.resolvePaid(requestId, link);
12909
13030
  } catch (err) {
12910
13031
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
12911
13032
  }
12912
13033
  return aborted;
12913
13034
  }
12914
- async resolvePaid(requestId, transferId) {
12915
- if (!await this.respondPaid(requestId, transferId)) return;
12916
- await this.clearLink(requestId);
12917
- this.setStatus(requestId, "paid");
13035
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
13036
+ resolvePaid(requestId, link) {
13037
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
12918
13038
  }
13039
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
12919
13040
  async revertPayable(requestId) {
12920
13041
  await this.clearLink(requestId);
12921
13042
  this.setStatus(requestId, "pending");
@@ -13239,11 +13360,12 @@ function composeFacadeParts(deps, hooks) {
13239
13360
  client: deps.client,
13240
13361
  fieldKey: deps.fieldKey,
13241
13362
  registry: deps.registry,
13242
- emit: (event) => deps.emit(event, {}),
13363
+ emit: (event, entry) => deps.emit(event, entry),
13243
13364
  ...deps.now !== void 0 ? { now: deps.now } : {},
13244
13365
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
13245
13366
  });
13246
13367
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13368
+ const machineStores = createMachineStores(deps.kv);
13247
13369
  const heldStates = /* @__PURE__ */ new Map();
13248
13370
  return {
13249
13371
  ownPubkeyBytes,
@@ -13251,12 +13373,29 @@ function composeFacadeParts(deps, hooks) {
13251
13373
  ledger,
13252
13374
  queue,
13253
13375
  historyStore,
13254
- machineStores: createMachineStores(deps.kv),
13376
+ machineStores,
13255
13377
  machineDeps,
13256
13378
  machine: new TransferMachine(machineDeps),
13257
13379
  heldStates,
13258
13380
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
13259
- requests: buildRequests(deps, hooks)
13381
+ requests: buildRequests(deps, hooks),
13382
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13383
+ };
13384
+ }
13385
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13386
+ return {
13387
+ stores: machineStores,
13388
+ kv: deps.kv,
13389
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
13390
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
13391
+ decryptPayload: machineDeps.decryptPayload,
13392
+ fullRePull: () => view.onEpochReset(),
13393
+ attention: (transferId, code, detail) => {
13394
+ deps.emit(
13395
+ "transfer:attention",
13396
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
13397
+ );
13398
+ }
13260
13399
  };
13261
13400
  }
13262
13401
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -13278,11 +13417,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
13278
13417
  ownPubkey: hexToBytes4(deps.ownPubkey),
13279
13418
  emit: deps.emit,
13280
13419
  now: deps.now ?? Date.now,
13281
- recordHistory: async ({ transferId, payload }) => {
13420
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
13282
13421
  await historyStore.recordSent({
13283
13422
  transferId,
13284
13423
  coinId: payload.coinId,
13285
- amount: payload.amount,
13424
+ // §5.9: the SETTLED amount (machine-computed from the certified
13425
+ // recipient blobs), never payload.amount — the plan.
13426
+ amount: committedAmount,
13286
13427
  recipientPubkey: payload.recipient,
13287
13428
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
13288
13429
  });
@@ -13309,7 +13450,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
13309
13450
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
13310
13451
  );
13311
13452
  },
13312
- syncEpoch: deps.syncEpoch ?? (() => ""),
13453
+ syncEpoch: deps.syncEpoch,
13313
13454
  ...deps.now !== void 0 ? { now: deps.now } : {}
13314
13455
  });
13315
13456
  }
@@ -13362,6 +13503,7 @@ var PaymentsFacade = class {
13362
13503
  this.receiveLoop = parts.receiveLoop;
13363
13504
  this.heldStates = parts.heldStates;
13364
13505
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13506
+ this.restoreDeps = parts.restoreDeps;
13365
13507
  this.requests = parts.requests;
13366
13508
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13367
13509
  this.heartbeat = new ConvergenceHeartbeat({
@@ -13390,6 +13532,7 @@ var PaymentsFacade = class {
13390
13532
  receiveLoop;
13391
13533
  heldStates;
13392
13534
  ownPubkeyBytes;
13535
+ restoreDeps;
13393
13536
  requests;
13394
13537
  currentEngine = null;
13395
13538
  pendingOps = /* @__PURE__ */ new Set();
@@ -13400,6 +13543,8 @@ var PaymentsFacade = class {
13400
13543
  converger;
13401
13544
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
13402
13545
  resumeFlight = new SingleFlight();
13546
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
13547
+ passChain = new SerialChain();
13403
13548
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13404
13549
  activeMoneyOps = /* @__PURE__ */ new Set();
13405
13550
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -13407,8 +13552,8 @@ var PaymentsFacade = class {
13407
13552
  if (this.started) return;
13408
13553
  this.started = true;
13409
13554
  this.heartbeat.start();
13410
- await this.deps.session.start();
13411
13555
  this.unsubscribers.push(
13556
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
13412
13557
  this.deps.session.subscribeStream("mailbox", () => {
13413
13558
  this.trackTail(this.receiveLoop.drainOnce());
13414
13559
  }),
@@ -13423,6 +13568,7 @@ var PaymentsFacade = class {
13423
13568
  if (status === "connected") this.heartbeat.recovered();
13424
13569
  });
13425
13570
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13571
+ await this.deps.session.start();
13426
13572
  this.trackTail(this.runConvergencePass());
13427
13573
  this.trackTail(this.seedHeldStates());
13428
13574
  await this.view.fullPull().catch(() => void 0);
@@ -13463,7 +13609,7 @@ var PaymentsFacade = class {
13463
13609
  }
13464
13610
  // ── money movement ─────────────────────────────────────────────────────────
13465
13611
  send(request) {
13466
- return this.track(this.sendWithPolicy(request));
13612
+ return this.track(this.sendOutcome(request));
13467
13613
  }
13468
13614
  async receive() {
13469
13615
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -13478,20 +13624,27 @@ var PaymentsFacade = class {
13478
13624
  if (!this.started) return;
13479
13625
  await this.track(this.runConvergencePass());
13480
13626
  }
13627
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
13628
+ async handleEpochChange(_newEpoch) {
13629
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
13630
+ await this.passChain.enqueue(() => this.convergeBody());
13631
+ }
13481
13632
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
13482
13633
  runConvergencePass() {
13483
- return this.resumeFlight.run(async () => {
13484
- const outcome = await this.converger.convergeOnce();
13485
- const pending = await this.converger.pendingWork().catch(() => true);
13486
- this.heartbeat.settle(outcome, pending);
13487
- });
13634
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
13635
+ }
13636
+ async convergeBody() {
13637
+ const outcome = await this.converger.convergeOnce();
13638
+ const pending = await this.converger.pendingWork().catch(() => true);
13639
+ this.heartbeat.settle(outcome, pending);
13488
13640
  }
13489
13641
  nowMs() {
13490
13642
  return (this.deps.now ?? Date.now)();
13491
13643
  }
13492
13644
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
13493
- async sendWithPolicy(request) {
13494
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
13645
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
13646
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
13647
+ async sendOutcome(request) {
13495
13648
  const run = {
13496
13649
  amount: request.amount,
13497
13650
  delivered: [],
@@ -13499,12 +13652,36 @@ var PaymentsFacade = class {
13499
13652
  tokenTransfers: [],
13500
13653
  settledShortfalls: [],
13501
13654
  firstPartialId: void 0,
13502
- pendingAny: false
13655
+ pendingAny: false,
13656
+ lastTransferId: ""
13503
13657
  };
13658
+ try {
13659
+ return await this.sendWithPolicy(request, run);
13660
+ } catch (err) {
13661
+ this.emitCleanFailure(err, run.lastTransferId);
13662
+ throw err;
13663
+ }
13664
+ }
13665
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
13666
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
13667
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
13668
+ emitCleanFailure(err, transferId) {
13669
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
13670
+ const failed = {
13671
+ id: transferId,
13672
+ status: "failed",
13673
+ tokens: [],
13674
+ tokenTransfers: [],
13675
+ error: messageOf(err)
13676
+ };
13677
+ this.deps.emit("transfer:updated", failed);
13678
+ }
13679
+ async sendWithPolicy(request, run) {
13680
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
13504
13681
  for (let attempt = 0; ; attempt++) {
13505
13682
  let ctx;
13506
13683
  try {
13507
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
13684
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
13508
13685
  } catch (err) {
13509
13686
  throw this.partialize(err, run);
13510
13687
  }
@@ -13549,11 +13726,8 @@ var PaymentsFacade = class {
13549
13726
  this.activeMoneyOps.delete(ctx.transferId);
13550
13727
  }
13551
13728
  }
13552
- /**
13553
- * Partial outcome (#677/#690): the shortfall is already durable (written by
13554
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
13555
- * the remainder under a NEW transferId — never the full amount.
13556
- */
13729
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
13730
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
13557
13731
  async consumePartial(ctx, disposition, run, attempt) {
13558
13732
  if (disposition.deliveryPending) {
13559
13733
  run.pendingAny = true;
@@ -13579,11 +13753,8 @@ var PaymentsFacade = class {
13579
13753
  run.amount = partial.remainingAmount;
13580
13754
  return null;
13581
13755
  }
13582
- /**
13583
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
13584
- * backstop still 'open' (committed>0) → converge via the same machine's
13585
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
13586
- */
13756
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
13757
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
13587
13758
  async disposeFailedAttempt(ctx, err) {
13588
13759
  if (isPossiblyCommittedSendOutcome(err)) {
13589
13760
  this.settleKeepOpen(ctx);
@@ -13632,8 +13803,9 @@ var PaymentsFacade = class {
13632
13803
  }
13633
13804
  return { kind: "rethrow", error: err };
13634
13805
  }
13635
- async planAndMaterialize(recipientPubkey, request) {
13806
+ async planAndMaterialize(recipientPubkey, request, run) {
13636
13807
  const transferId = this.newId();
13808
+ run.lastTransferId = transferId;
13637
13809
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
13638
13810
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
13639
13811
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -13765,11 +13937,7 @@ var PaymentsFacade = class {
13765
13937
  this.deps.emit("transfer:updated", result);
13766
13938
  return result;
13767
13939
  }
13768
- /**
13769
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
13770
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
13771
- * over the accumulated settled set — never bare, never a full-amount retry.
13772
- */
13940
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
13773
13941
  partialize(err, run) {
13774
13942
  if (run.delivered.length === 0) return err;
13775
13943
  return new PartialSendConflictError(
@@ -13921,22 +14089,14 @@ var PaymentsFacade = class {
13921
14089
  }
13922
14090
  }
13923
14091
  toUiToken(tokenId, coinId, amount) {
13924
- const registry = this.deps.registry;
13925
- const iconUrl = registry.getIconUrl(coinId);
13926
14092
  const now = this.nowMs();
13927
- return {
13928
- id: tokenId,
13929
- coinId,
13930
- symbol: registry.getSymbol(coinId),
13931
- name: registry.getName(coinId),
13932
- decimals: registry.getDecimals(coinId),
13933
- ...iconUrl !== null ? { iconUrl } : {},
13934
- amount: amount.toString(),
13935
- status: "transferring",
13936
- createdAt: now,
13937
- updatedAt: now,
13938
- lazy: true
13939
- };
14093
+ return toToken(
14094
+ tokenId,
14095
+ { assets: [], createdAt: now, updatedAt: now },
14096
+ { coinId, amount: amount.toString() },
14097
+ this.deps.registry,
14098
+ { transferring: true, suspectedSpent: false }
14099
+ );
13940
14100
  }
13941
14101
  track(op) {
13942
14102
  const settled = op.then(
@@ -13951,27 +14111,6 @@ var PaymentsFacade = class {
13951
14111
  this.track(op).catch(() => void 0);
13952
14112
  }
13953
14113
  };
13954
- function buildPayload(recipientPubkey, request, spend, spentStates) {
13955
- return {
13956
- v: 2,
13957
- recipient: recipientPubkey,
13958
- coinId: request.coinId,
13959
- amount: request.amount,
13960
- ...request.memo !== void 0 ? { memo: request.memo } : {},
13961
- direct: [...spend.plan.direct],
13962
- ...spend.plan.split !== void 0 ? {
13963
- split: {
13964
- tokenId: spend.plan.split.tokenId,
13965
- splitAmount: spend.plan.split.splitAmount.toString(),
13966
- remainderAmount: spend.plan.split.remainderAmount.toString()
13967
- }
13968
- } : {},
13969
- spentStates
13970
- };
13971
- }
13972
- function messageOf(err) {
13973
- return err instanceof Error ? err.message : String(err);
13974
- }
13975
14114
 
13976
14115
  // impl/wallet-api-v2/http.ts
13977
14116
  var WalletApiHttpError = class extends Error {
@@ -14317,6 +14456,7 @@ var WalletApiSession = class {
14317
14456
  epochLatch = null;
14318
14457
  reseedChain = Promise.resolve();
14319
14458
  reseedsPending = 0;
14459
+ epochHandlers = /* @__PURE__ */ new Set();
14320
14460
  handlers = /* @__PURE__ */ new Map();
14321
14461
  statusHandlers = /* @__PURE__ */ new Set();
14322
14462
  socket = null;
@@ -14419,6 +14559,18 @@ var WalletApiSession = class {
14419
14559
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
14420
14560
  }
14421
14561
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
14562
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
14563
+ currentEpoch() {
14564
+ return this.epochLatch ?? "";
14565
+ }
14566
+ /**
14567
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
14568
+ * the reseed chain, BEFORE any stream nudge resumes.
14569
+ */
14570
+ subscribeEpochChange(handler) {
14571
+ this.epochHandlers.add(handler);
14572
+ return () => this.epochHandlers.delete(handler);
14573
+ }
14422
14574
  noteEpoch(epoch) {
14423
14575
  if (this.epochLatch === epoch) return;
14424
14576
  const previous = this.epochLatch;
@@ -14432,7 +14584,7 @@ var WalletApiSession = class {
14432
14584
  }
14433
14585
  async runReseed(epoch, previous) {
14434
14586
  try {
14435
- await this.deps.onEpochChange(epoch);
14587
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
14436
14588
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
14437
14589
  } catch {
14438
14590
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -14694,6 +14846,7 @@ var WalletApiDeliveryPort = class {
14694
14846
  wake;
14695
14847
  deriveFn;
14696
14848
  seenChain = new SerialChain();
14849
+ lastIncomingEpoch = null;
14697
14850
  constructor(config) {
14698
14851
  this.client = config.client;
14699
14852
  this.kv = config.kv;
@@ -14788,6 +14941,7 @@ var WalletApiDeliveryPort = class {
14788
14941
  const seen = await this.readSeen();
14789
14942
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
14790
14943
  for (; ; ) {
14944
+ this.lastIncomingEpoch = String(page.syncEpoch);
14791
14945
  for (const entry of page.entries) {
14792
14946
  if (entry.status !== "unclaimed") continue;
14793
14947
  if (seen.has(entry.entryId)) continue;
@@ -14797,6 +14951,10 @@ var WalletApiDeliveryPort = class {
14797
14951
  page = await this.client.listMailbox(page.cursor);
14798
14952
  }
14799
14953
  }
14954
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
14955
+ incomingEpoch() {
14956
+ return this.lastIncomingEpoch;
14957
+ }
14800
14958
  toIncoming(entry) {
14801
14959
  const bundle = this.openEnvelope(entry);
14802
14960
  return {
@@ -14874,7 +15032,7 @@ var WalletApiSplitCheckpointStore = class {
14874
15032
  return utf8(`${transferId}:${String(opIndex)}`);
14875
15033
  }
14876
15034
  async put(transferId, opIndex, bytes) {
14877
- const { client, kv, fieldKey, signProgress } = this.config;
15035
+ const { kv, fieldKey } = this.config;
14878
15036
  const aad = this.aad(transferId, opIndex);
14879
15037
  const cacheKey = this.cacheKey(transferId, opIndex);
14880
15038
  let envelope = await kv.get(cacheKey);
@@ -14882,10 +15040,21 @@ var WalletApiSplitCheckpointStore = class {
14882
15040
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
14883
15041
  await kv.set(cacheKey, envelope);
14884
15042
  }
14885
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
14886
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
15043
+ const record = await this.post(transferId, opIndex, envelope);
14887
15044
  return this.open(record.payload, aad);
14888
15045
  }
15046
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
15047
+ async reseedCheckpoint(transferId, opIndex) {
15048
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
15049
+ if (envelope === null) return false;
15050
+ await this.post(transferId, opIndex, envelope);
15051
+ return true;
15052
+ }
15053
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
15054
+ async post(transferId, opIndex, envelope) {
15055
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
15056
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
15057
+ }
14889
15058
  async get(transferId, opIndex) {
14890
15059
  const records = await this.config.client.getProgress(transferId);
14891
15060
  const record = records.find((r) => r.opIndex === opIndex);
@@ -14908,7 +15077,7 @@ var WalletApiSplitCheckpointStore = class {
14908
15077
  };
14909
15078
 
14910
15079
  // core/payments-v2-wiring.ts
14911
- function resolvePaymentsV2Composition(walletApi) {
15080
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
14912
15081
  const source = walletApi;
14913
15082
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
14914
15083
  if (!source || network === null) {
@@ -14917,6 +15086,18 @@ function resolvePaymentsV2Composition(walletApi) {
14917
15086
  "INVALID_CONFIG"
14918
15087
  );
14919
15088
  }
15089
+ if (!(network in NETWORKS)) {
15090
+ throw new SphereError(
15091
+ `payments: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
15092
+ "INVALID_CONFIG"
15093
+ );
15094
+ }
15095
+ if (network !== sphereNetwork) {
15096
+ throw new SphereError(
15097
+ `payments: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
15098
+ "INVALID_CONFIG"
15099
+ );
15100
+ }
14920
15101
  if (typeof source.paymentsV2Transport === "function") {
14921
15102
  return { network, factory: source.paymentsV2Transport.bind(source) };
14922
15103
  }
@@ -14981,8 +15162,7 @@ function defaultPaymentsV2Transport(config) {
14981
15162
  deviceId: `${config.deviceId}:pv2`,
14982
15163
  kv: args.kv,
14983
15164
  webSocketFactory,
14984
- emitStatus: args.emitStatus,
14985
- onEpochChange: args.onEpochChange
15165
+ emitStatus: args.emitStatus
14986
15166
  });
14987
15167
  return { session, client: authedWireClient(session, client) };
14988
15168
  };
@@ -15052,8 +15232,7 @@ function composePaymentsV2(spec) {
15052
15232
  identity: identitySlice,
15053
15233
  network,
15054
15234
  kv,
15055
- emitStatus: (status) => host.emit("connection:status", { status }),
15056
- onEpochChange: async () => void 0
15235
+ emitStatus: (status) => host.emit("connection:status", { status })
15057
15236
  });
15058
15237
  const deps = {
15059
15238
  session: transport.session,
@@ -15083,7 +15262,10 @@ function composePaymentsV2(spec) {
15083
15262
  network,
15084
15263
  ownPubkey: identity.chainPubkey,
15085
15264
  ownNametag: host.nametag,
15086
- requestMemo: requestMemoCodec(identity.privateKey)
15265
+ requestMemo: requestMemoCodec(identity.privateKey),
15266
+ // §5.1: the session is the epoch authority — the facade registers its
15267
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
15268
+ syncEpoch: () => transport.session.currentEpoch()
15087
15269
  };
15088
15270
  return new PaymentsFacade(deps);
15089
15271
  }
@@ -15238,7 +15420,7 @@ var Sphere = class _Sphere {
15238
15420
  static async init(options) {
15239
15421
  if (options.debug) logger.configure({ debug: true });
15240
15422
  _Sphere.refuseRetiredModuleOptions(options);
15241
- resolvePaymentsV2Composition(options.walletApi);
15423
+ resolvePaymentsV2Composition(options.walletApi, options.network);
15242
15424
  _Sphere.configureTokenRegistry(options.storage, options.network);
15243
15425
  const groupChat = _Sphere.resolveGroupChatConfig(options.groupChat, options.network);
15244
15426
  const market = _Sphere.resolveMarketConfig(options.market);
@@ -15380,7 +15562,7 @@ var Sphere = class _Sphere {
15380
15562
  static async create(options) {
15381
15563
  if (options.debug) logger.configure({ debug: true });
15382
15564
  _Sphere.refuseRetiredModuleOptions(options);
15383
- const composition = resolvePaymentsV2Composition(options.walletApi);
15565
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15384
15566
  if (!options.mnemonic || !_Sphere.validateMnemonic(options.mnemonic)) {
15385
15567
  throw new SphereError("Invalid mnemonic", "INVALID_IDENTITY");
15386
15568
  }
@@ -15448,7 +15630,7 @@ var Sphere = class _Sphere {
15448
15630
  static async load(options) {
15449
15631
  if (options.debug) logger.configure({ debug: true });
15450
15632
  _Sphere.refuseRetiredModuleOptions(options);
15451
- const composition = resolvePaymentsV2Composition(options.walletApi);
15633
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15452
15634
  if (!await _Sphere.exists(options.storage)) {
15453
15635
  throw new SphereError("No wallet found. Use Sphere.create() to create a new wallet.", "NOT_INITIALIZED");
15454
15636
  }
@@ -15502,7 +15684,7 @@ var Sphere = class _Sphere {
15502
15684
  static async import(options) {
15503
15685
  if (options.debug) logger.configure({ debug: true });
15504
15686
  _Sphere.refuseRetiredModuleOptions(options);
15505
- const composition = resolvePaymentsV2Composition(options.walletApi);
15687
+ const composition = resolvePaymentsV2Composition(options.walletApi, options.network);
15506
15688
  if (!options.mnemonic && !options.masterKey) {
15507
15689
  throw new SphereError("Either mnemonic or masterKey is required", "INVALID_CONFIG");
15508
15690
  }