@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.
@@ -10674,14 +10674,14 @@ var IntentResume = class {
10674
10674
  }
10675
10675
  let changeOutput = null;
10676
10676
  if (payload.split) {
10677
- const splitOpIndex = payload.direct.length;
10677
+ const splitOpIndex2 = payload.direct.length;
10678
10678
  let splitDelivered = false;
10679
10679
  {
10680
10680
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
10681
10681
  const outcome = await this.host.certifyOperation(
10682
10682
  {
10683
10683
  kind: "split",
10684
- opIndex: splitOpIndex,
10684
+ opIndex: splitOpIndex2,
10685
10685
  uiTokenId: `v2_${payload.split.tokenId}`,
10686
10686
  genesisId: payload.split.tokenId,
10687
10687
  sourceSdkData: "",
@@ -20785,6 +20785,7 @@ var AccountingModule = class _AccountingModule {
20785
20785
  const unsubHistory = deps.on(
20786
20786
  "history:updated",
20787
20787
  (entry) => {
20788
+ if (!("dedupKey" in entry)) return;
20788
20789
  this._handleHistoryUpdated(entry).catch((err) => {
20789
20790
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
20790
20791
  });
@@ -26054,7 +26055,10 @@ var STORE_KEYS = {
26054
26055
  shortfalls: "shortfalls",
26055
26056
  settlingLinks: "settling",
26056
26057
  streamCursor: (s) => `cursor:${s}`,
26057
- epochLatch: "epoch-latch"
26058
+ epochLatch: "epoch-latch",
26059
+ // §5.2 InventoryView durable overlays (#625/#679).
26060
+ suspectedSpent: "suspected-spent",
26061
+ knownSpends: "known-spends"
26058
26062
  };
26059
26063
 
26060
26064
  // modules/payments-v2/machine/journal.ts
@@ -26304,7 +26308,7 @@ var TransferMachine = class {
26304
26308
  }
26305
26309
  if (cls === null) {
26306
26310
  await this.applyCommitted(engine, plan.transferId, committed);
26307
- this.closeTail(plan.transferId, plan.payload);
26311
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
26308
26312
  return { committed, deliveryPending };
26309
26313
  }
26310
26314
  if (cls === "conflict" && committed.length > 0) {
@@ -26330,10 +26334,10 @@ var TransferMachine = class {
26330
26334
  }
26331
26335
  await this.applyCommitted(engine, r.transferId, committed);
26332
26336
  if (conflicts.length > 0) {
26333
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
26337
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
26334
26338
  }
26335
26339
  await this.completeIntent(r.transferId, r.payload);
26336
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26340
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26337
26341
  return { deliveryPending, partial: null };
26338
26342
  }
26339
26343
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -26515,7 +26519,7 @@ var TransferMachine = class {
26515
26519
  }
26516
26520
  return added;
26517
26521
  }
26518
- async settlePartial(r, committed, conflicts) {
26522
+ async settlePartial(engine, r, committed, conflicts) {
26519
26523
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
26520
26524
  const entry = {
26521
26525
  transferId: r.transferId,
@@ -26527,26 +26531,27 @@ var TransferMachine = class {
26527
26531
  };
26528
26532
  await this.stores.shortfalls.upsert(entry);
26529
26533
  await this.completeIntent(r.transferId, r.payload);
26530
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26534
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26531
26535
  return entry;
26532
26536
  }
26533
- closeTail(transferId, payload) {
26537
+ closeTail(engine, transferId, payload, committed) {
26534
26538
  void (async () => {
26535
26539
  try {
26536
26540
  await this.completeIntent(transferId, payload);
26537
26541
  } catch {
26538
26542
  return;
26539
26543
  }
26540
- await this.finishBookkeeping(transferId, payload, "sent");
26544
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
26541
26545
  })();
26542
26546
  }
26543
26547
  async completeIntent(transferId, payload) {
26544
26548
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
26545
26549
  await this.deps.intents.complete(transferId, signature);
26546
26550
  }
26547
- async finishBookkeeping(transferId, payload, phase) {
26551
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
26548
26552
  try {
26549
- await this.deps.recordHistory?.({ transferId, payload, phase });
26553
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
26554
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
26550
26555
  } catch {
26551
26556
  }
26552
26557
  try {
@@ -26554,6 +26559,26 @@ var TransferMachine = class {
26554
26559
  } catch {
26555
26560
  }
26556
26561
  }
26562
+ /**
26563
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
26564
+ * certified recipient blobs (summarize()'s committed set is the only source;
26565
+ * a journal-rehydrated resume leg has no source token, but every committed
26566
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
26567
+ */
26568
+ async settledAmount(engine, coinId, committed) {
26569
+ let total = 0n;
26570
+ for (const outcome of committed) {
26571
+ if (outcome.recipientBlob === void 0) continue;
26572
+ const token = await engine.decodeToken({
26573
+ v: TOKEN_BLOB_VERSION,
26574
+ network: 0,
26575
+ tokenId: "",
26576
+ token: outcome.recipientBlob
26577
+ });
26578
+ total += engine.balanceOf(token, coinId);
26579
+ }
26580
+ return total.toString();
26581
+ }
26557
26582
  async disposeOnFailure(transferId, committed, cls) {
26558
26583
  if (committed.length > 0 || cls === "keep-open") return;
26559
26584
  try {
@@ -26578,7 +26603,7 @@ function conflictAmount(engine, payload, op, source) {
26578
26603
  }
26579
26604
 
26580
26605
  // modules/payments-v2/machine/resume.ts
26581
- async function resumeAll(deps) {
26606
+ async function resumeAll(deps, options = {}) {
26582
26607
  const report = { resumed: [], conflicted: [], failed: [] };
26583
26608
  const stores = createMachineStores(deps.kv);
26584
26609
  const open = await deps.intents.listOpen();
@@ -26588,7 +26613,12 @@ async function resumeAll(deps) {
26588
26613
  } catch {
26589
26614
  return report;
26590
26615
  }
26591
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
26616
+ await dropStaleBackstops(
26617
+ stores,
26618
+ locals,
26619
+ new Set(open.map((i) => i.transferId)),
26620
+ options.isLocallyActive ?? (() => false)
26621
+ );
26592
26622
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
26593
26623
  const ctx = {
26594
26624
  deps,
@@ -26601,11 +26631,16 @@ async function resumeAll(deps) {
26601
26631
  }
26602
26632
  return report;
26603
26633
  }
26604
- async function dropStaleLocalAborts(stores, locals, openIds) {
26634
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
26605
26635
  for (const entry of locals.values()) {
26606
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
26636
+ if (openIds.has(entry.transferId)) continue;
26637
+ if (entry.disposition !== "open") {
26607
26638
  await stores.backstop.removeByKey(entry.transferId);
26639
+ continue;
26608
26640
  }
26641
+ if (isLocallyActive(entry.transferId)) continue;
26642
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
26643
+ await stores.backstop.removeByKey(entry.transferId);
26609
26644
  }
26610
26645
  }
26611
26646
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -26813,7 +26848,9 @@ var Converger = class {
26813
26848
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
26814
26849
  }
26815
26850
  async resumePass() {
26816
- const report = await resumeAll(this.filteredMachineDeps());
26851
+ const report = await resumeAll(this.filteredMachineDeps(), {
26852
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
26853
+ });
26817
26854
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
26818
26855
  await this.deps.reconcile({
26819
26856
  resumed: report.resumed,
@@ -26929,6 +26966,136 @@ function distinctOpCount(entries) {
26929
26966
  return new Set(entries.map((entry) => entry.opIndex)).size;
26930
26967
  }
26931
26968
 
26969
+ // modules/payments-v2/restore.ts
26970
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
26971
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
26972
+ async function reseedAndReset(deps) {
26973
+ for (const entry of await deps.stores.backstop.list()) {
26974
+ if (entry.disposition !== "open") continue;
26975
+ try {
26976
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
26977
+ if (entry.requiresSeedClose) {
26978
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
26979
+ }
26980
+ } catch (err) {
26981
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
26982
+ }
26983
+ }
26984
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
26985
+ await deps.fullRePull();
26986
+ }
26987
+ async function splitOpIndex(deps, envelope) {
26988
+ const payload = await deps.decryptPayload(envelope);
26989
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
26990
+ }
26991
+
26992
+ // modules/payments-v2/inventory/presentation.ts
26993
+ function toToken(tokenId, snapshot, asset, registry, flags) {
26994
+ const iconUrl = registry.getIconUrl(asset.coinId);
26995
+ return {
26996
+ id: tokenId,
26997
+ coinId: asset.coinId,
26998
+ symbol: registry.getSymbol(asset.coinId),
26999
+ name: registry.getName(asset.coinId),
27000
+ decimals: registry.getDecimals(asset.coinId),
27001
+ ...iconUrl !== null ? { iconUrl } : {},
27002
+ amount: asset.amount,
27003
+ status: flags.transferring ? "transferring" : "confirmed",
27004
+ createdAt: snapshot.createdAt,
27005
+ updatedAt: snapshot.updatedAt,
27006
+ lazy: true,
27007
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
27008
+ };
27009
+ }
27010
+ function aggregateAssets(entries, isTransferring, registry) {
27011
+ const groups = /* @__PURE__ */ new Map();
27012
+ for (const [tokenId, entry] of entries) {
27013
+ const moving = isTransferring(tokenId);
27014
+ for (const asset of entry.assets) {
27015
+ let group = groups.get(asset.coinId);
27016
+ if (!group) {
27017
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
27018
+ groups.set(asset.coinId, group);
27019
+ }
27020
+ if (moving) {
27021
+ group.transferring += BigInt(asset.amount);
27022
+ group.transferringCount += 1;
27023
+ } else {
27024
+ group.total += BigInt(asset.amount);
27025
+ group.count += 1;
27026
+ }
27027
+ }
27028
+ }
27029
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
27030
+ }
27031
+ function toAsset(coinId, totals, registry) {
27032
+ const iconUrl = registry.getIconUrl(coinId);
27033
+ return {
27034
+ coinId,
27035
+ symbol: registry.getSymbol(coinId),
27036
+ name: registry.getName(coinId),
27037
+ decimals: registry.getDecimals(coinId),
27038
+ ...iconUrl !== null ? { iconUrl } : {},
27039
+ totalAmount: totals.total.toString(),
27040
+ tokenCount: totals.count,
27041
+ confirmedAmount: totals.total.toString(),
27042
+ unconfirmedAmount: "0",
27043
+ confirmedTokenCount: totals.count,
27044
+ unconfirmedTokenCount: 0,
27045
+ transferringTokenCount: totals.transferringCount,
27046
+ transferringAmount: totals.transferring.toString(),
27047
+ priceUsd: null,
27048
+ priceEur: null,
27049
+ change24h: null,
27050
+ fiatValueUsd: null,
27051
+ fiatValueEur: null
27052
+ };
27053
+ }
27054
+ async function withPrices(raw, registry, price) {
27055
+ try {
27056
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
27057
+ const quotes = await price.getPrices(names);
27058
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
27059
+ } catch {
27060
+ return raw;
27061
+ }
27062
+ }
27063
+ function priceOne(asset, quote) {
27064
+ if (!quote) return asset;
27065
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
27066
+ return {
27067
+ ...asset,
27068
+ priceUsd: quote.priceUsd,
27069
+ priceEur: quote.priceEur ?? null,
27070
+ change24h: quote.change24h ?? null,
27071
+ fiatValueUsd: human * quote.priceUsd,
27072
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
27073
+ };
27074
+ }
27075
+
27076
+ // modules/payments-v2/machine/payload.ts
27077
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
27078
+ return {
27079
+ v: 2,
27080
+ recipient: recipientPubkey,
27081
+ coinId: request.coinId,
27082
+ amount: request.amount,
27083
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
27084
+ direct: [...spend.plan.direct],
27085
+ ...spend.plan.split !== void 0 ? {
27086
+ split: {
27087
+ tokenId: spend.plan.split.tokenId,
27088
+ splitAmount: spend.plan.split.splitAmount.toString(),
27089
+ remainderAmount: spend.plan.split.remainderAmount.toString()
27090
+ }
27091
+ } : {},
27092
+ spentStates
27093
+ };
27094
+ }
27095
+ function messageOf(err) {
27096
+ return err instanceof Error ? err.message : String(err);
27097
+ }
27098
+
26932
27099
  // modules/payments-v2/history/History.ts
26933
27100
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
26934
27101
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -26987,13 +27154,14 @@ var History = class {
26987
27154
  }
26988
27155
  // ── internals ────────────────────────────────────────────────────────────────
26989
27156
  async post(build) {
27157
+ const record = build();
26990
27158
  try {
26991
- await this.deps.client.postHistory([build()]);
27159
+ await this.deps.client.postHistory([record]);
26992
27160
  } catch (err) {
26993
27161
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
26994
27162
  return;
26995
27163
  }
26996
- this.deps.emit("history:updated");
27164
+ this.deps.emit("history:updated", this.toEntry(record));
26997
27165
  }
26998
27166
  ts(timestamp) {
26999
27167
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -27050,93 +27218,9 @@ var History = class {
27050
27218
  }
27051
27219
  };
27052
27220
 
27053
- // modules/payments-v2/inventory/presentation.ts
27054
- function toToken(tokenId, snapshot, asset, registry, flags) {
27055
- const iconUrl = registry.getIconUrl(asset.coinId);
27056
- return {
27057
- id: tokenId,
27058
- coinId: asset.coinId,
27059
- symbol: registry.getSymbol(asset.coinId),
27060
- name: registry.getName(asset.coinId),
27061
- decimals: registry.getDecimals(asset.coinId),
27062
- ...iconUrl !== null ? { iconUrl } : {},
27063
- amount: asset.amount,
27064
- status: flags.transferring ? "transferring" : "confirmed",
27065
- createdAt: snapshot.createdAt,
27066
- updatedAt: snapshot.updatedAt,
27067
- lazy: true,
27068
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
27069
- };
27070
- }
27071
- function aggregateAssets(entries, isTransferring, registry) {
27072
- const groups = /* @__PURE__ */ new Map();
27073
- for (const [tokenId, entry] of entries) {
27074
- const moving = isTransferring(tokenId);
27075
- for (const asset of entry.assets) {
27076
- let group = groups.get(asset.coinId);
27077
- if (!group) {
27078
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
27079
- groups.set(asset.coinId, group);
27080
- }
27081
- if (moving) {
27082
- group.transferring += BigInt(asset.amount);
27083
- group.transferringCount += 1;
27084
- } else {
27085
- group.total += BigInt(asset.amount);
27086
- group.count += 1;
27087
- }
27088
- }
27089
- }
27090
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
27091
- }
27092
- function toAsset(coinId, totals, registry) {
27093
- const iconUrl = registry.getIconUrl(coinId);
27094
- return {
27095
- coinId,
27096
- symbol: registry.getSymbol(coinId),
27097
- name: registry.getName(coinId),
27098
- decimals: registry.getDecimals(coinId),
27099
- ...iconUrl !== null ? { iconUrl } : {},
27100
- totalAmount: totals.total.toString(),
27101
- tokenCount: totals.count,
27102
- confirmedAmount: totals.total.toString(),
27103
- unconfirmedAmount: "0",
27104
- confirmedTokenCount: totals.count,
27105
- unconfirmedTokenCount: 0,
27106
- transferringTokenCount: totals.transferringCount,
27107
- transferringAmount: totals.transferring.toString(),
27108
- priceUsd: null,
27109
- priceEur: null,
27110
- change24h: null,
27111
- fiatValueUsd: null,
27112
- fiatValueEur: null
27113
- };
27114
- }
27115
- async function withPrices(raw, registry, price) {
27116
- try {
27117
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
27118
- const quotes = await price.getPrices(names);
27119
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
27120
- } catch {
27121
- return raw;
27122
- }
27123
- }
27124
- function priceOne(asset, quote) {
27125
- if (!quote) return asset;
27126
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
27127
- return {
27128
- ...asset,
27129
- priceUsd: quote.priceUsd,
27130
- priceEur: quote.priceEur ?? null,
27131
- change24h: quote.change24h ?? null,
27132
- fiatValueUsd: human * quote.priceUsd,
27133
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
27134
- };
27135
- }
27136
-
27137
27221
  // modules/payments-v2/inventory/InventoryView.ts
27138
- var SUSPECTED_KEY = "suspected-spent";
27139
- var KNOWN_SPENDS_KEY = "known-spends";
27222
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
27223
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
27140
27224
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
27141
27225
  function stateKey(tokenId, stateHash) {
27142
27226
  return `${tokenId}:${stateHash}`;
@@ -27440,19 +27524,24 @@ var Receive = class {
27440
27524
  const engine = deps.engine();
27441
27525
  const stored = [];
27442
27526
  const pending = [];
27443
- let epoch = "";
27527
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
27444
27528
  try {
27445
- epoch = deps.syncEpoch();
27446
27529
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
27447
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
27448
- for await (const entry of deps.delivery.incoming(since)) {
27449
- await this.processEntry(deps, engine, entry, pending, stored);
27450
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
27530
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
27531
+ for (let pass = 0; pass < 2; pass++) {
27532
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
27533
+ await this.processEntry(deps, engine, entry, pending, stored);
27534
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
27535
+ }
27536
+ await flushAcks(deps, pending, pageEpoch);
27537
+ const served = deps.delivery.incomingEpoch();
27538
+ if (basis === null || served === null || served === basis.syncEpoch) break;
27539
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
27540
+ basis = null;
27451
27541
  }
27452
- await flushAcks(deps, pending, epoch);
27453
27542
  } catch (err) {
27454
27543
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
27455
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
27544
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
27456
27545
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
27457
27546
  });
27458
27547
  }
@@ -27525,7 +27614,7 @@ async function announce(deps, entry, record) {
27525
27614
  receivedAt
27526
27615
  };
27527
27616
  }
27528
- async function flushAcks(deps, pending, epoch) {
27617
+ async function flushAcks(deps, pending, epochOf) {
27529
27618
  let lastAcked = null;
27530
27619
  try {
27531
27620
  while (pending.length > 0) {
@@ -27536,7 +27625,7 @@ async function flushAcks(deps, pending, epoch) {
27536
27625
  }
27537
27626
  } finally {
27538
27627
  if (lastAcked !== null) {
27539
- const record = { cursor: lastAcked, syncEpoch: epoch };
27628
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
27540
27629
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
27541
27630
  }
27542
27631
  }
@@ -27637,9 +27726,19 @@ var Requests = class {
27637
27726
  }
27638
27727
  });
27639
27728
  }
27729
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
27640
27730
  writeLink(requestId, transferId, committed) {
27641
27731
  return this.mutateJournal((m) => {
27642
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
27732
+ const prev = m.get(requestId);
27733
+ const same = prev !== void 0 && prev.transferId === transferId;
27734
+ const next = {
27735
+ requestId,
27736
+ transferId,
27737
+ committed: committed || same && prev.committed,
27738
+ createdAt: same ? prev.createdAt : this.now()
27739
+ };
27740
+ if (same && prev.committed === next.committed) return false;
27741
+ m.set(requestId, next);
27643
27742
  return true;
27644
27743
  });
27645
27744
  }
@@ -27792,23 +27891,46 @@ var Requests = class {
27792
27891
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
27793
27892
  if (transferId !== void 0) {
27794
27893
  const committed = error instanceof PartialSendConflictError;
27795
- await this.writeLink(id, transferId, committed);
27796
- this.setStatus(id, "settling");
27894
+ await this.settle(id, transferId, { committed, respond: false });
27797
27895
  } else {
27798
27896
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
27799
27897
  this.setStatus(id, "paid");
27800
27898
  }
27801
27899
  } else {
27802
- await this.clearLink(id);
27803
- this.setStatus(id, "pending");
27900
+ await this.revertPayable(id);
27804
27901
  }
27805
27902
  throw error;
27806
27903
  }
27807
- this.setStatus(id, "paid");
27808
- await this.respondPaid(id, result.id);
27809
- await this.clearLink(id);
27904
+ await this.settle(id, result.id, { committed: true, respond: true });
27810
27905
  return result;
27811
27906
  }
27907
+ /**
27908
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
27909
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
27910
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
27911
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
27912
+ * not a reload. Every path that binds a request to a transfer outcome funnels
27913
+ * through here — pay()'s clean success (respond now), pay()'s
27914
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
27915
+ * — so a failed respond always leaves the link + 'settling' and the next
27916
+ * reconcile pass (the committed-link override) retries the respond.
27917
+ */
27918
+ async settle(requestId, transferId, opts) {
27919
+ let journalDown = false;
27920
+ try {
27921
+ await this.writeLink(requestId, transferId, opts.committed);
27922
+ } catch (err) {
27923
+ if (!opts.respond) throw err;
27924
+ journalDown = true;
27925
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
27926
+ }
27927
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
27928
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
27929
+ return;
27930
+ }
27931
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
27932
+ this.setStatus(requestId, "paid");
27933
+ }
27812
27934
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
27813
27935
  async respondPaid(id, transferId) {
27814
27936
  try {
@@ -27844,32 +27966,32 @@ var Requests = class {
27844
27966
  let aborted = null;
27845
27967
  for (const [requestId, link] of [...this.journal]) {
27846
27968
  if (link.committed || resumed.has(link.transferId)) {
27847
- await this.resolvePaid(requestId, link.transferId);
27969
+ await this.resolvePaid(requestId, link);
27848
27970
  } else if (conflicted.has(link.transferId)) {
27849
27971
  await this.revertPayable(requestId);
27850
27972
  } else if (open.has(link.transferId)) {
27851
27973
  } else {
27852
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
27974
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
27853
27975
  }
27854
27976
  }
27855
27977
  }
27856
27978
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
27857
27979
  // a probe failure defers the link to the next session.
27858
- async reconcileUnaccounted(requestId, transferId, aborted) {
27980
+ async reconcileUnaccounted(requestId, link, aborted) {
27859
27981
  try {
27860
27982
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
27861
- if (aborted.has(transferId)) await this.revertPayable(requestId);
27862
- else await this.resolvePaid(requestId, transferId);
27983
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
27984
+ else await this.resolvePaid(requestId, link);
27863
27985
  } catch (err) {
27864
27986
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
27865
27987
  }
27866
27988
  return aborted;
27867
27989
  }
27868
- async resolvePaid(requestId, transferId) {
27869
- if (!await this.respondPaid(requestId, transferId)) return;
27870
- await this.clearLink(requestId);
27871
- this.setStatus(requestId, "paid");
27990
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
27991
+ resolvePaid(requestId, link) {
27992
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
27872
27993
  }
27994
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
27873
27995
  async revertPayable(requestId) {
27874
27996
  await this.clearLink(requestId);
27875
27997
  this.setStatus(requestId, "pending");
@@ -28193,11 +28315,12 @@ function composeFacadeParts(deps, hooks) {
28193
28315
  client: deps.client,
28194
28316
  fieldKey: deps.fieldKey,
28195
28317
  registry: deps.registry,
28196
- emit: (event) => deps.emit(event, {}),
28318
+ emit: (event, entry) => deps.emit(event, entry),
28197
28319
  ...deps.now !== void 0 ? { now: deps.now } : {},
28198
28320
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
28199
28321
  });
28200
28322
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
28323
+ const machineStores = createMachineStores(deps.kv);
28201
28324
  const heldStates = /* @__PURE__ */ new Map();
28202
28325
  return {
28203
28326
  ownPubkeyBytes,
@@ -28205,12 +28328,29 @@ function composeFacadeParts(deps, hooks) {
28205
28328
  ledger,
28206
28329
  queue,
28207
28330
  historyStore,
28208
- machineStores: createMachineStores(deps.kv),
28331
+ machineStores,
28209
28332
  machineDeps,
28210
28333
  machine: new TransferMachine(machineDeps),
28211
28334
  heldStates,
28212
28335
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
28213
- requests: buildRequests(deps, hooks)
28336
+ requests: buildRequests(deps, hooks),
28337
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
28338
+ };
28339
+ }
28340
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
28341
+ return {
28342
+ stores: machineStores,
28343
+ kv: deps.kv,
28344
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
28345
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
28346
+ decryptPayload: machineDeps.decryptPayload,
28347
+ fullRePull: () => view.onEpochReset(),
28348
+ attention: (transferId, code, detail) => {
28349
+ deps.emit(
28350
+ "transfer:attention",
28351
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28352
+ );
28353
+ }
28214
28354
  };
28215
28355
  }
28216
28356
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -28232,11 +28372,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
28232
28372
  ownPubkey: hexToBytes4(deps.ownPubkey),
28233
28373
  emit: deps.emit,
28234
28374
  now: deps.now ?? Date.now,
28235
- recordHistory: async ({ transferId, payload }) => {
28375
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
28236
28376
  await historyStore.recordSent({
28237
28377
  transferId,
28238
28378
  coinId: payload.coinId,
28239
- amount: payload.amount,
28379
+ // §5.9: the SETTLED amount (machine-computed from the certified
28380
+ // recipient blobs), never payload.amount — the plan.
28381
+ amount: committedAmount,
28240
28382
  recipientPubkey: payload.recipient,
28241
28383
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
28242
28384
  });
@@ -28263,7 +28405,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
28263
28405
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
28264
28406
  );
28265
28407
  },
28266
- syncEpoch: deps.syncEpoch ?? (() => ""),
28408
+ syncEpoch: deps.syncEpoch,
28267
28409
  ...deps.now !== void 0 ? { now: deps.now } : {}
28268
28410
  });
28269
28411
  }
@@ -28316,6 +28458,7 @@ var PaymentsFacade = class {
28316
28458
  this.receiveLoop = parts.receiveLoop;
28317
28459
  this.heldStates = parts.heldStates;
28318
28460
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
28461
+ this.restoreDeps = parts.restoreDeps;
28319
28462
  this.requests = parts.requests;
28320
28463
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
28321
28464
  this.heartbeat = new ConvergenceHeartbeat({
@@ -28344,6 +28487,7 @@ var PaymentsFacade = class {
28344
28487
  receiveLoop;
28345
28488
  heldStates;
28346
28489
  ownPubkeyBytes;
28490
+ restoreDeps;
28347
28491
  requests;
28348
28492
  currentEngine = null;
28349
28493
  pendingOps = /* @__PURE__ */ new Set();
@@ -28354,6 +28498,8 @@ var PaymentsFacade = class {
28354
28498
  converger;
28355
28499
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
28356
28500
  resumeFlight = new SingleFlight();
28501
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
28502
+ passChain = new SerialChain();
28357
28503
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
28358
28504
  activeMoneyOps = /* @__PURE__ */ new Set();
28359
28505
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -28361,8 +28507,8 @@ var PaymentsFacade = class {
28361
28507
  if (this.started) return;
28362
28508
  this.started = true;
28363
28509
  this.heartbeat.start();
28364
- await this.deps.session.start();
28365
28510
  this.unsubscribers.push(
28511
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
28366
28512
  this.deps.session.subscribeStream("mailbox", () => {
28367
28513
  this.trackTail(this.receiveLoop.drainOnce());
28368
28514
  }),
@@ -28377,6 +28523,7 @@ var PaymentsFacade = class {
28377
28523
  if (status === "connected") this.heartbeat.recovered();
28378
28524
  });
28379
28525
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
28526
+ await this.deps.session.start();
28380
28527
  this.trackTail(this.runConvergencePass());
28381
28528
  this.trackTail(this.seedHeldStates());
28382
28529
  await this.view.fullPull().catch(() => void 0);
@@ -28417,7 +28564,7 @@ var PaymentsFacade = class {
28417
28564
  }
28418
28565
  // ── money movement ─────────────────────────────────────────────────────────
28419
28566
  send(request) {
28420
- return this.track(this.sendWithPolicy(request));
28567
+ return this.track(this.sendOutcome(request));
28421
28568
  }
28422
28569
  async receive() {
28423
28570
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -28432,20 +28579,27 @@ var PaymentsFacade = class {
28432
28579
  if (!this.started) return;
28433
28580
  await this.track(this.runConvergencePass());
28434
28581
  }
28582
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
28583
+ async handleEpochChange(_newEpoch) {
28584
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
28585
+ await this.passChain.enqueue(() => this.convergeBody());
28586
+ }
28435
28587
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
28436
28588
  runConvergencePass() {
28437
- return this.resumeFlight.run(async () => {
28438
- const outcome = await this.converger.convergeOnce();
28439
- const pending = await this.converger.pendingWork().catch(() => true);
28440
- this.heartbeat.settle(outcome, pending);
28441
- });
28589
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
28590
+ }
28591
+ async convergeBody() {
28592
+ const outcome = await this.converger.convergeOnce();
28593
+ const pending = await this.converger.pendingWork().catch(() => true);
28594
+ this.heartbeat.settle(outcome, pending);
28442
28595
  }
28443
28596
  nowMs() {
28444
28597
  return (this.deps.now ?? Date.now)();
28445
28598
  }
28446
28599
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
28447
- async sendWithPolicy(request) {
28448
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
28600
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
28601
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
28602
+ async sendOutcome(request) {
28449
28603
  const run = {
28450
28604
  amount: request.amount,
28451
28605
  delivered: [],
@@ -28453,12 +28607,36 @@ var PaymentsFacade = class {
28453
28607
  tokenTransfers: [],
28454
28608
  settledShortfalls: [],
28455
28609
  firstPartialId: void 0,
28456
- pendingAny: false
28610
+ pendingAny: false,
28611
+ lastTransferId: ""
28612
+ };
28613
+ try {
28614
+ return await this.sendWithPolicy(request, run);
28615
+ } catch (err) {
28616
+ this.emitCleanFailure(err, run.lastTransferId);
28617
+ throw err;
28618
+ }
28619
+ }
28620
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
28621
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
28622
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
28623
+ emitCleanFailure(err, transferId) {
28624
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
28625
+ const failed = {
28626
+ id: transferId,
28627
+ status: "failed",
28628
+ tokens: [],
28629
+ tokenTransfers: [],
28630
+ error: messageOf(err)
28457
28631
  };
28632
+ this.deps.emit("transfer:updated", failed);
28633
+ }
28634
+ async sendWithPolicy(request, run) {
28635
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
28458
28636
  for (let attempt = 0; ; attempt++) {
28459
28637
  let ctx;
28460
28638
  try {
28461
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
28639
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
28462
28640
  } catch (err) {
28463
28641
  throw this.partialize(err, run);
28464
28642
  }
@@ -28503,11 +28681,8 @@ var PaymentsFacade = class {
28503
28681
  this.activeMoneyOps.delete(ctx.transferId);
28504
28682
  }
28505
28683
  }
28506
- /**
28507
- * Partial outcome (#677/#690): the shortfall is already durable (written by
28508
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
28509
- * the remainder under a NEW transferId — never the full amount.
28510
- */
28684
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
28685
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
28511
28686
  async consumePartial(ctx, disposition, run, attempt) {
28512
28687
  if (disposition.deliveryPending) {
28513
28688
  run.pendingAny = true;
@@ -28533,11 +28708,8 @@ var PaymentsFacade = class {
28533
28708
  run.amount = partial.remainingAmount;
28534
28709
  return null;
28535
28710
  }
28536
- /**
28537
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
28538
- * backstop still 'open' (committed>0) → converge via the same machine's
28539
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
28540
- */
28711
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
28712
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
28541
28713
  async disposeFailedAttempt(ctx, err) {
28542
28714
  if (isPossiblyCommittedSendOutcome(err)) {
28543
28715
  this.settleKeepOpen(ctx);
@@ -28586,8 +28758,9 @@ var PaymentsFacade = class {
28586
28758
  }
28587
28759
  return { kind: "rethrow", error: err };
28588
28760
  }
28589
- async planAndMaterialize(recipientPubkey, request) {
28761
+ async planAndMaterialize(recipientPubkey, request, run) {
28590
28762
  const transferId = this.newId();
28763
+ run.lastTransferId = transferId;
28591
28764
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
28592
28765
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
28593
28766
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -28719,11 +28892,7 @@ var PaymentsFacade = class {
28719
28892
  this.deps.emit("transfer:updated", result);
28720
28893
  return result;
28721
28894
  }
28722
- /**
28723
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
28724
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
28725
- * over the accumulated settled set — never bare, never a full-amount retry.
28726
- */
28895
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
28727
28896
  partialize(err, run) {
28728
28897
  if (run.delivered.length === 0) return err;
28729
28898
  return new PartialSendConflictError(
@@ -28875,22 +29044,14 @@ var PaymentsFacade = class {
28875
29044
  }
28876
29045
  }
28877
29046
  toUiToken(tokenId, coinId, amount) {
28878
- const registry = this.deps.registry;
28879
- const iconUrl = registry.getIconUrl(coinId);
28880
29047
  const now = this.nowMs();
28881
- return {
28882
- id: tokenId,
28883
- coinId,
28884
- symbol: registry.getSymbol(coinId),
28885
- name: registry.getName(coinId),
28886
- decimals: registry.getDecimals(coinId),
28887
- ...iconUrl !== null ? { iconUrl } : {},
28888
- amount: amount.toString(),
28889
- status: "transferring",
28890
- createdAt: now,
28891
- updatedAt: now,
28892
- lazy: true
28893
- };
29048
+ return toToken(
29049
+ tokenId,
29050
+ { assets: [], createdAt: now, updatedAt: now },
29051
+ { coinId, amount: amount.toString() },
29052
+ this.deps.registry,
29053
+ { transferring: true, suspectedSpent: false }
29054
+ );
28894
29055
  }
28895
29056
  track(op) {
28896
29057
  const settled = op.then(
@@ -28905,27 +29066,6 @@ var PaymentsFacade = class {
28905
29066
  this.track(op).catch(() => void 0);
28906
29067
  }
28907
29068
  };
28908
- function buildPayload(recipientPubkey, request, spend, spentStates) {
28909
- return {
28910
- v: 2,
28911
- recipient: recipientPubkey,
28912
- coinId: request.coinId,
28913
- amount: request.amount,
28914
- ...request.memo !== void 0 ? { memo: request.memo } : {},
28915
- direct: [...spend.plan.direct],
28916
- ...spend.plan.split !== void 0 ? {
28917
- split: {
28918
- tokenId: spend.plan.split.tokenId,
28919
- splitAmount: spend.plan.split.splitAmount.toString(),
28920
- remainderAmount: spend.plan.split.remainderAmount.toString()
28921
- }
28922
- } : {},
28923
- spentStates
28924
- };
28925
- }
28926
- function messageOf(err) {
28927
- return err instanceof Error ? err.message : String(err);
28928
- }
28929
29069
 
28930
29070
  // impl/wallet-api-v2/http.ts
28931
29071
  var WalletApiHttpError = class extends Error {
@@ -29271,6 +29411,7 @@ var WalletApiSession = class {
29271
29411
  epochLatch = null;
29272
29412
  reseedChain = Promise.resolve();
29273
29413
  reseedsPending = 0;
29414
+ epochHandlers = /* @__PURE__ */ new Set();
29274
29415
  handlers = /* @__PURE__ */ new Map();
29275
29416
  statusHandlers = /* @__PURE__ */ new Set();
29276
29417
  socket = null;
@@ -29373,6 +29514,18 @@ var WalletApiSession = class {
29373
29514
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
29374
29515
  }
29375
29516
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
29517
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
29518
+ currentEpoch() {
29519
+ return this.epochLatch ?? "";
29520
+ }
29521
+ /**
29522
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
29523
+ * the reseed chain, BEFORE any stream nudge resumes.
29524
+ */
29525
+ subscribeEpochChange(handler) {
29526
+ this.epochHandlers.add(handler);
29527
+ return () => this.epochHandlers.delete(handler);
29528
+ }
29376
29529
  noteEpoch(epoch) {
29377
29530
  if (this.epochLatch === epoch) return;
29378
29531
  const previous = this.epochLatch;
@@ -29386,7 +29539,7 @@ var WalletApiSession = class {
29386
29539
  }
29387
29540
  async runReseed(epoch, previous) {
29388
29541
  try {
29389
- await this.deps.onEpochChange(epoch);
29542
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
29390
29543
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
29391
29544
  } catch {
29392
29545
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -29648,6 +29801,7 @@ var WalletApiDeliveryPort = class {
29648
29801
  wake;
29649
29802
  deriveFn;
29650
29803
  seenChain = new SerialChain();
29804
+ lastIncomingEpoch = null;
29651
29805
  constructor(config) {
29652
29806
  this.client = config.client;
29653
29807
  this.kv = config.kv;
@@ -29742,6 +29896,7 @@ var WalletApiDeliveryPort = class {
29742
29896
  const seen = await this.readSeen();
29743
29897
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
29744
29898
  for (; ; ) {
29899
+ this.lastIncomingEpoch = String(page.syncEpoch);
29745
29900
  for (const entry of page.entries) {
29746
29901
  if (entry.status !== "unclaimed") continue;
29747
29902
  if (seen.has(entry.entryId)) continue;
@@ -29751,6 +29906,10 @@ var WalletApiDeliveryPort = class {
29751
29906
  page = await this.client.listMailbox(page.cursor);
29752
29907
  }
29753
29908
  }
29909
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
29910
+ incomingEpoch() {
29911
+ return this.lastIncomingEpoch;
29912
+ }
29754
29913
  toIncoming(entry) {
29755
29914
  const bundle = this.openEnvelope(entry);
29756
29915
  return {
@@ -29828,7 +29987,7 @@ var WalletApiSplitCheckpointStore = class {
29828
29987
  return utf8(`${transferId}:${String(opIndex)}`);
29829
29988
  }
29830
29989
  async put(transferId, opIndex, bytes) {
29831
- const { client, kv, fieldKey, signProgress } = this.config;
29990
+ const { kv, fieldKey } = this.config;
29832
29991
  const aad = this.aad(transferId, opIndex);
29833
29992
  const cacheKey = this.cacheKey(transferId, opIndex);
29834
29993
  let envelope = await kv.get(cacheKey);
@@ -29836,10 +29995,21 @@ var WalletApiSplitCheckpointStore = class {
29836
29995
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
29837
29996
  await kv.set(cacheKey, envelope);
29838
29997
  }
29839
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
29840
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
29998
+ const record = await this.post(transferId, opIndex, envelope);
29841
29999
  return this.open(record.payload, aad);
29842
30000
  }
30001
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
30002
+ async reseedCheckpoint(transferId, opIndex) {
30003
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
30004
+ if (envelope === null) return false;
30005
+ await this.post(transferId, opIndex, envelope);
30006
+ return true;
30007
+ }
30008
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
30009
+ async post(transferId, opIndex, envelope) {
30010
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
30011
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
30012
+ }
29843
30013
  async get(transferId, opIndex) {
29844
30014
  const records = await this.config.client.getProgress(transferId);
29845
30015
  const record = records.find((r) => r.opIndex === opIndex);
@@ -29862,7 +30032,7 @@ var WalletApiSplitCheckpointStore = class {
29862
30032
  };
29863
30033
 
29864
30034
  // core/payments-v2-wiring.ts
29865
- function resolvePaymentsV2Composition(walletApi) {
30035
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
29866
30036
  const source = walletApi;
29867
30037
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
29868
30038
  if (!source || network === null) {
@@ -29871,6 +30041,18 @@ function resolvePaymentsV2Composition(walletApi) {
29871
30041
  "INVALID_CONFIG"
29872
30042
  );
29873
30043
  }
30044
+ if (!(network in NETWORKS)) {
30045
+ throw new SphereError(
30046
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
30047
+ "INVALID_CONFIG"
30048
+ );
30049
+ }
30050
+ if (network !== sphereNetwork) {
30051
+ throw new SphereError(
30052
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
30053
+ "INVALID_CONFIG"
30054
+ );
30055
+ }
29874
30056
  if (typeof source.paymentsV2Transport === "function") {
29875
30057
  return { network, factory: source.paymentsV2Transport.bind(source) };
29876
30058
  }
@@ -29936,8 +30118,7 @@ function defaultPaymentsV2Transport(config) {
29936
30118
  deviceId: `${config.deviceId}:pv2`,
29937
30119
  kv: args.kv,
29938
30120
  webSocketFactory,
29939
- emitStatus: args.emitStatus,
29940
- onEpochChange: args.onEpochChange
30121
+ emitStatus: args.emitStatus
29941
30122
  });
29942
30123
  return { session, client: authedWireClient(session, client) };
29943
30124
  };
@@ -30007,8 +30188,7 @@ function composePaymentsV2(spec) {
30007
30188
  identity: identitySlice,
30008
30189
  network,
30009
30190
  kv,
30010
- emitStatus: (status) => host.emit("connection:status", { status }),
30011
- onEpochChange: async () => void 0
30191
+ emitStatus: (status) => host.emit("connection:status", { status })
30012
30192
  });
30013
30193
  const deps = {
30014
30194
  session: transport.session,
@@ -30038,7 +30218,10 @@ function composePaymentsV2(spec) {
30038
30218
  network,
30039
30219
  ownPubkey: identity.chainPubkey,
30040
30220
  ownNametag: host.nametag,
30041
- requestMemo: requestMemoCodec(identity.privateKey)
30221
+ requestMemo: requestMemoCodec(identity.privateKey),
30222
+ // §5.1: the session is the epoch authority — the facade registers its
30223
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
30224
+ syncEpoch: () => transport.session.currentEpoch()
30042
30225
  };
30043
30226
  return new PaymentsFacade(deps);
30044
30227
  }
@@ -30368,18 +30551,20 @@ var Sphere = class _Sphere {
30368
30551
  }
30369
30552
  /**
30370
30553
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
30371
- * fail-closed at init, never a silently-degraded composition.
30554
+ * fail-closed at init, never a silently-degraded composition. Returns the
30555
+ * resolved composition (null = flag off); #728: resolving here also asserts
30556
+ * the single-network invariant (walletApi.network known + equal to the
30557
+ * Sphere network) before any session/KV/provider exists.
30372
30558
  */
30373
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
30374
- if (flag !== true) return false;
30559
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
30560
+ if (flag !== true) return null;
30375
30561
  if (accountingConfig || swapConfig) {
30376
30562
  throw new SphereError(
30377
30563
  "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.",
30378
30564
  "INVALID_CONFIG"
30379
30565
  );
30380
30566
  }
30381
- resolvePaymentsV2Composition(walletApi);
30382
- return true;
30567
+ return resolvePaymentsV2Composition(walletApi, network);
30383
30568
  }
30384
30569
  /**
30385
30570
  * Configure TokenRegistry in the main bundle context.
@@ -30434,12 +30619,14 @@ var Sphere = class _Sphere {
30434
30619
  options.walletApi
30435
30620
  );
30436
30621
  sphere._verification = options.verification;
30437
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30622
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30438
30623
  options.paymentsV2,
30439
30624
  accountingConfig,
30440
30625
  swapConfig,
30441
- options.walletApi
30626
+ options.walletApi,
30627
+ options.network
30442
30628
  );
30629
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30443
30630
  sphere._password = options.password ?? null;
30444
30631
  sphere._passwordProtected = sphere._password !== null;
30445
30632
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -30506,12 +30693,14 @@ var Sphere = class _Sphere {
30506
30693
  options.walletApi
30507
30694
  );
30508
30695
  sphere._verification = options.verification;
30509
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30696
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30510
30697
  options.paymentsV2,
30511
30698
  accountingConfig,
30512
30699
  swapConfig,
30513
- options.walletApi
30700
+ options.walletApi,
30701
+ options.network
30514
30702
  );
30703
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30515
30704
  sphere._password = options.password ?? null;
30516
30705
  sphere._passwordProtected = sphere._password !== null;
30517
30706
  if (!options.storage.isConnected()) {
@@ -30585,12 +30774,14 @@ var Sphere = class _Sphere {
30585
30774
  options.walletApi
30586
30775
  );
30587
30776
  sphere._verification = options.verification;
30588
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30777
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30589
30778
  options.paymentsV2,
30590
30779
  accountingConfig,
30591
30780
  swapConfig,
30592
- options.walletApi
30781
+ options.walletApi,
30782
+ options.network
30593
30783
  );
30784
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30594
30785
  sphere._password = options.password ?? null;
30595
30786
  sphere._passwordProtected = sphere._password !== null;
30596
30787
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -33248,7 +33439,9 @@ var Sphere = class _Sphere {
33248
33439
  * setOracleApiKey rebuild is what future operations snapshot.
33249
33440
  */
33250
33441
  async startPaymentsV2Inner(index, identity) {
33251
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
33442
+ if (this._paymentsV2Composition === null) {
33443
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
33444
+ }
33252
33445
  if (!this._addressModules.get(index)?.tokenEngine) {
33253
33446
  throw new SphereError(
33254
33447
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",