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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -12244,14 +12244,14 @@ var IntentResume = class {
12244
12244
  }
12245
12245
  let changeOutput = null;
12246
12246
  if (payload.split) {
12247
- const splitOpIndex = payload.direct.length;
12247
+ const splitOpIndex2 = payload.direct.length;
12248
12248
  let splitDelivered = false;
12249
12249
  {
12250
12250
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
12251
12251
  const outcome = await this.host.certifyOperation(
12252
12252
  {
12253
12253
  kind: "split",
12254
- opIndex: splitOpIndex,
12254
+ opIndex: splitOpIndex2,
12255
12255
  uiTokenId: `v2_${payload.split.tokenId}`,
12256
12256
  genesisId: payload.split.tokenId,
12257
12257
  sourceSdkData: "",
@@ -22358,6 +22358,7 @@ var AccountingModule = class _AccountingModule {
22358
22358
  const unsubHistory = deps.on(
22359
22359
  "history:updated",
22360
22360
  (entry) => {
22361
+ if (!("dedupKey" in entry)) return;
22361
22362
  this._handleHistoryUpdated(entry).catch((err) => {
22362
22363
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
22363
22364
  });
@@ -27620,7 +27621,10 @@ var STORE_KEYS = {
27620
27621
  shortfalls: "shortfalls",
27621
27622
  settlingLinks: "settling",
27622
27623
  streamCursor: (s) => `cursor:${s}`,
27623
- epochLatch: "epoch-latch"
27624
+ epochLatch: "epoch-latch",
27625
+ // §5.2 InventoryView durable overlays (#625/#679).
27626
+ suspectedSpent: "suspected-spent",
27627
+ knownSpends: "known-spends"
27624
27628
  };
27625
27629
 
27626
27630
  // modules/payments-v2/machine/journal.ts
@@ -27870,7 +27874,7 @@ var TransferMachine = class {
27870
27874
  }
27871
27875
  if (cls === null) {
27872
27876
  await this.applyCommitted(engine, plan.transferId, committed);
27873
- this.closeTail(plan.transferId, plan.payload);
27877
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
27874
27878
  return { committed, deliveryPending };
27875
27879
  }
27876
27880
  if (cls === "conflict" && committed.length > 0) {
@@ -27896,10 +27900,10 @@ var TransferMachine = class {
27896
27900
  }
27897
27901
  await this.applyCommitted(engine, r.transferId, committed);
27898
27902
  if (conflicts.length > 0) {
27899
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
27903
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
27900
27904
  }
27901
27905
  await this.completeIntent(r.transferId, r.payload);
27902
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27906
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27903
27907
  return { deliveryPending, partial: null };
27904
27908
  }
27905
27909
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -28081,7 +28085,7 @@ var TransferMachine = class {
28081
28085
  }
28082
28086
  return added;
28083
28087
  }
28084
- async settlePartial(r, committed, conflicts) {
28088
+ async settlePartial(engine, r, committed, conflicts) {
28085
28089
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
28086
28090
  const entry = {
28087
28091
  transferId: r.transferId,
@@ -28093,26 +28097,27 @@ var TransferMachine = class {
28093
28097
  };
28094
28098
  await this.stores.shortfalls.upsert(entry);
28095
28099
  await this.completeIntent(r.transferId, r.payload);
28096
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
28100
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
28097
28101
  return entry;
28098
28102
  }
28099
- closeTail(transferId, payload) {
28103
+ closeTail(engine, transferId, payload, committed) {
28100
28104
  void (async () => {
28101
28105
  try {
28102
28106
  await this.completeIntent(transferId, payload);
28103
28107
  } catch {
28104
28108
  return;
28105
28109
  }
28106
- await this.finishBookkeeping(transferId, payload, "sent");
28110
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
28107
28111
  })();
28108
28112
  }
28109
28113
  async completeIntent(transferId, payload) {
28110
28114
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
28111
28115
  await this.deps.intents.complete(transferId, signature);
28112
28116
  }
28113
- async finishBookkeeping(transferId, payload, phase) {
28117
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
28114
28118
  try {
28115
- await this.deps.recordHistory?.({ transferId, payload, phase });
28119
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
28120
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
28116
28121
  } catch {
28117
28122
  }
28118
28123
  try {
@@ -28120,6 +28125,26 @@ var TransferMachine = class {
28120
28125
  } catch {
28121
28126
  }
28122
28127
  }
28128
+ /**
28129
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
28130
+ * certified recipient blobs (summarize()'s committed set is the only source;
28131
+ * a journal-rehydrated resume leg has no source token, but every committed
28132
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
28133
+ */
28134
+ async settledAmount(engine, coinId, committed) {
28135
+ let total = 0n;
28136
+ for (const outcome of committed) {
28137
+ if (outcome.recipientBlob === void 0) continue;
28138
+ const token = await engine.decodeToken({
28139
+ v: TOKEN_BLOB_VERSION,
28140
+ network: 0,
28141
+ tokenId: "",
28142
+ token: outcome.recipientBlob
28143
+ });
28144
+ total += engine.balanceOf(token, coinId);
28145
+ }
28146
+ return total.toString();
28147
+ }
28123
28148
  async disposeOnFailure(transferId, committed, cls) {
28124
28149
  if (committed.length > 0 || cls === "keep-open") return;
28125
28150
  try {
@@ -28144,7 +28169,7 @@ function conflictAmount(engine, payload, op, source) {
28144
28169
  }
28145
28170
 
28146
28171
  // modules/payments-v2/machine/resume.ts
28147
- async function resumeAll(deps) {
28172
+ async function resumeAll(deps, options = {}) {
28148
28173
  const report = { resumed: [], conflicted: [], failed: [] };
28149
28174
  const stores = createMachineStores(deps.kv);
28150
28175
  const open = await deps.intents.listOpen();
@@ -28154,7 +28179,12 @@ async function resumeAll(deps) {
28154
28179
  } catch {
28155
28180
  return report;
28156
28181
  }
28157
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
28182
+ await dropStaleBackstops(
28183
+ stores,
28184
+ locals,
28185
+ new Set(open.map((i) => i.transferId)),
28186
+ options.isLocallyActive ?? (() => false)
28187
+ );
28158
28188
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
28159
28189
  const ctx = {
28160
28190
  deps,
@@ -28167,11 +28197,16 @@ async function resumeAll(deps) {
28167
28197
  }
28168
28198
  return report;
28169
28199
  }
28170
- async function dropStaleLocalAborts(stores, locals, openIds) {
28200
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
28171
28201
  for (const entry of locals.values()) {
28172
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
28202
+ if (openIds.has(entry.transferId)) continue;
28203
+ if (entry.disposition !== "open") {
28173
28204
  await stores.backstop.removeByKey(entry.transferId);
28205
+ continue;
28174
28206
  }
28207
+ if (isLocallyActive(entry.transferId)) continue;
28208
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
28209
+ await stores.backstop.removeByKey(entry.transferId);
28175
28210
  }
28176
28211
  }
28177
28212
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -28379,7 +28414,9 @@ var Converger = class {
28379
28414
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
28380
28415
  }
28381
28416
  async resumePass() {
28382
- const report = await resumeAll(this.filteredMachineDeps());
28417
+ const report = await resumeAll(this.filteredMachineDeps(), {
28418
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
28419
+ });
28383
28420
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
28384
28421
  await this.deps.reconcile({
28385
28422
  resumed: report.resumed,
@@ -28495,6 +28532,136 @@ function distinctOpCount(entries) {
28495
28532
  return new Set(entries.map((entry) => entry.opIndex)).size;
28496
28533
  }
28497
28534
 
28535
+ // modules/payments-v2/restore.ts
28536
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
28537
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
28538
+ async function reseedAndReset(deps) {
28539
+ for (const entry of await deps.stores.backstop.list()) {
28540
+ if (entry.disposition !== "open") continue;
28541
+ try {
28542
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
28543
+ if (entry.requiresSeedClose) {
28544
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
28545
+ }
28546
+ } catch (err) {
28547
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
28548
+ }
28549
+ }
28550
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
28551
+ await deps.fullRePull();
28552
+ }
28553
+ async function splitOpIndex(deps, envelope) {
28554
+ const payload = await deps.decryptPayload(envelope);
28555
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
28556
+ }
28557
+
28558
+ // modules/payments-v2/inventory/presentation.ts
28559
+ function toToken(tokenId, snapshot, asset, registry, flags) {
28560
+ const iconUrl = registry.getIconUrl(asset.coinId);
28561
+ return {
28562
+ id: tokenId,
28563
+ coinId: asset.coinId,
28564
+ symbol: registry.getSymbol(asset.coinId),
28565
+ name: registry.getName(asset.coinId),
28566
+ decimals: registry.getDecimals(asset.coinId),
28567
+ ...iconUrl !== null ? { iconUrl } : {},
28568
+ amount: asset.amount,
28569
+ status: flags.transferring ? "transferring" : "confirmed",
28570
+ createdAt: snapshot.createdAt,
28571
+ updatedAt: snapshot.updatedAt,
28572
+ lazy: true,
28573
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28574
+ };
28575
+ }
28576
+ function aggregateAssets(entries, isTransferring, registry) {
28577
+ const groups = /* @__PURE__ */ new Map();
28578
+ for (const [tokenId, entry] of entries) {
28579
+ const moving = isTransferring(tokenId);
28580
+ for (const asset of entry.assets) {
28581
+ let group = groups.get(asset.coinId);
28582
+ if (!group) {
28583
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28584
+ groups.set(asset.coinId, group);
28585
+ }
28586
+ if (moving) {
28587
+ group.transferring += BigInt(asset.amount);
28588
+ group.transferringCount += 1;
28589
+ } else {
28590
+ group.total += BigInt(asset.amount);
28591
+ group.count += 1;
28592
+ }
28593
+ }
28594
+ }
28595
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28596
+ }
28597
+ function toAsset(coinId, totals, registry) {
28598
+ const iconUrl = registry.getIconUrl(coinId);
28599
+ return {
28600
+ coinId,
28601
+ symbol: registry.getSymbol(coinId),
28602
+ name: registry.getName(coinId),
28603
+ decimals: registry.getDecimals(coinId),
28604
+ ...iconUrl !== null ? { iconUrl } : {},
28605
+ totalAmount: totals.total.toString(),
28606
+ tokenCount: totals.count,
28607
+ confirmedAmount: totals.total.toString(),
28608
+ unconfirmedAmount: "0",
28609
+ confirmedTokenCount: totals.count,
28610
+ unconfirmedTokenCount: 0,
28611
+ transferringTokenCount: totals.transferringCount,
28612
+ transferringAmount: totals.transferring.toString(),
28613
+ priceUsd: null,
28614
+ priceEur: null,
28615
+ change24h: null,
28616
+ fiatValueUsd: null,
28617
+ fiatValueEur: null
28618
+ };
28619
+ }
28620
+ async function withPrices(raw, registry, price) {
28621
+ try {
28622
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28623
+ const quotes = await price.getPrices(names);
28624
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28625
+ } catch {
28626
+ return raw;
28627
+ }
28628
+ }
28629
+ function priceOne(asset, quote) {
28630
+ if (!quote) return asset;
28631
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28632
+ return {
28633
+ ...asset,
28634
+ priceUsd: quote.priceUsd,
28635
+ priceEur: quote.priceEur ?? null,
28636
+ change24h: quote.change24h ?? null,
28637
+ fiatValueUsd: human * quote.priceUsd,
28638
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28639
+ };
28640
+ }
28641
+
28642
+ // modules/payments-v2/machine/payload.ts
28643
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
28644
+ return {
28645
+ v: 2,
28646
+ recipient: recipientPubkey,
28647
+ coinId: request.coinId,
28648
+ amount: request.amount,
28649
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
28650
+ direct: [...spend.plan.direct],
28651
+ ...spend.plan.split !== void 0 ? {
28652
+ split: {
28653
+ tokenId: spend.plan.split.tokenId,
28654
+ splitAmount: spend.plan.split.splitAmount.toString(),
28655
+ remainderAmount: spend.plan.split.remainderAmount.toString()
28656
+ }
28657
+ } : {},
28658
+ spentStates
28659
+ };
28660
+ }
28661
+ function messageOf(err) {
28662
+ return err instanceof Error ? err.message : String(err);
28663
+ }
28664
+
28498
28665
  // modules/payments-v2/history/History.ts
28499
28666
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
28500
28667
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -28553,13 +28720,14 @@ var History = class {
28553
28720
  }
28554
28721
  // ── internals ────────────────────────────────────────────────────────────────
28555
28722
  async post(build) {
28723
+ const record = build();
28556
28724
  try {
28557
- await this.deps.client.postHistory([build()]);
28725
+ await this.deps.client.postHistory([record]);
28558
28726
  } catch (err) {
28559
28727
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
28560
28728
  return;
28561
28729
  }
28562
- this.deps.emit("history:updated");
28730
+ this.deps.emit("history:updated", this.toEntry(record));
28563
28731
  }
28564
28732
  ts(timestamp) {
28565
28733
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -28616,93 +28784,9 @@ var History = class {
28616
28784
  }
28617
28785
  };
28618
28786
 
28619
- // modules/payments-v2/inventory/presentation.ts
28620
- function toToken(tokenId, snapshot, asset, registry, flags) {
28621
- const iconUrl = registry.getIconUrl(asset.coinId);
28622
- return {
28623
- id: tokenId,
28624
- coinId: asset.coinId,
28625
- symbol: registry.getSymbol(asset.coinId),
28626
- name: registry.getName(asset.coinId),
28627
- decimals: registry.getDecimals(asset.coinId),
28628
- ...iconUrl !== null ? { iconUrl } : {},
28629
- amount: asset.amount,
28630
- status: flags.transferring ? "transferring" : "confirmed",
28631
- createdAt: snapshot.createdAt,
28632
- updatedAt: snapshot.updatedAt,
28633
- lazy: true,
28634
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28635
- };
28636
- }
28637
- function aggregateAssets(entries, isTransferring, registry) {
28638
- const groups = /* @__PURE__ */ new Map();
28639
- for (const [tokenId, entry] of entries) {
28640
- const moving = isTransferring(tokenId);
28641
- for (const asset of entry.assets) {
28642
- let group = groups.get(asset.coinId);
28643
- if (!group) {
28644
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28645
- groups.set(asset.coinId, group);
28646
- }
28647
- if (moving) {
28648
- group.transferring += BigInt(asset.amount);
28649
- group.transferringCount += 1;
28650
- } else {
28651
- group.total += BigInt(asset.amount);
28652
- group.count += 1;
28653
- }
28654
- }
28655
- }
28656
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28657
- }
28658
- function toAsset(coinId, totals, registry) {
28659
- const iconUrl = registry.getIconUrl(coinId);
28660
- return {
28661
- coinId,
28662
- symbol: registry.getSymbol(coinId),
28663
- name: registry.getName(coinId),
28664
- decimals: registry.getDecimals(coinId),
28665
- ...iconUrl !== null ? { iconUrl } : {},
28666
- totalAmount: totals.total.toString(),
28667
- tokenCount: totals.count,
28668
- confirmedAmount: totals.total.toString(),
28669
- unconfirmedAmount: "0",
28670
- confirmedTokenCount: totals.count,
28671
- unconfirmedTokenCount: 0,
28672
- transferringTokenCount: totals.transferringCount,
28673
- transferringAmount: totals.transferring.toString(),
28674
- priceUsd: null,
28675
- priceEur: null,
28676
- change24h: null,
28677
- fiatValueUsd: null,
28678
- fiatValueEur: null
28679
- };
28680
- }
28681
- async function withPrices(raw, registry, price) {
28682
- try {
28683
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28684
- const quotes = await price.getPrices(names);
28685
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28686
- } catch {
28687
- return raw;
28688
- }
28689
- }
28690
- function priceOne(asset, quote) {
28691
- if (!quote) return asset;
28692
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28693
- return {
28694
- ...asset,
28695
- priceUsd: quote.priceUsd,
28696
- priceEur: quote.priceEur ?? null,
28697
- change24h: quote.change24h ?? null,
28698
- fiatValueUsd: human * quote.priceUsd,
28699
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28700
- };
28701
- }
28702
-
28703
28787
  // modules/payments-v2/inventory/InventoryView.ts
28704
- var SUSPECTED_KEY = "suspected-spent";
28705
- var KNOWN_SPENDS_KEY = "known-spends";
28788
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
28789
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
28706
28790
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
28707
28791
  function stateKey(tokenId, stateHash) {
28708
28792
  return `${tokenId}:${stateHash}`;
@@ -29006,19 +29090,24 @@ var Receive = class {
29006
29090
  const engine = deps.engine();
29007
29091
  const stored = [];
29008
29092
  const pending = [];
29009
- let epoch = "";
29093
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
29010
29094
  try {
29011
- epoch = deps.syncEpoch();
29012
29095
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
29013
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
29014
- for await (const entry of deps.delivery.incoming(since)) {
29015
- await this.processEntry(deps, engine, entry, pending, stored);
29016
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
29096
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
29097
+ for (let pass = 0; pass < 2; pass++) {
29098
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
29099
+ await this.processEntry(deps, engine, entry, pending, stored);
29100
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
29101
+ }
29102
+ await flushAcks(deps, pending, pageEpoch);
29103
+ const served = deps.delivery.incomingEpoch();
29104
+ if (basis === null || served === null || served === basis.syncEpoch) break;
29105
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
29106
+ basis = null;
29017
29107
  }
29018
- await flushAcks(deps, pending, epoch);
29019
29108
  } catch (err) {
29020
29109
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
29021
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
29110
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
29022
29111
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
29023
29112
  });
29024
29113
  }
@@ -29091,7 +29180,7 @@ async function announce(deps, entry, record) {
29091
29180
  receivedAt
29092
29181
  };
29093
29182
  }
29094
- async function flushAcks(deps, pending, epoch) {
29183
+ async function flushAcks(deps, pending, epochOf) {
29095
29184
  let lastAcked = null;
29096
29185
  try {
29097
29186
  while (pending.length > 0) {
@@ -29102,7 +29191,7 @@ async function flushAcks(deps, pending, epoch) {
29102
29191
  }
29103
29192
  } finally {
29104
29193
  if (lastAcked !== null) {
29105
- const record = { cursor: lastAcked, syncEpoch: epoch };
29194
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
29106
29195
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
29107
29196
  }
29108
29197
  }
@@ -29203,9 +29292,19 @@ var Requests = class {
29203
29292
  }
29204
29293
  });
29205
29294
  }
29295
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
29206
29296
  writeLink(requestId, transferId, committed) {
29207
29297
  return this.mutateJournal((m) => {
29208
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
29298
+ const prev = m.get(requestId);
29299
+ const same = prev !== void 0 && prev.transferId === transferId;
29300
+ const next = {
29301
+ requestId,
29302
+ transferId,
29303
+ committed: committed || same && prev.committed,
29304
+ createdAt: same ? prev.createdAt : this.now()
29305
+ };
29306
+ if (same && prev.committed === next.committed) return false;
29307
+ m.set(requestId, next);
29209
29308
  return true;
29210
29309
  });
29211
29310
  }
@@ -29358,23 +29457,46 @@ var Requests = class {
29358
29457
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
29359
29458
  if (transferId !== void 0) {
29360
29459
  const committed = error instanceof PartialSendConflictError;
29361
- await this.writeLink(id, transferId, committed);
29362
- this.setStatus(id, "settling");
29460
+ await this.settle(id, transferId, { committed, respond: false });
29363
29461
  } else {
29364
29462
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
29365
29463
  this.setStatus(id, "paid");
29366
29464
  }
29367
29465
  } else {
29368
- await this.clearLink(id);
29369
- this.setStatus(id, "pending");
29466
+ await this.revertPayable(id);
29370
29467
  }
29371
29468
  throw error;
29372
29469
  }
29373
- this.setStatus(id, "paid");
29374
- await this.respondPaid(id, result.id);
29375
- await this.clearLink(id);
29470
+ await this.settle(id, result.id, { committed: true, respond: true });
29376
29471
  return result;
29377
29472
  }
29473
+ /**
29474
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
29475
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
29476
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
29477
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
29478
+ * not a reload. Every path that binds a request to a transfer outcome funnels
29479
+ * through here — pay()'s clean success (respond now), pay()'s
29480
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
29481
+ * — so a failed respond always leaves the link + 'settling' and the next
29482
+ * reconcile pass (the committed-link override) retries the respond.
29483
+ */
29484
+ async settle(requestId, transferId, opts) {
29485
+ let journalDown = false;
29486
+ try {
29487
+ await this.writeLink(requestId, transferId, opts.committed);
29488
+ } catch (err) {
29489
+ if (!opts.respond) throw err;
29490
+ journalDown = true;
29491
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
29492
+ }
29493
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
29494
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
29495
+ return;
29496
+ }
29497
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
29498
+ this.setStatus(requestId, "paid");
29499
+ }
29378
29500
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
29379
29501
  async respondPaid(id, transferId) {
29380
29502
  try {
@@ -29410,32 +29532,32 @@ var Requests = class {
29410
29532
  let aborted = null;
29411
29533
  for (const [requestId, link] of [...this.journal]) {
29412
29534
  if (link.committed || resumed.has(link.transferId)) {
29413
- await this.resolvePaid(requestId, link.transferId);
29535
+ await this.resolvePaid(requestId, link);
29414
29536
  } else if (conflicted.has(link.transferId)) {
29415
29537
  await this.revertPayable(requestId);
29416
29538
  } else if (open.has(link.transferId)) {
29417
29539
  } else {
29418
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
29540
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
29419
29541
  }
29420
29542
  }
29421
29543
  }
29422
29544
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
29423
29545
  // a probe failure defers the link to the next session.
29424
- async reconcileUnaccounted(requestId, transferId, aborted) {
29546
+ async reconcileUnaccounted(requestId, link, aborted) {
29425
29547
  try {
29426
29548
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
29427
- if (aborted.has(transferId)) await this.revertPayable(requestId);
29428
- else await this.resolvePaid(requestId, transferId);
29549
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
29550
+ else await this.resolvePaid(requestId, link);
29429
29551
  } catch (err) {
29430
29552
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
29431
29553
  }
29432
29554
  return aborted;
29433
29555
  }
29434
- async resolvePaid(requestId, transferId) {
29435
- if (!await this.respondPaid(requestId, transferId)) return;
29436
- await this.clearLink(requestId);
29437
- this.setStatus(requestId, "paid");
29556
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
29557
+ resolvePaid(requestId, link) {
29558
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
29438
29559
  }
29560
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
29439
29561
  async revertPayable(requestId) {
29440
29562
  await this.clearLink(requestId);
29441
29563
  this.setStatus(requestId, "pending");
@@ -29759,11 +29881,12 @@ function composeFacadeParts(deps, hooks) {
29759
29881
  client: deps.client,
29760
29882
  fieldKey: deps.fieldKey,
29761
29883
  registry: deps.registry,
29762
- emit: (event) => deps.emit(event, {}),
29884
+ emit: (event, entry) => deps.emit(event, entry),
29763
29885
  ...deps.now !== void 0 ? { now: deps.now } : {},
29764
29886
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
29765
29887
  });
29766
29888
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
29889
+ const machineStores = createMachineStores(deps.kv);
29767
29890
  const heldStates = /* @__PURE__ */ new Map();
29768
29891
  return {
29769
29892
  ownPubkeyBytes,
@@ -29771,12 +29894,29 @@ function composeFacadeParts(deps, hooks) {
29771
29894
  ledger,
29772
29895
  queue,
29773
29896
  historyStore,
29774
- machineStores: createMachineStores(deps.kv),
29897
+ machineStores,
29775
29898
  machineDeps,
29776
29899
  machine: new TransferMachine(machineDeps),
29777
29900
  heldStates,
29778
29901
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
29779
- requests: buildRequests(deps, hooks)
29902
+ requests: buildRequests(deps, hooks),
29903
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
29904
+ };
29905
+ }
29906
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
29907
+ return {
29908
+ stores: machineStores,
29909
+ kv: deps.kv,
29910
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
29911
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
29912
+ decryptPayload: machineDeps.decryptPayload,
29913
+ fullRePull: () => view.onEpochReset(),
29914
+ attention: (transferId, code, detail) => {
29915
+ deps.emit(
29916
+ "transfer:attention",
29917
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
29918
+ );
29919
+ }
29780
29920
  };
29781
29921
  }
29782
29922
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -29798,11 +29938,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
29798
29938
  ownPubkey: hexToBytes4(deps.ownPubkey),
29799
29939
  emit: deps.emit,
29800
29940
  now: deps.now ?? Date.now,
29801
- recordHistory: async ({ transferId, payload }) => {
29941
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
29802
29942
  await historyStore.recordSent({
29803
29943
  transferId,
29804
29944
  coinId: payload.coinId,
29805
- amount: payload.amount,
29945
+ // §5.9: the SETTLED amount (machine-computed from the certified
29946
+ // recipient blobs), never payload.amount — the plan.
29947
+ amount: committedAmount,
29806
29948
  recipientPubkey: payload.recipient,
29807
29949
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
29808
29950
  });
@@ -29829,7 +29971,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
29829
29971
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
29830
29972
  );
29831
29973
  },
29832
- syncEpoch: deps.syncEpoch ?? (() => ""),
29974
+ syncEpoch: deps.syncEpoch,
29833
29975
  ...deps.now !== void 0 ? { now: deps.now } : {}
29834
29976
  });
29835
29977
  }
@@ -29882,6 +30024,7 @@ var PaymentsFacade = class {
29882
30024
  this.receiveLoop = parts.receiveLoop;
29883
30025
  this.heldStates = parts.heldStates;
29884
30026
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
30027
+ this.restoreDeps = parts.restoreDeps;
29885
30028
  this.requests = parts.requests;
29886
30029
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
29887
30030
  this.heartbeat = new ConvergenceHeartbeat({
@@ -29910,6 +30053,7 @@ var PaymentsFacade = class {
29910
30053
  receiveLoop;
29911
30054
  heldStates;
29912
30055
  ownPubkeyBytes;
30056
+ restoreDeps;
29913
30057
  requests;
29914
30058
  currentEngine = null;
29915
30059
  pendingOps = /* @__PURE__ */ new Set();
@@ -29920,6 +30064,8 @@ var PaymentsFacade = class {
29920
30064
  converger;
29921
30065
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
29922
30066
  resumeFlight = new SingleFlight();
30067
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
30068
+ passChain = new SerialChain();
29923
30069
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
29924
30070
  activeMoneyOps = /* @__PURE__ */ new Set();
29925
30071
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -29927,8 +30073,8 @@ var PaymentsFacade = class {
29927
30073
  if (this.started) return;
29928
30074
  this.started = true;
29929
30075
  this.heartbeat.start();
29930
- await this.deps.session.start();
29931
30076
  this.unsubscribers.push(
30077
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
29932
30078
  this.deps.session.subscribeStream("mailbox", () => {
29933
30079
  this.trackTail(this.receiveLoop.drainOnce());
29934
30080
  }),
@@ -29943,6 +30089,7 @@ var PaymentsFacade = class {
29943
30089
  if (status === "connected") this.heartbeat.recovered();
29944
30090
  });
29945
30091
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
30092
+ await this.deps.session.start();
29946
30093
  this.trackTail(this.runConvergencePass());
29947
30094
  this.trackTail(this.seedHeldStates());
29948
30095
  await this.view.fullPull().catch(() => void 0);
@@ -29983,7 +30130,7 @@ var PaymentsFacade = class {
29983
30130
  }
29984
30131
  // ── money movement ─────────────────────────────────────────────────────────
29985
30132
  send(request) {
29986
- return this.track(this.sendWithPolicy(request));
30133
+ return this.track(this.sendOutcome(request));
29987
30134
  }
29988
30135
  async receive() {
29989
30136
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -29998,20 +30145,27 @@ var PaymentsFacade = class {
29998
30145
  if (!this.started) return;
29999
30146
  await this.track(this.runConvergencePass());
30000
30147
  }
30148
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
30149
+ async handleEpochChange(_newEpoch) {
30150
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
30151
+ await this.passChain.enqueue(() => this.convergeBody());
30152
+ }
30001
30153
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
30002
30154
  runConvergencePass() {
30003
- return this.resumeFlight.run(async () => {
30004
- const outcome = await this.converger.convergeOnce();
30005
- const pending = await this.converger.pendingWork().catch(() => true);
30006
- this.heartbeat.settle(outcome, pending);
30007
- });
30155
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
30156
+ }
30157
+ async convergeBody() {
30158
+ const outcome = await this.converger.convergeOnce();
30159
+ const pending = await this.converger.pendingWork().catch(() => true);
30160
+ this.heartbeat.settle(outcome, pending);
30008
30161
  }
30009
30162
  nowMs() {
30010
30163
  return (this.deps.now ?? Date.now)();
30011
30164
  }
30012
30165
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
30013
- async sendWithPolicy(request) {
30014
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
30166
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
30167
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
30168
+ async sendOutcome(request) {
30015
30169
  const run = {
30016
30170
  amount: request.amount,
30017
30171
  delivered: [],
@@ -30019,12 +30173,36 @@ var PaymentsFacade = class {
30019
30173
  tokenTransfers: [],
30020
30174
  settledShortfalls: [],
30021
30175
  firstPartialId: void 0,
30022
- pendingAny: false
30176
+ pendingAny: false,
30177
+ lastTransferId: ""
30023
30178
  };
30179
+ try {
30180
+ return await this.sendWithPolicy(request, run);
30181
+ } catch (err) {
30182
+ this.emitCleanFailure(err, run.lastTransferId);
30183
+ throw err;
30184
+ }
30185
+ }
30186
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
30187
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
30188
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
30189
+ emitCleanFailure(err, transferId) {
30190
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
30191
+ const failed = {
30192
+ id: transferId,
30193
+ status: "failed",
30194
+ tokens: [],
30195
+ tokenTransfers: [],
30196
+ error: messageOf(err)
30197
+ };
30198
+ this.deps.emit("transfer:updated", failed);
30199
+ }
30200
+ async sendWithPolicy(request, run) {
30201
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
30024
30202
  for (let attempt = 0; ; attempt++) {
30025
30203
  let ctx;
30026
30204
  try {
30027
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
30205
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
30028
30206
  } catch (err) {
30029
30207
  throw this.partialize(err, run);
30030
30208
  }
@@ -30069,11 +30247,8 @@ var PaymentsFacade = class {
30069
30247
  this.activeMoneyOps.delete(ctx.transferId);
30070
30248
  }
30071
30249
  }
30072
- /**
30073
- * Partial outcome (#677/#690): the shortfall is already durable (written by
30074
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
30075
- * the remainder under a NEW transferId — never the full amount.
30076
- */
30250
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
30251
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
30077
30252
  async consumePartial(ctx, disposition, run, attempt) {
30078
30253
  if (disposition.deliveryPending) {
30079
30254
  run.pendingAny = true;
@@ -30099,11 +30274,8 @@ var PaymentsFacade = class {
30099
30274
  run.amount = partial.remainingAmount;
30100
30275
  return null;
30101
30276
  }
30102
- /**
30103
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
30104
- * backstop still 'open' (committed>0) → converge via the same machine's
30105
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
30106
- */
30277
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
30278
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
30107
30279
  async disposeFailedAttempt(ctx, err) {
30108
30280
  if (isPossiblyCommittedSendOutcome(err)) {
30109
30281
  this.settleKeepOpen(ctx);
@@ -30152,8 +30324,9 @@ var PaymentsFacade = class {
30152
30324
  }
30153
30325
  return { kind: "rethrow", error: err };
30154
30326
  }
30155
- async planAndMaterialize(recipientPubkey, request) {
30327
+ async planAndMaterialize(recipientPubkey, request, run) {
30156
30328
  const transferId = this.newId();
30329
+ run.lastTransferId = transferId;
30157
30330
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
30158
30331
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
30159
30332
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -30285,11 +30458,7 @@ var PaymentsFacade = class {
30285
30458
  this.deps.emit("transfer:updated", result);
30286
30459
  return result;
30287
30460
  }
30288
- /**
30289
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
30290
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
30291
- * over the accumulated settled set — never bare, never a full-amount retry.
30292
- */
30461
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
30293
30462
  partialize(err, run) {
30294
30463
  if (run.delivered.length === 0) return err;
30295
30464
  return new PartialSendConflictError(
@@ -30441,22 +30610,14 @@ var PaymentsFacade = class {
30441
30610
  }
30442
30611
  }
30443
30612
  toUiToken(tokenId, coinId, amount) {
30444
- const registry = this.deps.registry;
30445
- const iconUrl = registry.getIconUrl(coinId);
30446
30613
  const now = this.nowMs();
30447
- return {
30448
- id: tokenId,
30449
- coinId,
30450
- symbol: registry.getSymbol(coinId),
30451
- name: registry.getName(coinId),
30452
- decimals: registry.getDecimals(coinId),
30453
- ...iconUrl !== null ? { iconUrl } : {},
30454
- amount: amount.toString(),
30455
- status: "transferring",
30456
- createdAt: now,
30457
- updatedAt: now,
30458
- lazy: true
30459
- };
30614
+ return toToken(
30615
+ tokenId,
30616
+ { assets: [], createdAt: now, updatedAt: now },
30617
+ { coinId, amount: amount.toString() },
30618
+ this.deps.registry,
30619
+ { transferring: true, suspectedSpent: false }
30620
+ );
30460
30621
  }
30461
30622
  track(op) {
30462
30623
  const settled = op.then(
@@ -30471,27 +30632,6 @@ var PaymentsFacade = class {
30471
30632
  this.track(op).catch(() => void 0);
30472
30633
  }
30473
30634
  };
30474
- function buildPayload(recipientPubkey, request, spend, spentStates) {
30475
- return {
30476
- v: 2,
30477
- recipient: recipientPubkey,
30478
- coinId: request.coinId,
30479
- amount: request.amount,
30480
- ...request.memo !== void 0 ? { memo: request.memo } : {},
30481
- direct: [...spend.plan.direct],
30482
- ...spend.plan.split !== void 0 ? {
30483
- split: {
30484
- tokenId: spend.plan.split.tokenId,
30485
- splitAmount: spend.plan.split.splitAmount.toString(),
30486
- remainderAmount: spend.plan.split.remainderAmount.toString()
30487
- }
30488
- } : {},
30489
- spentStates
30490
- };
30491
- }
30492
- function messageOf(err) {
30493
- return err instanceof Error ? err.message : String(err);
30494
- }
30495
30635
 
30496
30636
  // impl/wallet-api-v2/http.ts
30497
30637
  var WalletApiHttpError = class extends Error {
@@ -30837,6 +30977,7 @@ var WalletApiSession = class {
30837
30977
  epochLatch = null;
30838
30978
  reseedChain = Promise.resolve();
30839
30979
  reseedsPending = 0;
30980
+ epochHandlers = /* @__PURE__ */ new Set();
30840
30981
  handlers = /* @__PURE__ */ new Map();
30841
30982
  statusHandlers = /* @__PURE__ */ new Set();
30842
30983
  socket = null;
@@ -30939,6 +31080,18 @@ var WalletApiSession = class {
30939
31080
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
30940
31081
  }
30941
31082
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
31083
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
31084
+ currentEpoch() {
31085
+ return this.epochLatch ?? "";
31086
+ }
31087
+ /**
31088
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
31089
+ * the reseed chain, BEFORE any stream nudge resumes.
31090
+ */
31091
+ subscribeEpochChange(handler) {
31092
+ this.epochHandlers.add(handler);
31093
+ return () => this.epochHandlers.delete(handler);
31094
+ }
30942
31095
  noteEpoch(epoch) {
30943
31096
  if (this.epochLatch === epoch) return;
30944
31097
  const previous = this.epochLatch;
@@ -30952,7 +31105,7 @@ var WalletApiSession = class {
30952
31105
  }
30953
31106
  async runReseed(epoch, previous) {
30954
31107
  try {
30955
- await this.deps.onEpochChange(epoch);
31108
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
30956
31109
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
30957
31110
  } catch {
30958
31111
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -31214,6 +31367,7 @@ var WalletApiDeliveryPort = class {
31214
31367
  wake;
31215
31368
  deriveFn;
31216
31369
  seenChain = new SerialChain();
31370
+ lastIncomingEpoch = null;
31217
31371
  constructor(config) {
31218
31372
  this.client = config.client;
31219
31373
  this.kv = config.kv;
@@ -31308,6 +31462,7 @@ var WalletApiDeliveryPort = class {
31308
31462
  const seen = await this.readSeen();
31309
31463
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
31310
31464
  for (; ; ) {
31465
+ this.lastIncomingEpoch = String(page.syncEpoch);
31311
31466
  for (const entry of page.entries) {
31312
31467
  if (entry.status !== "unclaimed") continue;
31313
31468
  if (seen.has(entry.entryId)) continue;
@@ -31317,6 +31472,10 @@ var WalletApiDeliveryPort = class {
31317
31472
  page = await this.client.listMailbox(page.cursor);
31318
31473
  }
31319
31474
  }
31475
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
31476
+ incomingEpoch() {
31477
+ return this.lastIncomingEpoch;
31478
+ }
31320
31479
  toIncoming(entry) {
31321
31480
  const bundle = this.openEnvelope(entry);
31322
31481
  return {
@@ -31394,7 +31553,7 @@ var WalletApiSplitCheckpointStore = class {
31394
31553
  return utf8(`${transferId}:${String(opIndex)}`);
31395
31554
  }
31396
31555
  async put(transferId, opIndex, bytes) {
31397
- const { client, kv, fieldKey, signProgress } = this.config;
31556
+ const { kv, fieldKey } = this.config;
31398
31557
  const aad = this.aad(transferId, opIndex);
31399
31558
  const cacheKey = this.cacheKey(transferId, opIndex);
31400
31559
  let envelope = await kv.get(cacheKey);
@@ -31402,10 +31561,21 @@ var WalletApiSplitCheckpointStore = class {
31402
31561
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
31403
31562
  await kv.set(cacheKey, envelope);
31404
31563
  }
31405
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
31406
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
31564
+ const record = await this.post(transferId, opIndex, envelope);
31407
31565
  return this.open(record.payload, aad);
31408
31566
  }
31567
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
31568
+ async reseedCheckpoint(transferId, opIndex) {
31569
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
31570
+ if (envelope === null) return false;
31571
+ await this.post(transferId, opIndex, envelope);
31572
+ return true;
31573
+ }
31574
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
31575
+ async post(transferId, opIndex, envelope) {
31576
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
31577
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
31578
+ }
31409
31579
  async get(transferId, opIndex) {
31410
31580
  const records = await this.config.client.getProgress(transferId);
31411
31581
  const record = records.find((r) => r.opIndex === opIndex);
@@ -31428,7 +31598,7 @@ var WalletApiSplitCheckpointStore = class {
31428
31598
  };
31429
31599
 
31430
31600
  // core/payments-v2-wiring.ts
31431
- function resolvePaymentsV2Composition(walletApi) {
31601
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
31432
31602
  const source = walletApi;
31433
31603
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
31434
31604
  if (!source || network === null) {
@@ -31437,6 +31607,18 @@ function resolvePaymentsV2Composition(walletApi) {
31437
31607
  "INVALID_CONFIG"
31438
31608
  );
31439
31609
  }
31610
+ if (!(network in NETWORKS)) {
31611
+ throw new SphereError(
31612
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
31613
+ "INVALID_CONFIG"
31614
+ );
31615
+ }
31616
+ if (network !== sphereNetwork) {
31617
+ throw new SphereError(
31618
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
31619
+ "INVALID_CONFIG"
31620
+ );
31621
+ }
31440
31622
  if (typeof source.paymentsV2Transport === "function") {
31441
31623
  return { network, factory: source.paymentsV2Transport.bind(source) };
31442
31624
  }
@@ -31502,8 +31684,7 @@ function defaultPaymentsV2Transport(config) {
31502
31684
  deviceId: `${config.deviceId}:pv2`,
31503
31685
  kv: args.kv,
31504
31686
  webSocketFactory,
31505
- emitStatus: args.emitStatus,
31506
- onEpochChange: args.onEpochChange
31687
+ emitStatus: args.emitStatus
31507
31688
  });
31508
31689
  return { session, client: authedWireClient(session, client) };
31509
31690
  };
@@ -31573,8 +31754,7 @@ function composePaymentsV2(spec) {
31573
31754
  identity: identitySlice,
31574
31755
  network,
31575
31756
  kv,
31576
- emitStatus: (status) => host.emit("connection:status", { status }),
31577
- onEpochChange: async () => void 0
31757
+ emitStatus: (status) => host.emit("connection:status", { status })
31578
31758
  });
31579
31759
  const deps = {
31580
31760
  session: transport.session,
@@ -31604,7 +31784,10 @@ function composePaymentsV2(spec) {
31604
31784
  network,
31605
31785
  ownPubkey: identity.chainPubkey,
31606
31786
  ownNametag: host.nametag,
31607
- requestMemo: requestMemoCodec(identity.privateKey)
31787
+ requestMemo: requestMemoCodec(identity.privateKey),
31788
+ // §5.1: the session is the epoch authority — the facade registers its
31789
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
31790
+ syncEpoch: () => transport.session.currentEpoch()
31608
31791
  };
31609
31792
  return new PaymentsFacade(deps);
31610
31793
  }
@@ -31934,18 +32117,20 @@ var Sphere = class _Sphere {
31934
32117
  }
31935
32118
  /**
31936
32119
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
31937
- * fail-closed at init, never a silently-degraded composition.
32120
+ * fail-closed at init, never a silently-degraded composition. Returns the
32121
+ * resolved composition (null = flag off); #728: resolving here also asserts
32122
+ * the single-network invariant (walletApi.network known + equal to the
32123
+ * Sphere network) before any session/KV/provider exists.
31938
32124
  */
31939
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
31940
- if (flag !== true) return false;
32125
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
32126
+ if (flag !== true) return null;
31941
32127
  if (accountingConfig || swapConfig) {
31942
32128
  throw new SphereError(
31943
32129
  "paymentsV2 is incompatible with the accounting/swap modules (both are built on the legacy PaymentsModule and are deleted with the P11 flip) \u2014 disable them or the flag.",
31944
32130
  "INVALID_CONFIG"
31945
32131
  );
31946
32132
  }
31947
- resolvePaymentsV2Composition(walletApi);
31948
- return true;
32133
+ return resolvePaymentsV2Composition(walletApi, network);
31949
32134
  }
31950
32135
  /**
31951
32136
  * Configure TokenRegistry in the main bundle context.
@@ -32000,12 +32185,14 @@ var Sphere = class _Sphere {
32000
32185
  options.walletApi
32001
32186
  );
32002
32187
  sphere._verification = options.verification;
32003
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32188
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
32004
32189
  options.paymentsV2,
32005
32190
  accountingConfig,
32006
32191
  swapConfig,
32007
- options.walletApi
32192
+ options.walletApi,
32193
+ options.network
32008
32194
  );
32195
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
32009
32196
  sphere._password = options.password ?? null;
32010
32197
  sphere._passwordProtected = sphere._password !== null;
32011
32198
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -32072,12 +32259,14 @@ var Sphere = class _Sphere {
32072
32259
  options.walletApi
32073
32260
  );
32074
32261
  sphere._verification = options.verification;
32075
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32262
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
32076
32263
  options.paymentsV2,
32077
32264
  accountingConfig,
32078
32265
  swapConfig,
32079
- options.walletApi
32266
+ options.walletApi,
32267
+ options.network
32080
32268
  );
32269
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
32081
32270
  sphere._password = options.password ?? null;
32082
32271
  sphere._passwordProtected = sphere._password !== null;
32083
32272
  if (!options.storage.isConnected()) {
@@ -32151,12 +32340,14 @@ var Sphere = class _Sphere {
32151
32340
  options.walletApi
32152
32341
  );
32153
32342
  sphere._verification = options.verification;
32154
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32343
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
32155
32344
  options.paymentsV2,
32156
32345
  accountingConfig,
32157
32346
  swapConfig,
32158
- options.walletApi
32347
+ options.walletApi,
32348
+ options.network
32159
32349
  );
32350
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
32160
32351
  sphere._password = options.password ?? null;
32161
32352
  sphere._passwordProtected = sphere._password !== null;
32162
32353
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -34814,7 +35005,9 @@ var Sphere = class _Sphere {
34814
35005
  * setOracleApiKey rebuild is what future operations snapshot.
34815
35006
  */
34816
35007
  async startPaymentsV2Inner(index, identity) {
34817
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
35008
+ if (this._paymentsV2Composition === null) {
35009
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
35010
+ }
34818
35011
  if (!this._addressModules.get(index)?.tokenEngine) {
34819
35012
  throw new SphereError(
34820
35013
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",