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

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.js CHANGED
@@ -12083,14 +12083,14 @@ var IntentResume = class {
12083
12083
  }
12084
12084
  let changeOutput = null;
12085
12085
  if (payload.split) {
12086
- const splitOpIndex = payload.direct.length;
12086
+ const splitOpIndex2 = payload.direct.length;
12087
12087
  let splitDelivered = false;
12088
12088
  {
12089
12089
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
12090
12090
  const outcome = await this.host.certifyOperation(
12091
12091
  {
12092
12092
  kind: "split",
12093
- opIndex: splitOpIndex,
12093
+ opIndex: splitOpIndex2,
12094
12094
  uiTokenId: `v2_${payload.split.tokenId}`,
12095
12095
  genesisId: payload.split.tokenId,
12096
12096
  sourceSdkData: "",
@@ -22201,6 +22201,7 @@ var AccountingModule = class _AccountingModule {
22201
22201
  const unsubHistory = deps.on(
22202
22202
  "history:updated",
22203
22203
  (entry) => {
22204
+ if (!("dedupKey" in entry)) return;
22204
22205
  this._handleHistoryUpdated(entry).catch((err) => {
22205
22206
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
22206
22207
  });
@@ -27463,7 +27464,10 @@ var STORE_KEYS = {
27463
27464
  shortfalls: "shortfalls",
27464
27465
  settlingLinks: "settling",
27465
27466
  streamCursor: (s) => `cursor:${s}`,
27466
- epochLatch: "epoch-latch"
27467
+ epochLatch: "epoch-latch",
27468
+ // §5.2 InventoryView durable overlays (#625/#679).
27469
+ suspectedSpent: "suspected-spent",
27470
+ knownSpends: "known-spends"
27467
27471
  };
27468
27472
 
27469
27473
  // modules/payments-v2/machine/journal.ts
@@ -27713,7 +27717,7 @@ var TransferMachine = class {
27713
27717
  }
27714
27718
  if (cls === null) {
27715
27719
  await this.applyCommitted(engine, plan.transferId, committed);
27716
- this.closeTail(plan.transferId, plan.payload);
27720
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
27717
27721
  return { committed, deliveryPending };
27718
27722
  }
27719
27723
  if (cls === "conflict" && committed.length > 0) {
@@ -27739,10 +27743,10 @@ var TransferMachine = class {
27739
27743
  }
27740
27744
  await this.applyCommitted(engine, r.transferId, committed);
27741
27745
  if (conflicts.length > 0) {
27742
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
27746
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
27743
27747
  }
27744
27748
  await this.completeIntent(r.transferId, r.payload);
27745
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27749
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27746
27750
  return { deliveryPending, partial: null };
27747
27751
  }
27748
27752
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -27924,7 +27928,7 @@ var TransferMachine = class {
27924
27928
  }
27925
27929
  return added;
27926
27930
  }
27927
- async settlePartial(r, committed, conflicts) {
27931
+ async settlePartial(engine, r, committed, conflicts) {
27928
27932
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
27929
27933
  const entry = {
27930
27934
  transferId: r.transferId,
@@ -27936,26 +27940,27 @@ var TransferMachine = class {
27936
27940
  };
27937
27941
  await this.stores.shortfalls.upsert(entry);
27938
27942
  await this.completeIntent(r.transferId, r.payload);
27939
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27943
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27940
27944
  return entry;
27941
27945
  }
27942
- closeTail(transferId, payload) {
27946
+ closeTail(engine, transferId, payload, committed) {
27943
27947
  void (async () => {
27944
27948
  try {
27945
27949
  await this.completeIntent(transferId, payload);
27946
27950
  } catch {
27947
27951
  return;
27948
27952
  }
27949
- await this.finishBookkeeping(transferId, payload, "sent");
27953
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
27950
27954
  })();
27951
27955
  }
27952
27956
  async completeIntent(transferId, payload) {
27953
27957
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
27954
27958
  await this.deps.intents.complete(transferId, signature);
27955
27959
  }
27956
- async finishBookkeeping(transferId, payload, phase) {
27960
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
27957
27961
  try {
27958
- await this.deps.recordHistory?.({ transferId, payload, phase });
27962
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
27963
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
27959
27964
  } catch {
27960
27965
  }
27961
27966
  try {
@@ -27963,6 +27968,26 @@ var TransferMachine = class {
27963
27968
  } catch {
27964
27969
  }
27965
27970
  }
27971
+ /**
27972
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
27973
+ * certified recipient blobs (summarize()'s committed set is the only source;
27974
+ * a journal-rehydrated resume leg has no source token, but every committed
27975
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
27976
+ */
27977
+ async settledAmount(engine, coinId, committed) {
27978
+ let total = 0n;
27979
+ for (const outcome of committed) {
27980
+ if (outcome.recipientBlob === void 0) continue;
27981
+ const token = await engine.decodeToken({
27982
+ v: TOKEN_BLOB_VERSION,
27983
+ network: 0,
27984
+ tokenId: "",
27985
+ token: outcome.recipientBlob
27986
+ });
27987
+ total += engine.balanceOf(token, coinId);
27988
+ }
27989
+ return total.toString();
27990
+ }
27966
27991
  async disposeOnFailure(transferId, committed, cls) {
27967
27992
  if (committed.length > 0 || cls === "keep-open") return;
27968
27993
  try {
@@ -27987,7 +28012,7 @@ function conflictAmount(engine, payload, op, source) {
27987
28012
  }
27988
28013
 
27989
28014
  // modules/payments-v2/machine/resume.ts
27990
- async function resumeAll(deps) {
28015
+ async function resumeAll(deps, options = {}) {
27991
28016
  const report = { resumed: [], conflicted: [], failed: [] };
27992
28017
  const stores = createMachineStores(deps.kv);
27993
28018
  const open = await deps.intents.listOpen();
@@ -27997,7 +28022,12 @@ async function resumeAll(deps) {
27997
28022
  } catch {
27998
28023
  return report;
27999
28024
  }
28000
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
28025
+ await dropStaleBackstops(
28026
+ stores,
28027
+ locals,
28028
+ new Set(open.map((i) => i.transferId)),
28029
+ options.isLocallyActive ?? (() => false)
28030
+ );
28001
28031
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
28002
28032
  const ctx = {
28003
28033
  deps,
@@ -28010,11 +28040,16 @@ async function resumeAll(deps) {
28010
28040
  }
28011
28041
  return report;
28012
28042
  }
28013
- async function dropStaleLocalAborts(stores, locals, openIds) {
28043
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
28014
28044
  for (const entry of locals.values()) {
28015
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
28045
+ if (openIds.has(entry.transferId)) continue;
28046
+ if (entry.disposition !== "open") {
28016
28047
  await stores.backstop.removeByKey(entry.transferId);
28048
+ continue;
28017
28049
  }
28050
+ if (isLocallyActive(entry.transferId)) continue;
28051
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
28052
+ await stores.backstop.removeByKey(entry.transferId);
28018
28053
  }
28019
28054
  }
28020
28055
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -28222,7 +28257,9 @@ var Converger = class {
28222
28257
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
28223
28258
  }
28224
28259
  async resumePass() {
28225
- const report = await resumeAll(this.filteredMachineDeps());
28260
+ const report = await resumeAll(this.filteredMachineDeps(), {
28261
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
28262
+ });
28226
28263
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
28227
28264
  await this.deps.reconcile({
28228
28265
  resumed: report.resumed,
@@ -28338,6 +28375,136 @@ function distinctOpCount(entries) {
28338
28375
  return new Set(entries.map((entry) => entry.opIndex)).size;
28339
28376
  }
28340
28377
 
28378
+ // modules/payments-v2/restore.ts
28379
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
28380
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
28381
+ async function reseedAndReset(deps) {
28382
+ for (const entry of await deps.stores.backstop.list()) {
28383
+ if (entry.disposition !== "open") continue;
28384
+ try {
28385
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
28386
+ if (entry.requiresSeedClose) {
28387
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
28388
+ }
28389
+ } catch (err) {
28390
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
28391
+ }
28392
+ }
28393
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
28394
+ await deps.fullRePull();
28395
+ }
28396
+ async function splitOpIndex(deps, envelope) {
28397
+ const payload = await deps.decryptPayload(envelope);
28398
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
28399
+ }
28400
+
28401
+ // modules/payments-v2/inventory/presentation.ts
28402
+ function toToken(tokenId, snapshot, asset, registry, flags) {
28403
+ const iconUrl = registry.getIconUrl(asset.coinId);
28404
+ return {
28405
+ id: tokenId,
28406
+ coinId: asset.coinId,
28407
+ symbol: registry.getSymbol(asset.coinId),
28408
+ name: registry.getName(asset.coinId),
28409
+ decimals: registry.getDecimals(asset.coinId),
28410
+ ...iconUrl !== null ? { iconUrl } : {},
28411
+ amount: asset.amount,
28412
+ status: flags.transferring ? "transferring" : "confirmed",
28413
+ createdAt: snapshot.createdAt,
28414
+ updatedAt: snapshot.updatedAt,
28415
+ lazy: true,
28416
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28417
+ };
28418
+ }
28419
+ function aggregateAssets(entries, isTransferring, registry) {
28420
+ const groups = /* @__PURE__ */ new Map();
28421
+ for (const [tokenId, entry] of entries) {
28422
+ const moving = isTransferring(tokenId);
28423
+ for (const asset of entry.assets) {
28424
+ let group = groups.get(asset.coinId);
28425
+ if (!group) {
28426
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28427
+ groups.set(asset.coinId, group);
28428
+ }
28429
+ if (moving) {
28430
+ group.transferring += BigInt(asset.amount);
28431
+ group.transferringCount += 1;
28432
+ } else {
28433
+ group.total += BigInt(asset.amount);
28434
+ group.count += 1;
28435
+ }
28436
+ }
28437
+ }
28438
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28439
+ }
28440
+ function toAsset(coinId, totals, registry) {
28441
+ const iconUrl = registry.getIconUrl(coinId);
28442
+ return {
28443
+ coinId,
28444
+ symbol: registry.getSymbol(coinId),
28445
+ name: registry.getName(coinId),
28446
+ decimals: registry.getDecimals(coinId),
28447
+ ...iconUrl !== null ? { iconUrl } : {},
28448
+ totalAmount: totals.total.toString(),
28449
+ tokenCount: totals.count,
28450
+ confirmedAmount: totals.total.toString(),
28451
+ unconfirmedAmount: "0",
28452
+ confirmedTokenCount: totals.count,
28453
+ unconfirmedTokenCount: 0,
28454
+ transferringTokenCount: totals.transferringCount,
28455
+ transferringAmount: totals.transferring.toString(),
28456
+ priceUsd: null,
28457
+ priceEur: null,
28458
+ change24h: null,
28459
+ fiatValueUsd: null,
28460
+ fiatValueEur: null
28461
+ };
28462
+ }
28463
+ async function withPrices(raw, registry, price) {
28464
+ try {
28465
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28466
+ const quotes = await price.getPrices(names);
28467
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28468
+ } catch {
28469
+ return raw;
28470
+ }
28471
+ }
28472
+ function priceOne(asset, quote) {
28473
+ if (!quote) return asset;
28474
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28475
+ return {
28476
+ ...asset,
28477
+ priceUsd: quote.priceUsd,
28478
+ priceEur: quote.priceEur ?? null,
28479
+ change24h: quote.change24h ?? null,
28480
+ fiatValueUsd: human * quote.priceUsd,
28481
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28482
+ };
28483
+ }
28484
+
28485
+ // modules/payments-v2/machine/payload.ts
28486
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
28487
+ return {
28488
+ v: 2,
28489
+ recipient: recipientPubkey,
28490
+ coinId: request.coinId,
28491
+ amount: request.amount,
28492
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
28493
+ direct: [...spend.plan.direct],
28494
+ ...spend.plan.split !== void 0 ? {
28495
+ split: {
28496
+ tokenId: spend.plan.split.tokenId,
28497
+ splitAmount: spend.plan.split.splitAmount.toString(),
28498
+ remainderAmount: spend.plan.split.remainderAmount.toString()
28499
+ }
28500
+ } : {},
28501
+ spentStates
28502
+ };
28503
+ }
28504
+ function messageOf(err) {
28505
+ return err instanceof Error ? err.message : String(err);
28506
+ }
28507
+
28341
28508
  // modules/payments-v2/history/History.ts
28342
28509
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
28343
28510
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -28396,13 +28563,14 @@ var History = class {
28396
28563
  }
28397
28564
  // ── internals ────────────────────────────────────────────────────────────────
28398
28565
  async post(build) {
28566
+ const record = build();
28399
28567
  try {
28400
- await this.deps.client.postHistory([build()]);
28568
+ await this.deps.client.postHistory([record]);
28401
28569
  } catch (err) {
28402
28570
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
28403
28571
  return;
28404
28572
  }
28405
- this.deps.emit("history:updated");
28573
+ this.deps.emit("history:updated", this.toEntry(record));
28406
28574
  }
28407
28575
  ts(timestamp) {
28408
28576
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -28459,93 +28627,9 @@ var History = class {
28459
28627
  }
28460
28628
  };
28461
28629
 
28462
- // modules/payments-v2/inventory/presentation.ts
28463
- function toToken(tokenId, snapshot, asset, registry, flags) {
28464
- const iconUrl = registry.getIconUrl(asset.coinId);
28465
- return {
28466
- id: tokenId,
28467
- coinId: asset.coinId,
28468
- symbol: registry.getSymbol(asset.coinId),
28469
- name: registry.getName(asset.coinId),
28470
- decimals: registry.getDecimals(asset.coinId),
28471
- ...iconUrl !== null ? { iconUrl } : {},
28472
- amount: asset.amount,
28473
- status: flags.transferring ? "transferring" : "confirmed",
28474
- createdAt: snapshot.createdAt,
28475
- updatedAt: snapshot.updatedAt,
28476
- lazy: true,
28477
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28478
- };
28479
- }
28480
- function aggregateAssets(entries, isTransferring, registry) {
28481
- const groups = /* @__PURE__ */ new Map();
28482
- for (const [tokenId, entry] of entries) {
28483
- const moving = isTransferring(tokenId);
28484
- for (const asset of entry.assets) {
28485
- let group = groups.get(asset.coinId);
28486
- if (!group) {
28487
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28488
- groups.set(asset.coinId, group);
28489
- }
28490
- if (moving) {
28491
- group.transferring += BigInt(asset.amount);
28492
- group.transferringCount += 1;
28493
- } else {
28494
- group.total += BigInt(asset.amount);
28495
- group.count += 1;
28496
- }
28497
- }
28498
- }
28499
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28500
- }
28501
- function toAsset(coinId, totals, registry) {
28502
- const iconUrl = registry.getIconUrl(coinId);
28503
- return {
28504
- coinId,
28505
- symbol: registry.getSymbol(coinId),
28506
- name: registry.getName(coinId),
28507
- decimals: registry.getDecimals(coinId),
28508
- ...iconUrl !== null ? { iconUrl } : {},
28509
- totalAmount: totals.total.toString(),
28510
- tokenCount: totals.count,
28511
- confirmedAmount: totals.total.toString(),
28512
- unconfirmedAmount: "0",
28513
- confirmedTokenCount: totals.count,
28514
- unconfirmedTokenCount: 0,
28515
- transferringTokenCount: totals.transferringCount,
28516
- transferringAmount: totals.transferring.toString(),
28517
- priceUsd: null,
28518
- priceEur: null,
28519
- change24h: null,
28520
- fiatValueUsd: null,
28521
- fiatValueEur: null
28522
- };
28523
- }
28524
- async function withPrices(raw, registry, price) {
28525
- try {
28526
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28527
- const quotes = await price.getPrices(names);
28528
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28529
- } catch {
28530
- return raw;
28531
- }
28532
- }
28533
- function priceOne(asset, quote) {
28534
- if (!quote) return asset;
28535
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28536
- return {
28537
- ...asset,
28538
- priceUsd: quote.priceUsd,
28539
- priceEur: quote.priceEur ?? null,
28540
- change24h: quote.change24h ?? null,
28541
- fiatValueUsd: human * quote.priceUsd,
28542
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28543
- };
28544
- }
28545
-
28546
28630
  // modules/payments-v2/inventory/InventoryView.ts
28547
- var SUSPECTED_KEY = "suspected-spent";
28548
- var KNOWN_SPENDS_KEY = "known-spends";
28631
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
28632
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
28549
28633
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
28550
28634
  function stateKey(tokenId, stateHash) {
28551
28635
  return `${tokenId}:${stateHash}`;
@@ -28849,19 +28933,24 @@ var Receive = class {
28849
28933
  const engine = deps.engine();
28850
28934
  const stored = [];
28851
28935
  const pending = [];
28852
- let epoch = "";
28936
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
28853
28937
  try {
28854
- epoch = deps.syncEpoch();
28855
28938
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
28856
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
28857
- for await (const entry of deps.delivery.incoming(since)) {
28858
- await this.processEntry(deps, engine, entry, pending, stored);
28859
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
28939
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
28940
+ for (let pass = 0; pass < 2; pass++) {
28941
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
28942
+ await this.processEntry(deps, engine, entry, pending, stored);
28943
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
28944
+ }
28945
+ await flushAcks(deps, pending, pageEpoch);
28946
+ const served = deps.delivery.incomingEpoch();
28947
+ if (basis === null || served === null || served === basis.syncEpoch) break;
28948
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
28949
+ basis = null;
28860
28950
  }
28861
- await flushAcks(deps, pending, epoch);
28862
28951
  } catch (err) {
28863
28952
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
28864
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
28953
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
28865
28954
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
28866
28955
  });
28867
28956
  }
@@ -28934,7 +29023,7 @@ async function announce(deps, entry, record) {
28934
29023
  receivedAt
28935
29024
  };
28936
29025
  }
28937
- async function flushAcks(deps, pending, epoch) {
29026
+ async function flushAcks(deps, pending, epochOf) {
28938
29027
  let lastAcked = null;
28939
29028
  try {
28940
29029
  while (pending.length > 0) {
@@ -28945,7 +29034,7 @@ async function flushAcks(deps, pending, epoch) {
28945
29034
  }
28946
29035
  } finally {
28947
29036
  if (lastAcked !== null) {
28948
- const record = { cursor: lastAcked, syncEpoch: epoch };
29037
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
28949
29038
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
28950
29039
  }
28951
29040
  }
@@ -29046,9 +29135,19 @@ var Requests = class {
29046
29135
  }
29047
29136
  });
29048
29137
  }
29138
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
29049
29139
  writeLink(requestId, transferId, committed) {
29050
29140
  return this.mutateJournal((m) => {
29051
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
29141
+ const prev = m.get(requestId);
29142
+ const same = prev !== void 0 && prev.transferId === transferId;
29143
+ const next = {
29144
+ requestId,
29145
+ transferId,
29146
+ committed: committed || same && prev.committed,
29147
+ createdAt: same ? prev.createdAt : this.now()
29148
+ };
29149
+ if (same && prev.committed === next.committed) return false;
29150
+ m.set(requestId, next);
29052
29151
  return true;
29053
29152
  });
29054
29153
  }
@@ -29201,23 +29300,46 @@ var Requests = class {
29201
29300
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
29202
29301
  if (transferId !== void 0) {
29203
29302
  const committed = error instanceof PartialSendConflictError;
29204
- await this.writeLink(id, transferId, committed);
29205
- this.setStatus(id, "settling");
29303
+ await this.settle(id, transferId, { committed, respond: false });
29206
29304
  } else {
29207
29305
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
29208
29306
  this.setStatus(id, "paid");
29209
29307
  }
29210
29308
  } else {
29211
- await this.clearLink(id);
29212
- this.setStatus(id, "pending");
29309
+ await this.revertPayable(id);
29213
29310
  }
29214
29311
  throw error;
29215
29312
  }
29216
- this.setStatus(id, "paid");
29217
- await this.respondPaid(id, result.id);
29218
- await this.clearLink(id);
29313
+ await this.settle(id, result.id, { committed: true, respond: true });
29219
29314
  return result;
29220
29315
  }
29316
+ /**
29317
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
29318
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
29319
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
29320
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
29321
+ * not a reload. Every path that binds a request to a transfer outcome funnels
29322
+ * through here — pay()'s clean success (respond now), pay()'s
29323
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
29324
+ * — so a failed respond always leaves the link + 'settling' and the next
29325
+ * reconcile pass (the committed-link override) retries the respond.
29326
+ */
29327
+ async settle(requestId, transferId, opts) {
29328
+ let journalDown = false;
29329
+ try {
29330
+ await this.writeLink(requestId, transferId, opts.committed);
29331
+ } catch (err) {
29332
+ if (!opts.respond) throw err;
29333
+ journalDown = true;
29334
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
29335
+ }
29336
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
29337
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
29338
+ return;
29339
+ }
29340
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
29341
+ this.setStatus(requestId, "paid");
29342
+ }
29221
29343
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
29222
29344
  async respondPaid(id, transferId) {
29223
29345
  try {
@@ -29253,32 +29375,32 @@ var Requests = class {
29253
29375
  let aborted = null;
29254
29376
  for (const [requestId, link] of [...this.journal]) {
29255
29377
  if (link.committed || resumed.has(link.transferId)) {
29256
- await this.resolvePaid(requestId, link.transferId);
29378
+ await this.resolvePaid(requestId, link);
29257
29379
  } else if (conflicted.has(link.transferId)) {
29258
29380
  await this.revertPayable(requestId);
29259
29381
  } else if (open.has(link.transferId)) {
29260
29382
  } else {
29261
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
29383
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
29262
29384
  }
29263
29385
  }
29264
29386
  }
29265
29387
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
29266
29388
  // a probe failure defers the link to the next session.
29267
- async reconcileUnaccounted(requestId, transferId, aborted) {
29389
+ async reconcileUnaccounted(requestId, link, aborted) {
29268
29390
  try {
29269
29391
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
29270
- if (aborted.has(transferId)) await this.revertPayable(requestId);
29271
- else await this.resolvePaid(requestId, transferId);
29392
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
29393
+ else await this.resolvePaid(requestId, link);
29272
29394
  } catch (err) {
29273
29395
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
29274
29396
  }
29275
29397
  return aborted;
29276
29398
  }
29277
- async resolvePaid(requestId, transferId) {
29278
- if (!await this.respondPaid(requestId, transferId)) return;
29279
- await this.clearLink(requestId);
29280
- this.setStatus(requestId, "paid");
29399
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
29400
+ resolvePaid(requestId, link) {
29401
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
29281
29402
  }
29403
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
29282
29404
  async revertPayable(requestId) {
29283
29405
  await this.clearLink(requestId);
29284
29406
  this.setStatus(requestId, "pending");
@@ -29602,11 +29724,12 @@ function composeFacadeParts(deps, hooks) {
29602
29724
  client: deps.client,
29603
29725
  fieldKey: deps.fieldKey,
29604
29726
  registry: deps.registry,
29605
- emit: (event) => deps.emit(event, {}),
29727
+ emit: (event, entry) => deps.emit(event, entry),
29606
29728
  ...deps.now !== void 0 ? { now: deps.now } : {},
29607
29729
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
29608
29730
  });
29609
29731
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
29732
+ const machineStores = createMachineStores(deps.kv);
29610
29733
  const heldStates = /* @__PURE__ */ new Map();
29611
29734
  return {
29612
29735
  ownPubkeyBytes,
@@ -29614,12 +29737,29 @@ function composeFacadeParts(deps, hooks) {
29614
29737
  ledger,
29615
29738
  queue,
29616
29739
  historyStore,
29617
- machineStores: createMachineStores(deps.kv),
29740
+ machineStores,
29618
29741
  machineDeps,
29619
29742
  machine: new TransferMachine(machineDeps),
29620
29743
  heldStates,
29621
29744
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
29622
- requests: buildRequests(deps, hooks)
29745
+ requests: buildRequests(deps, hooks),
29746
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
29747
+ };
29748
+ }
29749
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
29750
+ return {
29751
+ stores: machineStores,
29752
+ kv: deps.kv,
29753
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
29754
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
29755
+ decryptPayload: machineDeps.decryptPayload,
29756
+ fullRePull: () => view.onEpochReset(),
29757
+ attention: (transferId, code, detail) => {
29758
+ deps.emit(
29759
+ "transfer:attention",
29760
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
29761
+ );
29762
+ }
29623
29763
  };
29624
29764
  }
29625
29765
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -29641,11 +29781,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
29641
29781
  ownPubkey: hexToBytes4(deps.ownPubkey),
29642
29782
  emit: deps.emit,
29643
29783
  now: deps.now ?? Date.now,
29644
- recordHistory: async ({ transferId, payload }) => {
29784
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
29645
29785
  await historyStore.recordSent({
29646
29786
  transferId,
29647
29787
  coinId: payload.coinId,
29648
- amount: payload.amount,
29788
+ // §5.9: the SETTLED amount (machine-computed from the certified
29789
+ // recipient blobs), never payload.amount — the plan.
29790
+ amount: committedAmount,
29649
29791
  recipientPubkey: payload.recipient,
29650
29792
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
29651
29793
  });
@@ -29672,7 +29814,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
29672
29814
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
29673
29815
  );
29674
29816
  },
29675
- syncEpoch: deps.syncEpoch ?? (() => ""),
29817
+ syncEpoch: deps.syncEpoch,
29676
29818
  ...deps.now !== void 0 ? { now: deps.now } : {}
29677
29819
  });
29678
29820
  }
@@ -29725,6 +29867,7 @@ var PaymentsFacade = class {
29725
29867
  this.receiveLoop = parts.receiveLoop;
29726
29868
  this.heldStates = parts.heldStates;
29727
29869
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
29870
+ this.restoreDeps = parts.restoreDeps;
29728
29871
  this.requests = parts.requests;
29729
29872
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
29730
29873
  this.heartbeat = new ConvergenceHeartbeat({
@@ -29753,6 +29896,7 @@ var PaymentsFacade = class {
29753
29896
  receiveLoop;
29754
29897
  heldStates;
29755
29898
  ownPubkeyBytes;
29899
+ restoreDeps;
29756
29900
  requests;
29757
29901
  currentEngine = null;
29758
29902
  pendingOps = /* @__PURE__ */ new Set();
@@ -29763,6 +29907,8 @@ var PaymentsFacade = class {
29763
29907
  converger;
29764
29908
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
29765
29909
  resumeFlight = new SingleFlight();
29910
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
29911
+ passChain = new SerialChain();
29766
29912
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
29767
29913
  activeMoneyOps = /* @__PURE__ */ new Set();
29768
29914
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -29770,8 +29916,8 @@ var PaymentsFacade = class {
29770
29916
  if (this.started) return;
29771
29917
  this.started = true;
29772
29918
  this.heartbeat.start();
29773
- await this.deps.session.start();
29774
29919
  this.unsubscribers.push(
29920
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
29775
29921
  this.deps.session.subscribeStream("mailbox", () => {
29776
29922
  this.trackTail(this.receiveLoop.drainOnce());
29777
29923
  }),
@@ -29786,6 +29932,7 @@ var PaymentsFacade = class {
29786
29932
  if (status === "connected") this.heartbeat.recovered();
29787
29933
  });
29788
29934
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
29935
+ await this.deps.session.start();
29789
29936
  this.trackTail(this.runConvergencePass());
29790
29937
  this.trackTail(this.seedHeldStates());
29791
29938
  await this.view.fullPull().catch(() => void 0);
@@ -29826,7 +29973,7 @@ var PaymentsFacade = class {
29826
29973
  }
29827
29974
  // ── money movement ─────────────────────────────────────────────────────────
29828
29975
  send(request) {
29829
- return this.track(this.sendWithPolicy(request));
29976
+ return this.track(this.sendOutcome(request));
29830
29977
  }
29831
29978
  async receive() {
29832
29979
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -29841,20 +29988,27 @@ var PaymentsFacade = class {
29841
29988
  if (!this.started) return;
29842
29989
  await this.track(this.runConvergencePass());
29843
29990
  }
29991
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
29992
+ async handleEpochChange(_newEpoch) {
29993
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
29994
+ await this.passChain.enqueue(() => this.convergeBody());
29995
+ }
29844
29996
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
29845
29997
  runConvergencePass() {
29846
- return this.resumeFlight.run(async () => {
29847
- const outcome = await this.converger.convergeOnce();
29848
- const pending = await this.converger.pendingWork().catch(() => true);
29849
- this.heartbeat.settle(outcome, pending);
29850
- });
29998
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
29999
+ }
30000
+ async convergeBody() {
30001
+ const outcome = await this.converger.convergeOnce();
30002
+ const pending = await this.converger.pendingWork().catch(() => true);
30003
+ this.heartbeat.settle(outcome, pending);
29851
30004
  }
29852
30005
  nowMs() {
29853
30006
  return (this.deps.now ?? Date.now)();
29854
30007
  }
29855
30008
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
29856
- async sendWithPolicy(request) {
29857
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
30009
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
30010
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
30011
+ async sendOutcome(request) {
29858
30012
  const run = {
29859
30013
  amount: request.amount,
29860
30014
  delivered: [],
@@ -29862,12 +30016,36 @@ var PaymentsFacade = class {
29862
30016
  tokenTransfers: [],
29863
30017
  settledShortfalls: [],
29864
30018
  firstPartialId: void 0,
29865
- pendingAny: false
30019
+ pendingAny: false,
30020
+ lastTransferId: ""
29866
30021
  };
30022
+ try {
30023
+ return await this.sendWithPolicy(request, run);
30024
+ } catch (err) {
30025
+ this.emitCleanFailure(err, run.lastTransferId);
30026
+ throw err;
30027
+ }
30028
+ }
30029
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
30030
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
30031
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
30032
+ emitCleanFailure(err, transferId) {
30033
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
30034
+ const failed = {
30035
+ id: transferId,
30036
+ status: "failed",
30037
+ tokens: [],
30038
+ tokenTransfers: [],
30039
+ error: messageOf(err)
30040
+ };
30041
+ this.deps.emit("transfer:updated", failed);
30042
+ }
30043
+ async sendWithPolicy(request, run) {
30044
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
29867
30045
  for (let attempt = 0; ; attempt++) {
29868
30046
  let ctx;
29869
30047
  try {
29870
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
30048
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
29871
30049
  } catch (err) {
29872
30050
  throw this.partialize(err, run);
29873
30051
  }
@@ -29912,11 +30090,8 @@ var PaymentsFacade = class {
29912
30090
  this.activeMoneyOps.delete(ctx.transferId);
29913
30091
  }
29914
30092
  }
29915
- /**
29916
- * Partial outcome (#677/#690): the shortfall is already durable (written by
29917
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
29918
- * the remainder under a NEW transferId — never the full amount.
29919
- */
30093
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
30094
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
29920
30095
  async consumePartial(ctx, disposition, run, attempt) {
29921
30096
  if (disposition.deliveryPending) {
29922
30097
  run.pendingAny = true;
@@ -29942,11 +30117,8 @@ var PaymentsFacade = class {
29942
30117
  run.amount = partial.remainingAmount;
29943
30118
  return null;
29944
30119
  }
29945
- /**
29946
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
29947
- * backstop still 'open' (committed>0) → converge via the same machine's
29948
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
29949
- */
30120
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
30121
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
29950
30122
  async disposeFailedAttempt(ctx, err) {
29951
30123
  if (isPossiblyCommittedSendOutcome(err)) {
29952
30124
  this.settleKeepOpen(ctx);
@@ -29995,8 +30167,9 @@ var PaymentsFacade = class {
29995
30167
  }
29996
30168
  return { kind: "rethrow", error: err };
29997
30169
  }
29998
- async planAndMaterialize(recipientPubkey, request) {
30170
+ async planAndMaterialize(recipientPubkey, request, run) {
29999
30171
  const transferId = this.newId();
30172
+ run.lastTransferId = transferId;
30000
30173
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
30001
30174
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
30002
30175
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -30128,11 +30301,7 @@ var PaymentsFacade = class {
30128
30301
  this.deps.emit("transfer:updated", result);
30129
30302
  return result;
30130
30303
  }
30131
- /**
30132
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
30133
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
30134
- * over the accumulated settled set — never bare, never a full-amount retry.
30135
- */
30304
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
30136
30305
  partialize(err, run) {
30137
30306
  if (run.delivered.length === 0) return err;
30138
30307
  return new PartialSendConflictError(
@@ -30284,22 +30453,14 @@ var PaymentsFacade = class {
30284
30453
  }
30285
30454
  }
30286
30455
  toUiToken(tokenId, coinId, amount) {
30287
- const registry = this.deps.registry;
30288
- const iconUrl = registry.getIconUrl(coinId);
30289
30456
  const now = this.nowMs();
30290
- return {
30291
- id: tokenId,
30292
- coinId,
30293
- symbol: registry.getSymbol(coinId),
30294
- name: registry.getName(coinId),
30295
- decimals: registry.getDecimals(coinId),
30296
- ...iconUrl !== null ? { iconUrl } : {},
30297
- amount: amount.toString(),
30298
- status: "transferring",
30299
- createdAt: now,
30300
- updatedAt: now,
30301
- lazy: true
30302
- };
30457
+ return toToken(
30458
+ tokenId,
30459
+ { assets: [], createdAt: now, updatedAt: now },
30460
+ { coinId, amount: amount.toString() },
30461
+ this.deps.registry,
30462
+ { transferring: true, suspectedSpent: false }
30463
+ );
30303
30464
  }
30304
30465
  track(op) {
30305
30466
  const settled = op.then(
@@ -30314,27 +30475,6 @@ var PaymentsFacade = class {
30314
30475
  this.track(op).catch(() => void 0);
30315
30476
  }
30316
30477
  };
30317
- function buildPayload(recipientPubkey, request, spend, spentStates) {
30318
- return {
30319
- v: 2,
30320
- recipient: recipientPubkey,
30321
- coinId: request.coinId,
30322
- amount: request.amount,
30323
- ...request.memo !== void 0 ? { memo: request.memo } : {},
30324
- direct: [...spend.plan.direct],
30325
- ...spend.plan.split !== void 0 ? {
30326
- split: {
30327
- tokenId: spend.plan.split.tokenId,
30328
- splitAmount: spend.plan.split.splitAmount.toString(),
30329
- remainderAmount: spend.plan.split.remainderAmount.toString()
30330
- }
30331
- } : {},
30332
- spentStates
30333
- };
30334
- }
30335
- function messageOf(err) {
30336
- return err instanceof Error ? err.message : String(err);
30337
- }
30338
30478
 
30339
30479
  // impl/wallet-api-v2/http.ts
30340
30480
  var WalletApiHttpError = class extends Error {
@@ -30680,6 +30820,7 @@ var WalletApiSession = class {
30680
30820
  epochLatch = null;
30681
30821
  reseedChain = Promise.resolve();
30682
30822
  reseedsPending = 0;
30823
+ epochHandlers = /* @__PURE__ */ new Set();
30683
30824
  handlers = /* @__PURE__ */ new Map();
30684
30825
  statusHandlers = /* @__PURE__ */ new Set();
30685
30826
  socket = null;
@@ -30782,6 +30923,18 @@ var WalletApiSession = class {
30782
30923
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
30783
30924
  }
30784
30925
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
30926
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
30927
+ currentEpoch() {
30928
+ return this.epochLatch ?? "";
30929
+ }
30930
+ /**
30931
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
30932
+ * the reseed chain, BEFORE any stream nudge resumes.
30933
+ */
30934
+ subscribeEpochChange(handler) {
30935
+ this.epochHandlers.add(handler);
30936
+ return () => this.epochHandlers.delete(handler);
30937
+ }
30785
30938
  noteEpoch(epoch) {
30786
30939
  if (this.epochLatch === epoch) return;
30787
30940
  const previous = this.epochLatch;
@@ -30795,7 +30948,7 @@ var WalletApiSession = class {
30795
30948
  }
30796
30949
  async runReseed(epoch, previous) {
30797
30950
  try {
30798
- await this.deps.onEpochChange(epoch);
30951
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
30799
30952
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
30800
30953
  } catch {
30801
30954
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -31057,6 +31210,7 @@ var WalletApiDeliveryPort = class {
31057
31210
  wake;
31058
31211
  deriveFn;
31059
31212
  seenChain = new SerialChain();
31213
+ lastIncomingEpoch = null;
31060
31214
  constructor(config) {
31061
31215
  this.client = config.client;
31062
31216
  this.kv = config.kv;
@@ -31151,6 +31305,7 @@ var WalletApiDeliveryPort = class {
31151
31305
  const seen = await this.readSeen();
31152
31306
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
31153
31307
  for (; ; ) {
31308
+ this.lastIncomingEpoch = String(page.syncEpoch);
31154
31309
  for (const entry of page.entries) {
31155
31310
  if (entry.status !== "unclaimed") continue;
31156
31311
  if (seen.has(entry.entryId)) continue;
@@ -31160,6 +31315,10 @@ var WalletApiDeliveryPort = class {
31160
31315
  page = await this.client.listMailbox(page.cursor);
31161
31316
  }
31162
31317
  }
31318
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
31319
+ incomingEpoch() {
31320
+ return this.lastIncomingEpoch;
31321
+ }
31163
31322
  toIncoming(entry) {
31164
31323
  const bundle = this.openEnvelope(entry);
31165
31324
  return {
@@ -31237,7 +31396,7 @@ var WalletApiSplitCheckpointStore = class {
31237
31396
  return utf8(`${transferId}:${String(opIndex)}`);
31238
31397
  }
31239
31398
  async put(transferId, opIndex, bytes) {
31240
- const { client, kv, fieldKey, signProgress } = this.config;
31399
+ const { kv, fieldKey } = this.config;
31241
31400
  const aad = this.aad(transferId, opIndex);
31242
31401
  const cacheKey = this.cacheKey(transferId, opIndex);
31243
31402
  let envelope = await kv.get(cacheKey);
@@ -31245,10 +31404,21 @@ var WalletApiSplitCheckpointStore = class {
31245
31404
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
31246
31405
  await kv.set(cacheKey, envelope);
31247
31406
  }
31248
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
31249
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
31407
+ const record = await this.post(transferId, opIndex, envelope);
31250
31408
  return this.open(record.payload, aad);
31251
31409
  }
31410
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
31411
+ async reseedCheckpoint(transferId, opIndex) {
31412
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
31413
+ if (envelope === null) return false;
31414
+ await this.post(transferId, opIndex, envelope);
31415
+ return true;
31416
+ }
31417
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
31418
+ async post(transferId, opIndex, envelope) {
31419
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
31420
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
31421
+ }
31252
31422
  async get(transferId, opIndex) {
31253
31423
  const records = await this.config.client.getProgress(transferId);
31254
31424
  const record = records.find((r) => r.opIndex === opIndex);
@@ -31271,7 +31441,7 @@ var WalletApiSplitCheckpointStore = class {
31271
31441
  };
31272
31442
 
31273
31443
  // core/payments-v2-wiring.ts
31274
- function resolvePaymentsV2Composition(walletApi) {
31444
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
31275
31445
  const source = walletApi;
31276
31446
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
31277
31447
  if (!source || network === null) {
@@ -31280,6 +31450,18 @@ function resolvePaymentsV2Composition(walletApi) {
31280
31450
  "INVALID_CONFIG"
31281
31451
  );
31282
31452
  }
31453
+ if (!(network in NETWORKS)) {
31454
+ throw new SphereError(
31455
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
31456
+ "INVALID_CONFIG"
31457
+ );
31458
+ }
31459
+ if (network !== sphereNetwork) {
31460
+ throw new SphereError(
31461
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
31462
+ "INVALID_CONFIG"
31463
+ );
31464
+ }
31283
31465
  if (typeof source.paymentsV2Transport === "function") {
31284
31466
  return { network, factory: source.paymentsV2Transport.bind(source) };
31285
31467
  }
@@ -31345,8 +31527,7 @@ function defaultPaymentsV2Transport(config) {
31345
31527
  deviceId: `${config.deviceId}:pv2`,
31346
31528
  kv: args.kv,
31347
31529
  webSocketFactory,
31348
- emitStatus: args.emitStatus,
31349
- onEpochChange: args.onEpochChange
31530
+ emitStatus: args.emitStatus
31350
31531
  });
31351
31532
  return { session, client: authedWireClient(session, client) };
31352
31533
  };
@@ -31416,8 +31597,7 @@ function composePaymentsV2(spec) {
31416
31597
  identity: identitySlice,
31417
31598
  network,
31418
31599
  kv,
31419
- emitStatus: (status) => host.emit("connection:status", { status }),
31420
- onEpochChange: async () => void 0
31600
+ emitStatus: (status) => host.emit("connection:status", { status })
31421
31601
  });
31422
31602
  const deps = {
31423
31603
  session: transport.session,
@@ -31447,7 +31627,10 @@ function composePaymentsV2(spec) {
31447
31627
  network,
31448
31628
  ownPubkey: identity.chainPubkey,
31449
31629
  ownNametag: host.nametag,
31450
- requestMemo: requestMemoCodec(identity.privateKey)
31630
+ requestMemo: requestMemoCodec(identity.privateKey),
31631
+ // §5.1: the session is the epoch authority — the facade registers its
31632
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
31633
+ syncEpoch: () => transport.session.currentEpoch()
31451
31634
  };
31452
31635
  return new PaymentsFacade(deps);
31453
31636
  }
@@ -31777,18 +31960,20 @@ var Sphere = class _Sphere {
31777
31960
  }
31778
31961
  /**
31779
31962
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
31780
- * fail-closed at init, never a silently-degraded composition.
31963
+ * fail-closed at init, never a silently-degraded composition. Returns the
31964
+ * resolved composition (null = flag off); #728: resolving here also asserts
31965
+ * the single-network invariant (walletApi.network known + equal to the
31966
+ * Sphere network) before any session/KV/provider exists.
31781
31967
  */
31782
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
31783
- if (flag !== true) return false;
31968
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
31969
+ if (flag !== true) return null;
31784
31970
  if (accountingConfig || swapConfig) {
31785
31971
  throw new SphereError(
31786
31972
  "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.",
31787
31973
  "INVALID_CONFIG"
31788
31974
  );
31789
31975
  }
31790
- resolvePaymentsV2Composition(walletApi);
31791
- return true;
31976
+ return resolvePaymentsV2Composition(walletApi, network);
31792
31977
  }
31793
31978
  /**
31794
31979
  * Configure TokenRegistry in the main bundle context.
@@ -31843,12 +32028,14 @@ var Sphere = class _Sphere {
31843
32028
  options.walletApi
31844
32029
  );
31845
32030
  sphere._verification = options.verification;
31846
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32031
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31847
32032
  options.paymentsV2,
31848
32033
  accountingConfig,
31849
32034
  swapConfig,
31850
- options.walletApi
32035
+ options.walletApi,
32036
+ options.network
31851
32037
  );
32038
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31852
32039
  sphere._password = options.password ?? null;
31853
32040
  sphere._passwordProtected = sphere._password !== null;
31854
32041
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -31915,12 +32102,14 @@ var Sphere = class _Sphere {
31915
32102
  options.walletApi
31916
32103
  );
31917
32104
  sphere._verification = options.verification;
31918
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32105
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31919
32106
  options.paymentsV2,
31920
32107
  accountingConfig,
31921
32108
  swapConfig,
31922
- options.walletApi
32109
+ options.walletApi,
32110
+ options.network
31923
32111
  );
32112
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31924
32113
  sphere._password = options.password ?? null;
31925
32114
  sphere._passwordProtected = sphere._password !== null;
31926
32115
  if (!options.storage.isConnected()) {
@@ -31994,12 +32183,14 @@ var Sphere = class _Sphere {
31994
32183
  options.walletApi
31995
32184
  );
31996
32185
  sphere._verification = options.verification;
31997
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32186
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31998
32187
  options.paymentsV2,
31999
32188
  accountingConfig,
32000
32189
  swapConfig,
32001
- options.walletApi
32190
+ options.walletApi,
32191
+ options.network
32002
32192
  );
32193
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
32003
32194
  sphere._password = options.password ?? null;
32004
32195
  sphere._passwordProtected = sphere._password !== null;
32005
32196
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -34657,7 +34848,9 @@ var Sphere = class _Sphere {
34657
34848
  * setOracleApiKey rebuild is what future operations snapshot.
34658
34849
  */
34659
34850
  async startPaymentsV2Inner(index, identity) {
34660
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
34851
+ if (this._paymentsV2Composition === null) {
34852
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
34853
+ }
34661
34854
  if (!this._addressModules.get(index)?.tokenEngine) {
34662
34855
  throw new SphereError(
34663
34856
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",