@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.
@@ -10584,14 +10584,14 @@ var IntentResume = class {
10584
10584
  }
10585
10585
  let changeOutput = null;
10586
10586
  if (payload.split) {
10587
- const splitOpIndex = payload.direct.length;
10587
+ const splitOpIndex2 = payload.direct.length;
10588
10588
  let splitDelivered = false;
10589
10589
  {
10590
10590
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
10591
10591
  const outcome = await this.host.certifyOperation(
10592
10592
  {
10593
10593
  kind: "split",
10594
- opIndex: splitOpIndex,
10594
+ opIndex: splitOpIndex2,
10595
10595
  uiTokenId: `v2_${payload.split.tokenId}`,
10596
10596
  genesisId: payload.split.tokenId,
10597
10597
  sourceSdkData: "",
@@ -20699,6 +20699,7 @@ var AccountingModule = class _AccountingModule {
20699
20699
  const unsubHistory = deps.on(
20700
20700
  "history:updated",
20701
20701
  (entry) => {
20702
+ if (!("dedupKey" in entry)) return;
20702
20703
  this._handleHistoryUpdated(entry).catch((err) => {
20703
20704
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
20704
20705
  });
@@ -25968,7 +25969,10 @@ var STORE_KEYS = {
25968
25969
  shortfalls: "shortfalls",
25969
25970
  settlingLinks: "settling",
25970
25971
  streamCursor: (s) => `cursor:${s}`,
25971
- epochLatch: "epoch-latch"
25972
+ epochLatch: "epoch-latch",
25973
+ // §5.2 InventoryView durable overlays (#625/#679).
25974
+ suspectedSpent: "suspected-spent",
25975
+ knownSpends: "known-spends"
25972
25976
  };
25973
25977
 
25974
25978
  // modules/payments-v2/machine/journal.ts
@@ -26218,7 +26222,7 @@ var TransferMachine = class {
26218
26222
  }
26219
26223
  if (cls === null) {
26220
26224
  await this.applyCommitted(engine, plan.transferId, committed);
26221
- this.closeTail(plan.transferId, plan.payload);
26225
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
26222
26226
  return { committed, deliveryPending };
26223
26227
  }
26224
26228
  if (cls === "conflict" && committed.length > 0) {
@@ -26244,10 +26248,10 @@ var TransferMachine = class {
26244
26248
  }
26245
26249
  await this.applyCommitted(engine, r.transferId, committed);
26246
26250
  if (conflicts.length > 0) {
26247
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
26251
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
26248
26252
  }
26249
26253
  await this.completeIntent(r.transferId, r.payload);
26250
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26254
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26251
26255
  return { deliveryPending, partial: null };
26252
26256
  }
26253
26257
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -26429,7 +26433,7 @@ var TransferMachine = class {
26429
26433
  }
26430
26434
  return added;
26431
26435
  }
26432
- async settlePartial(r, committed, conflicts) {
26436
+ async settlePartial(engine, r, committed, conflicts) {
26433
26437
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
26434
26438
  const entry = {
26435
26439
  transferId: r.transferId,
@@ -26441,26 +26445,27 @@ var TransferMachine = class {
26441
26445
  };
26442
26446
  await this.stores.shortfalls.upsert(entry);
26443
26447
  await this.completeIntent(r.transferId, r.payload);
26444
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26448
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26445
26449
  return entry;
26446
26450
  }
26447
- closeTail(transferId, payload) {
26451
+ closeTail(engine, transferId, payload, committed) {
26448
26452
  void (async () => {
26449
26453
  try {
26450
26454
  await this.completeIntent(transferId, payload);
26451
26455
  } catch {
26452
26456
  return;
26453
26457
  }
26454
- await this.finishBookkeeping(transferId, payload, "sent");
26458
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
26455
26459
  })();
26456
26460
  }
26457
26461
  async completeIntent(transferId, payload) {
26458
26462
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
26459
26463
  await this.deps.intents.complete(transferId, signature);
26460
26464
  }
26461
- async finishBookkeeping(transferId, payload, phase) {
26465
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
26462
26466
  try {
26463
- await this.deps.recordHistory?.({ transferId, payload, phase });
26467
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
26468
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
26464
26469
  } catch {
26465
26470
  }
26466
26471
  try {
@@ -26468,6 +26473,26 @@ var TransferMachine = class {
26468
26473
  } catch {
26469
26474
  }
26470
26475
  }
26476
+ /**
26477
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
26478
+ * certified recipient blobs (summarize()'s committed set is the only source;
26479
+ * a journal-rehydrated resume leg has no source token, but every committed
26480
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
26481
+ */
26482
+ async settledAmount(engine, coinId, committed) {
26483
+ let total = 0n;
26484
+ for (const outcome of committed) {
26485
+ if (outcome.recipientBlob === void 0) continue;
26486
+ const token = await engine.decodeToken({
26487
+ v: TOKEN_BLOB_VERSION,
26488
+ network: 0,
26489
+ tokenId: "",
26490
+ token: outcome.recipientBlob
26491
+ });
26492
+ total += engine.balanceOf(token, coinId);
26493
+ }
26494
+ return total.toString();
26495
+ }
26471
26496
  async disposeOnFailure(transferId, committed, cls) {
26472
26497
  if (committed.length > 0 || cls === "keep-open") return;
26473
26498
  try {
@@ -26492,7 +26517,7 @@ function conflictAmount(engine, payload, op, source) {
26492
26517
  }
26493
26518
 
26494
26519
  // modules/payments-v2/machine/resume.ts
26495
- async function resumeAll(deps) {
26520
+ async function resumeAll(deps, options = {}) {
26496
26521
  const report = { resumed: [], conflicted: [], failed: [] };
26497
26522
  const stores = createMachineStores(deps.kv);
26498
26523
  const open = await deps.intents.listOpen();
@@ -26502,7 +26527,12 @@ async function resumeAll(deps) {
26502
26527
  } catch {
26503
26528
  return report;
26504
26529
  }
26505
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
26530
+ await dropStaleBackstops(
26531
+ stores,
26532
+ locals,
26533
+ new Set(open.map((i) => i.transferId)),
26534
+ options.isLocallyActive ?? (() => false)
26535
+ );
26506
26536
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
26507
26537
  const ctx = {
26508
26538
  deps,
@@ -26515,11 +26545,16 @@ async function resumeAll(deps) {
26515
26545
  }
26516
26546
  return report;
26517
26547
  }
26518
- async function dropStaleLocalAborts(stores, locals, openIds) {
26548
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
26519
26549
  for (const entry of locals.values()) {
26520
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
26550
+ if (openIds.has(entry.transferId)) continue;
26551
+ if (entry.disposition !== "open") {
26521
26552
  await stores.backstop.removeByKey(entry.transferId);
26553
+ continue;
26522
26554
  }
26555
+ if (isLocallyActive(entry.transferId)) continue;
26556
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
26557
+ await stores.backstop.removeByKey(entry.transferId);
26523
26558
  }
26524
26559
  }
26525
26560
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -26727,7 +26762,9 @@ var Converger = class {
26727
26762
  return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
26728
26763
  }
26729
26764
  async resumePass() {
26730
- const report = await resumeAll(this.filteredMachineDeps());
26765
+ const report = await resumeAll(this.filteredMachineDeps(), {
26766
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
26767
+ });
26731
26768
  const progress = report.resumed.length > 0 || report.conflicted.length > 0;
26732
26769
  await this.deps.reconcile({
26733
26770
  resumed: report.resumed,
@@ -26843,6 +26880,136 @@ function distinctOpCount(entries) {
26843
26880
  return new Set(entries.map((entry) => entry.opIndex)).size;
26844
26881
  }
26845
26882
 
26883
+ // modules/payments-v2/restore.ts
26884
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
26885
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
26886
+ async function reseedAndReset(deps) {
26887
+ for (const entry of await deps.stores.backstop.list()) {
26888
+ if (entry.disposition !== "open") continue;
26889
+ try {
26890
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
26891
+ if (entry.requiresSeedClose) {
26892
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
26893
+ }
26894
+ } catch (err) {
26895
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
26896
+ }
26897
+ }
26898
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
26899
+ await deps.fullRePull();
26900
+ }
26901
+ async function splitOpIndex(deps, envelope) {
26902
+ const payload = await deps.decryptPayload(envelope);
26903
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
26904
+ }
26905
+
26906
+ // modules/payments-v2/inventory/presentation.ts
26907
+ function toToken(tokenId, snapshot, asset, registry, flags) {
26908
+ const iconUrl = registry.getIconUrl(asset.coinId);
26909
+ return {
26910
+ id: tokenId,
26911
+ coinId: asset.coinId,
26912
+ symbol: registry.getSymbol(asset.coinId),
26913
+ name: registry.getName(asset.coinId),
26914
+ decimals: registry.getDecimals(asset.coinId),
26915
+ ...iconUrl !== null ? { iconUrl } : {},
26916
+ amount: asset.amount,
26917
+ status: flags.transferring ? "transferring" : "confirmed",
26918
+ createdAt: snapshot.createdAt,
26919
+ updatedAt: snapshot.updatedAt,
26920
+ lazy: true,
26921
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
26922
+ };
26923
+ }
26924
+ function aggregateAssets(entries, isTransferring, registry) {
26925
+ const groups = /* @__PURE__ */ new Map();
26926
+ for (const [tokenId, entry] of entries) {
26927
+ const moving = isTransferring(tokenId);
26928
+ for (const asset of entry.assets) {
26929
+ let group = groups.get(asset.coinId);
26930
+ if (!group) {
26931
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
26932
+ groups.set(asset.coinId, group);
26933
+ }
26934
+ if (moving) {
26935
+ group.transferring += BigInt(asset.amount);
26936
+ group.transferringCount += 1;
26937
+ } else {
26938
+ group.total += BigInt(asset.amount);
26939
+ group.count += 1;
26940
+ }
26941
+ }
26942
+ }
26943
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
26944
+ }
26945
+ function toAsset(coinId, totals, registry) {
26946
+ const iconUrl = registry.getIconUrl(coinId);
26947
+ return {
26948
+ coinId,
26949
+ symbol: registry.getSymbol(coinId),
26950
+ name: registry.getName(coinId),
26951
+ decimals: registry.getDecimals(coinId),
26952
+ ...iconUrl !== null ? { iconUrl } : {},
26953
+ totalAmount: totals.total.toString(),
26954
+ tokenCount: totals.count,
26955
+ confirmedAmount: totals.total.toString(),
26956
+ unconfirmedAmount: "0",
26957
+ confirmedTokenCount: totals.count,
26958
+ unconfirmedTokenCount: 0,
26959
+ transferringTokenCount: totals.transferringCount,
26960
+ transferringAmount: totals.transferring.toString(),
26961
+ priceUsd: null,
26962
+ priceEur: null,
26963
+ change24h: null,
26964
+ fiatValueUsd: null,
26965
+ fiatValueEur: null
26966
+ };
26967
+ }
26968
+ async function withPrices(raw, registry, price) {
26969
+ try {
26970
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
26971
+ const quotes = await price.getPrices(names);
26972
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
26973
+ } catch {
26974
+ return raw;
26975
+ }
26976
+ }
26977
+ function priceOne(asset, quote) {
26978
+ if (!quote) return asset;
26979
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
26980
+ return {
26981
+ ...asset,
26982
+ priceUsd: quote.priceUsd,
26983
+ priceEur: quote.priceEur ?? null,
26984
+ change24h: quote.change24h ?? null,
26985
+ fiatValueUsd: human * quote.priceUsd,
26986
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
26987
+ };
26988
+ }
26989
+
26990
+ // modules/payments-v2/machine/payload.ts
26991
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
26992
+ return {
26993
+ v: 2,
26994
+ recipient: recipientPubkey,
26995
+ coinId: request.coinId,
26996
+ amount: request.amount,
26997
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
26998
+ direct: [...spend.plan.direct],
26999
+ ...spend.plan.split !== void 0 ? {
27000
+ split: {
27001
+ tokenId: spend.plan.split.tokenId,
27002
+ splitAmount: spend.plan.split.splitAmount.toString(),
27003
+ remainderAmount: spend.plan.split.remainderAmount.toString()
27004
+ }
27005
+ } : {},
27006
+ spentStates
27007
+ };
27008
+ }
27009
+ function messageOf(err) {
27010
+ return err instanceof Error ? err.message : String(err);
27011
+ }
27012
+
26846
27013
  // modules/payments-v2/history/History.ts
26847
27014
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
26848
27015
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -26901,13 +27068,14 @@ var History = class {
26901
27068
  }
26902
27069
  // ── internals ────────────────────────────────────────────────────────────────
26903
27070
  async post(build) {
27071
+ const record = build();
26904
27072
  try {
26905
- await this.deps.client.postHistory([build()]);
27073
+ await this.deps.client.postHistory([record]);
26906
27074
  } catch (err) {
26907
27075
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
26908
27076
  return;
26909
27077
  }
26910
- this.deps.emit("history:updated");
27078
+ this.deps.emit("history:updated", this.toEntry(record));
26911
27079
  }
26912
27080
  ts(timestamp) {
26913
27081
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -26964,93 +27132,9 @@ var History = class {
26964
27132
  }
26965
27133
  };
26966
27134
 
26967
- // modules/payments-v2/inventory/presentation.ts
26968
- function toToken(tokenId, snapshot, asset, registry, flags) {
26969
- const iconUrl = registry.getIconUrl(asset.coinId);
26970
- return {
26971
- id: tokenId,
26972
- coinId: asset.coinId,
26973
- symbol: registry.getSymbol(asset.coinId),
26974
- name: registry.getName(asset.coinId),
26975
- decimals: registry.getDecimals(asset.coinId),
26976
- ...iconUrl !== null ? { iconUrl } : {},
26977
- amount: asset.amount,
26978
- status: flags.transferring ? "transferring" : "confirmed",
26979
- createdAt: snapshot.createdAt,
26980
- updatedAt: snapshot.updatedAt,
26981
- lazy: true,
26982
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
26983
- };
26984
- }
26985
- function aggregateAssets(entries, isTransferring, registry) {
26986
- const groups = /* @__PURE__ */ new Map();
26987
- for (const [tokenId, entry] of entries) {
26988
- const moving = isTransferring(tokenId);
26989
- for (const asset of entry.assets) {
26990
- let group = groups.get(asset.coinId);
26991
- if (!group) {
26992
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
26993
- groups.set(asset.coinId, group);
26994
- }
26995
- if (moving) {
26996
- group.transferring += BigInt(asset.amount);
26997
- group.transferringCount += 1;
26998
- } else {
26999
- group.total += BigInt(asset.amount);
27000
- group.count += 1;
27001
- }
27002
- }
27003
- }
27004
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
27005
- }
27006
- function toAsset(coinId, totals, registry) {
27007
- const iconUrl = registry.getIconUrl(coinId);
27008
- return {
27009
- coinId,
27010
- symbol: registry.getSymbol(coinId),
27011
- name: registry.getName(coinId),
27012
- decimals: registry.getDecimals(coinId),
27013
- ...iconUrl !== null ? { iconUrl } : {},
27014
- totalAmount: totals.total.toString(),
27015
- tokenCount: totals.count,
27016
- confirmedAmount: totals.total.toString(),
27017
- unconfirmedAmount: "0",
27018
- confirmedTokenCount: totals.count,
27019
- unconfirmedTokenCount: 0,
27020
- transferringTokenCount: totals.transferringCount,
27021
- transferringAmount: totals.transferring.toString(),
27022
- priceUsd: null,
27023
- priceEur: null,
27024
- change24h: null,
27025
- fiatValueUsd: null,
27026
- fiatValueEur: null
27027
- };
27028
- }
27029
- async function withPrices(raw, registry, price) {
27030
- try {
27031
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
27032
- const quotes = await price.getPrices(names);
27033
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
27034
- } catch {
27035
- return raw;
27036
- }
27037
- }
27038
- function priceOne(asset, quote) {
27039
- if (!quote) return asset;
27040
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
27041
- return {
27042
- ...asset,
27043
- priceUsd: quote.priceUsd,
27044
- priceEur: quote.priceEur ?? null,
27045
- change24h: quote.change24h ?? null,
27046
- fiatValueUsd: human * quote.priceUsd,
27047
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
27048
- };
27049
- }
27050
-
27051
27135
  // modules/payments-v2/inventory/InventoryView.ts
27052
- var SUSPECTED_KEY = "suspected-spent";
27053
- var KNOWN_SPENDS_KEY = "known-spends";
27136
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
27137
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
27054
27138
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
27055
27139
  function stateKey(tokenId, stateHash) {
27056
27140
  return `${tokenId}:${stateHash}`;
@@ -27354,19 +27438,24 @@ var Receive = class {
27354
27438
  const engine = deps.engine();
27355
27439
  const stored = [];
27356
27440
  const pending = [];
27357
- let epoch = "";
27441
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
27358
27442
  try {
27359
- epoch = deps.syncEpoch();
27360
27443
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
27361
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
27362
- for await (const entry of deps.delivery.incoming(since)) {
27363
- await this.processEntry(deps, engine, entry, pending, stored);
27364
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
27444
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
27445
+ for (let pass = 0; pass < 2; pass++) {
27446
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
27447
+ await this.processEntry(deps, engine, entry, pending, stored);
27448
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
27449
+ }
27450
+ await flushAcks(deps, pending, pageEpoch);
27451
+ const served = deps.delivery.incomingEpoch();
27452
+ if (basis === null || served === null || served === basis.syncEpoch) break;
27453
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
27454
+ basis = null;
27365
27455
  }
27366
- await flushAcks(deps, pending, epoch);
27367
27456
  } catch (err) {
27368
27457
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
27369
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
27458
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
27370
27459
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
27371
27460
  });
27372
27461
  }
@@ -27439,7 +27528,7 @@ async function announce(deps, entry, record) {
27439
27528
  receivedAt
27440
27529
  };
27441
27530
  }
27442
- async function flushAcks(deps, pending, epoch) {
27531
+ async function flushAcks(deps, pending, epochOf) {
27443
27532
  let lastAcked = null;
27444
27533
  try {
27445
27534
  while (pending.length > 0) {
@@ -27450,7 +27539,7 @@ async function flushAcks(deps, pending, epoch) {
27450
27539
  }
27451
27540
  } finally {
27452
27541
  if (lastAcked !== null) {
27453
- const record = { cursor: lastAcked, syncEpoch: epoch };
27542
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
27454
27543
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
27455
27544
  }
27456
27545
  }
@@ -27551,9 +27640,19 @@ var Requests = class {
27551
27640
  }
27552
27641
  });
27553
27642
  }
27643
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
27554
27644
  writeLink(requestId, transferId, committed) {
27555
27645
  return this.mutateJournal((m) => {
27556
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
27646
+ const prev = m.get(requestId);
27647
+ const same = prev !== void 0 && prev.transferId === transferId;
27648
+ const next = {
27649
+ requestId,
27650
+ transferId,
27651
+ committed: committed || same && prev.committed,
27652
+ createdAt: same ? prev.createdAt : this.now()
27653
+ };
27654
+ if (same && prev.committed === next.committed) return false;
27655
+ m.set(requestId, next);
27557
27656
  return true;
27558
27657
  });
27559
27658
  }
@@ -27706,23 +27805,46 @@ var Requests = class {
27706
27805
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
27707
27806
  if (transferId !== void 0) {
27708
27807
  const committed = error instanceof PartialSendConflictError;
27709
- await this.writeLink(id, transferId, committed);
27710
- this.setStatus(id, "settling");
27808
+ await this.settle(id, transferId, { committed, respond: false });
27711
27809
  } else {
27712
27810
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
27713
27811
  this.setStatus(id, "paid");
27714
27812
  }
27715
27813
  } else {
27716
- await this.clearLink(id);
27717
- this.setStatus(id, "pending");
27814
+ await this.revertPayable(id);
27718
27815
  }
27719
27816
  throw error;
27720
27817
  }
27721
- this.setStatus(id, "paid");
27722
- await this.respondPaid(id, result.id);
27723
- await this.clearLink(id);
27818
+ await this.settle(id, result.id, { committed: true, respond: true });
27724
27819
  return result;
27725
27820
  }
27821
+ /**
27822
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
27823
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
27824
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
27825
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
27826
+ * not a reload. Every path that binds a request to a transfer outcome funnels
27827
+ * through here — pay()'s clean success (respond now), pay()'s
27828
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
27829
+ * — so a failed respond always leaves the link + 'settling' and the next
27830
+ * reconcile pass (the committed-link override) retries the respond.
27831
+ */
27832
+ async settle(requestId, transferId, opts) {
27833
+ let journalDown = false;
27834
+ try {
27835
+ await this.writeLink(requestId, transferId, opts.committed);
27836
+ } catch (err) {
27837
+ if (!opts.respond) throw err;
27838
+ journalDown = true;
27839
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
27840
+ }
27841
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
27842
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
27843
+ return;
27844
+ }
27845
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
27846
+ this.setStatus(requestId, "paid");
27847
+ }
27726
27848
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
27727
27849
  async respondPaid(id, transferId) {
27728
27850
  try {
@@ -27758,32 +27880,32 @@ var Requests = class {
27758
27880
  let aborted = null;
27759
27881
  for (const [requestId, link] of [...this.journal]) {
27760
27882
  if (link.committed || resumed.has(link.transferId)) {
27761
- await this.resolvePaid(requestId, link.transferId);
27883
+ await this.resolvePaid(requestId, link);
27762
27884
  } else if (conflicted.has(link.transferId)) {
27763
27885
  await this.revertPayable(requestId);
27764
27886
  } else if (open.has(link.transferId)) {
27765
27887
  } else {
27766
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
27888
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
27767
27889
  }
27768
27890
  }
27769
27891
  }
27770
27892
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
27771
27893
  // a probe failure defers the link to the next session.
27772
- async reconcileUnaccounted(requestId, transferId, aborted) {
27894
+ async reconcileUnaccounted(requestId, link, aborted) {
27773
27895
  try {
27774
27896
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
27775
- if (aborted.has(transferId)) await this.revertPayable(requestId);
27776
- else await this.resolvePaid(requestId, transferId);
27897
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
27898
+ else await this.resolvePaid(requestId, link);
27777
27899
  } catch (err) {
27778
27900
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
27779
27901
  }
27780
27902
  return aborted;
27781
27903
  }
27782
- async resolvePaid(requestId, transferId) {
27783
- if (!await this.respondPaid(requestId, transferId)) return;
27784
- await this.clearLink(requestId);
27785
- this.setStatus(requestId, "paid");
27904
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
27905
+ resolvePaid(requestId, link) {
27906
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
27786
27907
  }
27908
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
27787
27909
  async revertPayable(requestId) {
27788
27910
  await this.clearLink(requestId);
27789
27911
  this.setStatus(requestId, "pending");
@@ -28107,11 +28229,12 @@ function composeFacadeParts(deps, hooks) {
28107
28229
  client: deps.client,
28108
28230
  fieldKey: deps.fieldKey,
28109
28231
  registry: deps.registry,
28110
- emit: (event) => deps.emit(event, {}),
28232
+ emit: (event, entry) => deps.emit(event, entry),
28111
28233
  ...deps.now !== void 0 ? { now: deps.now } : {},
28112
28234
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
28113
28235
  });
28114
28236
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
28237
+ const machineStores = createMachineStores(deps.kv);
28115
28238
  const heldStates = /* @__PURE__ */ new Map();
28116
28239
  return {
28117
28240
  ownPubkeyBytes,
@@ -28119,12 +28242,29 @@ function composeFacadeParts(deps, hooks) {
28119
28242
  ledger,
28120
28243
  queue,
28121
28244
  historyStore,
28122
- machineStores: createMachineStores(deps.kv),
28245
+ machineStores,
28123
28246
  machineDeps,
28124
28247
  machine: new TransferMachine(machineDeps),
28125
28248
  heldStates,
28126
28249
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
28127
- requests: buildRequests(deps, hooks)
28250
+ requests: buildRequests(deps, hooks),
28251
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
28252
+ };
28253
+ }
28254
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
28255
+ return {
28256
+ stores: machineStores,
28257
+ kv: deps.kv,
28258
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
28259
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
28260
+ decryptPayload: machineDeps.decryptPayload,
28261
+ fullRePull: () => view.onEpochReset(),
28262
+ attention: (transferId, code, detail) => {
28263
+ deps.emit(
28264
+ "transfer:attention",
28265
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28266
+ );
28267
+ }
28128
28268
  };
28129
28269
  }
28130
28270
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -28146,11 +28286,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
28146
28286
  ownPubkey: hexToBytes4(deps.ownPubkey),
28147
28287
  emit: deps.emit,
28148
28288
  now: deps.now ?? Date.now,
28149
- recordHistory: async ({ transferId, payload }) => {
28289
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
28150
28290
  await historyStore.recordSent({
28151
28291
  transferId,
28152
28292
  coinId: payload.coinId,
28153
- amount: payload.amount,
28293
+ // §5.9: the SETTLED amount (machine-computed from the certified
28294
+ // recipient blobs), never payload.amount — the plan.
28295
+ amount: committedAmount,
28154
28296
  recipientPubkey: payload.recipient,
28155
28297
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
28156
28298
  });
@@ -28177,7 +28319,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
28177
28319
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
28178
28320
  );
28179
28321
  },
28180
- syncEpoch: deps.syncEpoch ?? (() => ""),
28322
+ syncEpoch: deps.syncEpoch,
28181
28323
  ...deps.now !== void 0 ? { now: deps.now } : {}
28182
28324
  });
28183
28325
  }
@@ -28230,6 +28372,7 @@ var PaymentsFacade = class {
28230
28372
  this.receiveLoop = parts.receiveLoop;
28231
28373
  this.heldStates = parts.heldStates;
28232
28374
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
28375
+ this.restoreDeps = parts.restoreDeps;
28233
28376
  this.requests = parts.requests;
28234
28377
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
28235
28378
  this.heartbeat = new ConvergenceHeartbeat({
@@ -28258,6 +28401,7 @@ var PaymentsFacade = class {
28258
28401
  receiveLoop;
28259
28402
  heldStates;
28260
28403
  ownPubkeyBytes;
28404
+ restoreDeps;
28261
28405
  requests;
28262
28406
  currentEngine = null;
28263
28407
  pendingOps = /* @__PURE__ */ new Set();
@@ -28268,6 +28412,8 @@ var PaymentsFacade = class {
28268
28412
  converger;
28269
28413
  /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
28270
28414
  resumeFlight = new SingleFlight();
28415
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
28416
+ passChain = new SerialChain();
28271
28417
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
28272
28418
  activeMoneyOps = /* @__PURE__ */ new Set();
28273
28419
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -28275,8 +28421,8 @@ var PaymentsFacade = class {
28275
28421
  if (this.started) return;
28276
28422
  this.started = true;
28277
28423
  this.heartbeat.start();
28278
- await this.deps.session.start();
28279
28424
  this.unsubscribers.push(
28425
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
28280
28426
  this.deps.session.subscribeStream("mailbox", () => {
28281
28427
  this.trackTail(this.receiveLoop.drainOnce());
28282
28428
  }),
@@ -28291,6 +28437,7 @@ var PaymentsFacade = class {
28291
28437
  if (status === "connected") this.heartbeat.recovered();
28292
28438
  });
28293
28439
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
28440
+ await this.deps.session.start();
28294
28441
  this.trackTail(this.runConvergencePass());
28295
28442
  this.trackTail(this.seedHeldStates());
28296
28443
  await this.view.fullPull().catch(() => void 0);
@@ -28331,7 +28478,7 @@ var PaymentsFacade = class {
28331
28478
  }
28332
28479
  // ── money movement ─────────────────────────────────────────────────────────
28333
28480
  send(request) {
28334
- return this.track(this.sendWithPolicy(request));
28481
+ return this.track(this.sendOutcome(request));
28335
28482
  }
28336
28483
  async receive() {
28337
28484
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -28346,20 +28493,27 @@ var PaymentsFacade = class {
28346
28493
  if (!this.started) return;
28347
28494
  await this.track(this.runConvergencePass());
28348
28495
  }
28496
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
28497
+ async handleEpochChange(_newEpoch) {
28498
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
28499
+ await this.passChain.enqueue(() => this.convergeBody());
28500
+ }
28349
28501
  /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
28350
28502
  runConvergencePass() {
28351
- return this.resumeFlight.run(async () => {
28352
- const outcome = await this.converger.convergeOnce();
28353
- const pending = await this.converger.pendingWork().catch(() => true);
28354
- this.heartbeat.settle(outcome, pending);
28355
- });
28503
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
28504
+ }
28505
+ async convergeBody() {
28506
+ const outcome = await this.converger.convergeOnce();
28507
+ const pending = await this.converger.pendingWork().catch(() => true);
28508
+ this.heartbeat.settle(outcome, pending);
28356
28509
  }
28357
28510
  nowMs() {
28358
28511
  return (this.deps.now ?? Date.now)();
28359
28512
  }
28360
28513
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
28361
- async sendWithPolicy(request) {
28362
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
28514
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
28515
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
28516
+ async sendOutcome(request) {
28363
28517
  const run = {
28364
28518
  amount: request.amount,
28365
28519
  delivered: [],
@@ -28367,12 +28521,36 @@ var PaymentsFacade = class {
28367
28521
  tokenTransfers: [],
28368
28522
  settledShortfalls: [],
28369
28523
  firstPartialId: void 0,
28370
- pendingAny: false
28524
+ pendingAny: false,
28525
+ lastTransferId: ""
28526
+ };
28527
+ try {
28528
+ return await this.sendWithPolicy(request, run);
28529
+ } catch (err) {
28530
+ this.emitCleanFailure(err, run.lastTransferId);
28531
+ throw err;
28532
+ }
28533
+ }
28534
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
28535
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
28536
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
28537
+ emitCleanFailure(err, transferId) {
28538
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
28539
+ const failed = {
28540
+ id: transferId,
28541
+ status: "failed",
28542
+ tokens: [],
28543
+ tokenTransfers: [],
28544
+ error: messageOf(err)
28371
28545
  };
28546
+ this.deps.emit("transfer:updated", failed);
28547
+ }
28548
+ async sendWithPolicy(request, run) {
28549
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
28372
28550
  for (let attempt = 0; ; attempt++) {
28373
28551
  let ctx;
28374
28552
  try {
28375
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
28553
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
28376
28554
  } catch (err) {
28377
28555
  throw this.partialize(err, run);
28378
28556
  }
@@ -28417,11 +28595,8 @@ var PaymentsFacade = class {
28417
28595
  this.activeMoneyOps.delete(ctx.transferId);
28418
28596
  }
28419
28597
  }
28420
- /**
28421
- * Partial outcome (#677/#690): the shortfall is already durable (written by
28422
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
28423
- * the remainder under a NEW transferId — never the full amount.
28424
- */
28598
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
28599
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
28425
28600
  async consumePartial(ctx, disposition, run, attempt) {
28426
28601
  if (disposition.deliveryPending) {
28427
28602
  run.pendingAny = true;
@@ -28447,11 +28622,8 @@ var PaymentsFacade = class {
28447
28622
  run.amount = partial.remainingAmount;
28448
28623
  return null;
28449
28624
  }
28450
- /**
28451
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
28452
- * backstop still 'open' (committed>0) → converge via the same machine's
28453
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
28454
- */
28625
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
28626
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
28455
28627
  async disposeFailedAttempt(ctx, err) {
28456
28628
  if (isPossiblyCommittedSendOutcome(err)) {
28457
28629
  this.settleKeepOpen(ctx);
@@ -28500,8 +28672,9 @@ var PaymentsFacade = class {
28500
28672
  }
28501
28673
  return { kind: "rethrow", error: err };
28502
28674
  }
28503
- async planAndMaterialize(recipientPubkey, request) {
28675
+ async planAndMaterialize(recipientPubkey, request, run) {
28504
28676
  const transferId = this.newId();
28677
+ run.lastTransferId = transferId;
28505
28678
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
28506
28679
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
28507
28680
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -28633,11 +28806,7 @@ var PaymentsFacade = class {
28633
28806
  this.deps.emit("transfer:updated", result);
28634
28807
  return result;
28635
28808
  }
28636
- /**
28637
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
28638
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
28639
- * over the accumulated settled set — never bare, never a full-amount retry.
28640
- */
28809
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
28641
28810
  partialize(err, run) {
28642
28811
  if (run.delivered.length === 0) return err;
28643
28812
  return new PartialSendConflictError(
@@ -28789,22 +28958,14 @@ var PaymentsFacade = class {
28789
28958
  }
28790
28959
  }
28791
28960
  toUiToken(tokenId, coinId, amount) {
28792
- const registry = this.deps.registry;
28793
- const iconUrl = registry.getIconUrl(coinId);
28794
28961
  const now = this.nowMs();
28795
- return {
28796
- id: tokenId,
28797
- coinId,
28798
- symbol: registry.getSymbol(coinId),
28799
- name: registry.getName(coinId),
28800
- decimals: registry.getDecimals(coinId),
28801
- ...iconUrl !== null ? { iconUrl } : {},
28802
- amount: amount.toString(),
28803
- status: "transferring",
28804
- createdAt: now,
28805
- updatedAt: now,
28806
- lazy: true
28807
- };
28962
+ return toToken(
28963
+ tokenId,
28964
+ { assets: [], createdAt: now, updatedAt: now },
28965
+ { coinId, amount: amount.toString() },
28966
+ this.deps.registry,
28967
+ { transferring: true, suspectedSpent: false }
28968
+ );
28808
28969
  }
28809
28970
  track(op) {
28810
28971
  const settled = op.then(
@@ -28819,27 +28980,6 @@ var PaymentsFacade = class {
28819
28980
  this.track(op).catch(() => void 0);
28820
28981
  }
28821
28982
  };
28822
- function buildPayload(recipientPubkey, request, spend, spentStates) {
28823
- return {
28824
- v: 2,
28825
- recipient: recipientPubkey,
28826
- coinId: request.coinId,
28827
- amount: request.amount,
28828
- ...request.memo !== void 0 ? { memo: request.memo } : {},
28829
- direct: [...spend.plan.direct],
28830
- ...spend.plan.split !== void 0 ? {
28831
- split: {
28832
- tokenId: spend.plan.split.tokenId,
28833
- splitAmount: spend.plan.split.splitAmount.toString(),
28834
- remainderAmount: spend.plan.split.remainderAmount.toString()
28835
- }
28836
- } : {},
28837
- spentStates
28838
- };
28839
- }
28840
- function messageOf(err) {
28841
- return err instanceof Error ? err.message : String(err);
28842
- }
28843
28983
 
28844
28984
  // impl/wallet-api-v2/http.ts
28845
28985
  var WalletApiHttpError = class extends Error {
@@ -29185,6 +29325,7 @@ var WalletApiSession = class {
29185
29325
  epochLatch = null;
29186
29326
  reseedChain = Promise.resolve();
29187
29327
  reseedsPending = 0;
29328
+ epochHandlers = /* @__PURE__ */ new Set();
29188
29329
  handlers = /* @__PURE__ */ new Map();
29189
29330
  statusHandlers = /* @__PURE__ */ new Set();
29190
29331
  socket = null;
@@ -29287,6 +29428,18 @@ var WalletApiSession = class {
29287
29428
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
29288
29429
  }
29289
29430
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
29431
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
29432
+ currentEpoch() {
29433
+ return this.epochLatch ?? "";
29434
+ }
29435
+ /**
29436
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
29437
+ * the reseed chain, BEFORE any stream nudge resumes.
29438
+ */
29439
+ subscribeEpochChange(handler) {
29440
+ this.epochHandlers.add(handler);
29441
+ return () => this.epochHandlers.delete(handler);
29442
+ }
29290
29443
  noteEpoch(epoch) {
29291
29444
  if (this.epochLatch === epoch) return;
29292
29445
  const previous = this.epochLatch;
@@ -29300,7 +29453,7 @@ var WalletApiSession = class {
29300
29453
  }
29301
29454
  async runReseed(epoch, previous) {
29302
29455
  try {
29303
- await this.deps.onEpochChange(epoch);
29456
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
29304
29457
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
29305
29458
  } catch {
29306
29459
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -29562,6 +29715,7 @@ var WalletApiDeliveryPort = class {
29562
29715
  wake;
29563
29716
  deriveFn;
29564
29717
  seenChain = new SerialChain();
29718
+ lastIncomingEpoch = null;
29565
29719
  constructor(config) {
29566
29720
  this.client = config.client;
29567
29721
  this.kv = config.kv;
@@ -29656,6 +29810,7 @@ var WalletApiDeliveryPort = class {
29656
29810
  const seen = await this.readSeen();
29657
29811
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
29658
29812
  for (; ; ) {
29813
+ this.lastIncomingEpoch = String(page.syncEpoch);
29659
29814
  for (const entry of page.entries) {
29660
29815
  if (entry.status !== "unclaimed") continue;
29661
29816
  if (seen.has(entry.entryId)) continue;
@@ -29665,6 +29820,10 @@ var WalletApiDeliveryPort = class {
29665
29820
  page = await this.client.listMailbox(page.cursor);
29666
29821
  }
29667
29822
  }
29823
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
29824
+ incomingEpoch() {
29825
+ return this.lastIncomingEpoch;
29826
+ }
29668
29827
  toIncoming(entry) {
29669
29828
  const bundle = this.openEnvelope(entry);
29670
29829
  return {
@@ -29742,7 +29901,7 @@ var WalletApiSplitCheckpointStore = class {
29742
29901
  return utf8(`${transferId}:${String(opIndex)}`);
29743
29902
  }
29744
29903
  async put(transferId, opIndex, bytes) {
29745
- const { client, kv, fieldKey, signProgress } = this.config;
29904
+ const { kv, fieldKey } = this.config;
29746
29905
  const aad = this.aad(transferId, opIndex);
29747
29906
  const cacheKey = this.cacheKey(transferId, opIndex);
29748
29907
  let envelope = await kv.get(cacheKey);
@@ -29750,10 +29909,21 @@ var WalletApiSplitCheckpointStore = class {
29750
29909
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
29751
29910
  await kv.set(cacheKey, envelope);
29752
29911
  }
29753
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
29754
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
29912
+ const record = await this.post(transferId, opIndex, envelope);
29755
29913
  return this.open(record.payload, aad);
29756
29914
  }
29915
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
29916
+ async reseedCheckpoint(transferId, opIndex) {
29917
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
29918
+ if (envelope === null) return false;
29919
+ await this.post(transferId, opIndex, envelope);
29920
+ return true;
29921
+ }
29922
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
29923
+ async post(transferId, opIndex, envelope) {
29924
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
29925
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
29926
+ }
29757
29927
  async get(transferId, opIndex) {
29758
29928
  const records = await this.config.client.getProgress(transferId);
29759
29929
  const record = records.find((r) => r.opIndex === opIndex);
@@ -29776,7 +29946,7 @@ var WalletApiSplitCheckpointStore = class {
29776
29946
  };
29777
29947
 
29778
29948
  // core/payments-v2-wiring.ts
29779
- function resolvePaymentsV2Composition(walletApi) {
29949
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
29780
29950
  const source = walletApi;
29781
29951
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
29782
29952
  if (!source || network === null) {
@@ -29785,6 +29955,18 @@ function resolvePaymentsV2Composition(walletApi) {
29785
29955
  "INVALID_CONFIG"
29786
29956
  );
29787
29957
  }
29958
+ if (!(network in NETWORKS)) {
29959
+ throw new SphereError(
29960
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
29961
+ "INVALID_CONFIG"
29962
+ );
29963
+ }
29964
+ if (network !== sphereNetwork) {
29965
+ throw new SphereError(
29966
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
29967
+ "INVALID_CONFIG"
29968
+ );
29969
+ }
29788
29970
  if (typeof source.paymentsV2Transport === "function") {
29789
29971
  return { network, factory: source.paymentsV2Transport.bind(source) };
29790
29972
  }
@@ -29850,8 +30032,7 @@ function defaultPaymentsV2Transport(config) {
29850
30032
  deviceId: `${config.deviceId}:pv2`,
29851
30033
  kv: args.kv,
29852
30034
  webSocketFactory,
29853
- emitStatus: args.emitStatus,
29854
- onEpochChange: args.onEpochChange
30035
+ emitStatus: args.emitStatus
29855
30036
  });
29856
30037
  return { session, client: authedWireClient(session, client) };
29857
30038
  };
@@ -29921,8 +30102,7 @@ function composePaymentsV2(spec) {
29921
30102
  identity: identitySlice,
29922
30103
  network,
29923
30104
  kv,
29924
- emitStatus: (status) => host.emit("connection:status", { status }),
29925
- onEpochChange: async () => void 0
30105
+ emitStatus: (status) => host.emit("connection:status", { status })
29926
30106
  });
29927
30107
  const deps = {
29928
30108
  session: transport.session,
@@ -29952,7 +30132,10 @@ function composePaymentsV2(spec) {
29952
30132
  network,
29953
30133
  ownPubkey: identity.chainPubkey,
29954
30134
  ownNametag: host.nametag,
29955
- requestMemo: requestMemoCodec(identity.privateKey)
30135
+ requestMemo: requestMemoCodec(identity.privateKey),
30136
+ // §5.1: the session is the epoch authority — the facade registers its
30137
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
30138
+ syncEpoch: () => transport.session.currentEpoch()
29956
30139
  };
29957
30140
  return new PaymentsFacade(deps);
29958
30141
  }
@@ -30282,18 +30465,20 @@ var Sphere = class _Sphere {
30282
30465
  }
30283
30466
  /**
30284
30467
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
30285
- * fail-closed at init, never a silently-degraded composition.
30468
+ * fail-closed at init, never a silently-degraded composition. Returns the
30469
+ * resolved composition (null = flag off); #728: resolving here also asserts
30470
+ * the single-network invariant (walletApi.network known + equal to the
30471
+ * Sphere network) before any session/KV/provider exists.
30286
30472
  */
30287
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
30288
- if (flag !== true) return false;
30473
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
30474
+ if (flag !== true) return null;
30289
30475
  if (accountingConfig || swapConfig) {
30290
30476
  throw new SphereError(
30291
30477
  "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.",
30292
30478
  "INVALID_CONFIG"
30293
30479
  );
30294
30480
  }
30295
- resolvePaymentsV2Composition(walletApi);
30296
- return true;
30481
+ return resolvePaymentsV2Composition(walletApi, network);
30297
30482
  }
30298
30483
  /**
30299
30484
  * Configure TokenRegistry in the main bundle context.
@@ -30348,12 +30533,14 @@ var Sphere = class _Sphere {
30348
30533
  options.walletApi
30349
30534
  );
30350
30535
  sphere._verification = options.verification;
30351
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30536
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30352
30537
  options.paymentsV2,
30353
30538
  accountingConfig,
30354
30539
  swapConfig,
30355
- options.walletApi
30540
+ options.walletApi,
30541
+ options.network
30356
30542
  );
30543
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30357
30544
  sphere._password = options.password ?? null;
30358
30545
  sphere._passwordProtected = sphere._password !== null;
30359
30546
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -30420,12 +30607,14 @@ var Sphere = class _Sphere {
30420
30607
  options.walletApi
30421
30608
  );
30422
30609
  sphere._verification = options.verification;
30423
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30610
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30424
30611
  options.paymentsV2,
30425
30612
  accountingConfig,
30426
30613
  swapConfig,
30427
- options.walletApi
30614
+ options.walletApi,
30615
+ options.network
30428
30616
  );
30617
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30429
30618
  sphere._password = options.password ?? null;
30430
30619
  sphere._passwordProtected = sphere._password !== null;
30431
30620
  if (!options.storage.isConnected()) {
@@ -30499,12 +30688,14 @@ var Sphere = class _Sphere {
30499
30688
  options.walletApi
30500
30689
  );
30501
30690
  sphere._verification = options.verification;
30502
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30691
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30503
30692
  options.paymentsV2,
30504
30693
  accountingConfig,
30505
30694
  swapConfig,
30506
- options.walletApi
30695
+ options.walletApi,
30696
+ options.network
30507
30697
  );
30698
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30508
30699
  sphere._password = options.password ?? null;
30509
30700
  sphere._passwordProtected = sphere._password !== null;
30510
30701
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -33162,7 +33353,9 @@ var Sphere = class _Sphere {
33162
33353
  * setOracleApiKey rebuild is what future operations snapshot.
33163
33354
  */
33164
33355
  async startPaymentsV2Inner(index, identity) {
33165
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
33356
+ if (this._paymentsV2Composition === null) {
33357
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
33358
+ }
33166
33359
  if (!this._addressModules.get(index)?.tokenEngine) {
33167
33360
  throw new SphereError(
33168
33361
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",