@unicitylabs/sphere-sdk 0.14.0-dev.5 → 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.
@@ -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
@@ -25991,6 +25995,10 @@ function isValidationReject(err) {
25991
25995
  if (e === null) return false;
25992
25996
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
25993
25997
  }
25998
+ function retryAfterMsOf(err) {
25999
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
26000
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
26001
+ }
25994
26002
  var chainRegistry = /* @__PURE__ */ new WeakMap();
25995
26003
  var replayRegistry = /* @__PURE__ */ new WeakMap();
25996
26004
  function registryFor(registry, kv, storeKey, make) {
@@ -26214,7 +26222,7 @@ var TransferMachine = class {
26214
26222
  }
26215
26223
  if (cls === null) {
26216
26224
  await this.applyCommitted(engine, plan.transferId, committed);
26217
- this.closeTail(plan.transferId, plan.payload);
26225
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
26218
26226
  return { committed, deliveryPending };
26219
26227
  }
26220
26228
  if (cls === "conflict" && committed.length > 0) {
@@ -26240,10 +26248,10 @@ var TransferMachine = class {
26240
26248
  }
26241
26249
  await this.applyCommitted(engine, r.transferId, committed);
26242
26250
  if (conflicts.length > 0) {
26243
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
26251
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
26244
26252
  }
26245
26253
  await this.completeIntent(r.transferId, r.payload);
26246
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26254
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26247
26255
  return { deliveryPending, partial: null };
26248
26256
  }
26249
26257
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -26425,7 +26433,7 @@ var TransferMachine = class {
26425
26433
  }
26426
26434
  return added;
26427
26435
  }
26428
- async settlePartial(r, committed, conflicts) {
26436
+ async settlePartial(engine, r, committed, conflicts) {
26429
26437
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
26430
26438
  const entry = {
26431
26439
  transferId: r.transferId,
@@ -26437,26 +26445,27 @@ var TransferMachine = class {
26437
26445
  };
26438
26446
  await this.stores.shortfalls.upsert(entry);
26439
26447
  await this.completeIntent(r.transferId, r.payload);
26440
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26448
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26441
26449
  return entry;
26442
26450
  }
26443
- closeTail(transferId, payload) {
26451
+ closeTail(engine, transferId, payload, committed) {
26444
26452
  void (async () => {
26445
26453
  try {
26446
26454
  await this.completeIntent(transferId, payload);
26447
26455
  } catch {
26448
26456
  return;
26449
26457
  }
26450
- await this.finishBookkeeping(transferId, payload, "sent");
26458
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
26451
26459
  })();
26452
26460
  }
26453
26461
  async completeIntent(transferId, payload) {
26454
26462
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
26455
26463
  await this.deps.intents.complete(transferId, signature);
26456
26464
  }
26457
- async finishBookkeeping(transferId, payload, phase) {
26465
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
26458
26466
  try {
26459
- 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 });
26460
26469
  } catch {
26461
26470
  }
26462
26471
  try {
@@ -26464,6 +26473,26 @@ var TransferMachine = class {
26464
26473
  } catch {
26465
26474
  }
26466
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
+ }
26467
26496
  async disposeOnFailure(transferId, committed, cls) {
26468
26497
  if (committed.length > 0 || cls === "keep-open") return;
26469
26498
  try {
@@ -26488,7 +26517,7 @@ function conflictAmount(engine, payload, op, source) {
26488
26517
  }
26489
26518
 
26490
26519
  // modules/payments-v2/machine/resume.ts
26491
- async function resumeAll(deps) {
26520
+ async function resumeAll(deps, options = {}) {
26492
26521
  const report = { resumed: [], conflicted: [], failed: [] };
26493
26522
  const stores = createMachineStores(deps.kv);
26494
26523
  const open = await deps.intents.listOpen();
@@ -26498,7 +26527,12 @@ async function resumeAll(deps) {
26498
26527
  } catch {
26499
26528
  return report;
26500
26529
  }
26501
- 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
+ );
26502
26536
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
26503
26537
  const ctx = {
26504
26538
  deps,
@@ -26511,11 +26545,16 @@ async function resumeAll(deps) {
26511
26545
  }
26512
26546
  return report;
26513
26547
  }
26514
- async function dropStaleLocalAborts(stores, locals, openIds) {
26548
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
26515
26549
  for (const entry of locals.values()) {
26516
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
26550
+ if (openIds.has(entry.transferId)) continue;
26551
+ if (entry.disposition !== "open") {
26517
26552
  await stores.backstop.removeByKey(entry.transferId);
26553
+ continue;
26518
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);
26519
26558
  }
26520
26559
  }
26521
26560
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -26628,6 +26667,349 @@ function validatePayload(raw) {
26628
26667
  return p;
26629
26668
  }
26630
26669
 
26670
+ // modules/payments-v2/convergence.ts
26671
+ var HEARTBEAT_SEED_MS = 5e3;
26672
+ var HEARTBEAT_CAP_MS = 12e4;
26673
+ var ConvergenceHeartbeat = class {
26674
+ constructor(deps) {
26675
+ this.deps = deps;
26676
+ }
26677
+ timer = null;
26678
+ delayMs = HEARTBEAT_SEED_MS;
26679
+ notBefore = 0;
26680
+ running = false;
26681
+ start() {
26682
+ this.running = true;
26683
+ this.delayMs = HEARTBEAT_SEED_MS;
26684
+ this.notBefore = 0;
26685
+ }
26686
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
26687
+ stop() {
26688
+ this.running = false;
26689
+ this.clearTimer();
26690
+ }
26691
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
26692
+ settle(outcome, pending) {
26693
+ if (outcome.retryAfterMs !== null) {
26694
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
26695
+ }
26696
+ if (!pending) {
26697
+ this.delayMs = HEARTBEAT_SEED_MS;
26698
+ return;
26699
+ }
26700
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
26701
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
26702
+ this.schedule(delay);
26703
+ }
26704
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
26705
+ arm() {
26706
+ if (!this.running) return;
26707
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
26708
+ this.schedule(HEARTBEAT_SEED_MS);
26709
+ }
26710
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
26711
+ recovered() {
26712
+ this.delayMs = HEARTBEAT_SEED_MS;
26713
+ if (this.timer !== null) this.arm();
26714
+ }
26715
+ schedule(delayMs) {
26716
+ this.clearTimer();
26717
+ if (!this.running) return;
26718
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
26719
+ this.timer = setTimeout(() => {
26720
+ this.timer = null;
26721
+ this.deps.onTick();
26722
+ }, floored);
26723
+ }
26724
+ clearTimer() {
26725
+ if (this.timer !== null) clearTimeout(this.timer);
26726
+ this.timer = null;
26727
+ }
26728
+ };
26729
+ var Converger = class {
26730
+ constructor(deps) {
26731
+ this.deps = deps;
26732
+ }
26733
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
26734
+ async convergeOnce() {
26735
+ let progress = false;
26736
+ let retryAfterMs = null;
26737
+ try {
26738
+ progress = await this.resumePass();
26739
+ } catch (err) {
26740
+ retryAfterMs = retryAfterMsOf(err);
26741
+ }
26742
+ try {
26743
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
26744
+ progress = progress || stats.delivered > 0;
26745
+ } catch (err) {
26746
+ retryAfterMs ??= retryAfterMsOf(err);
26747
+ }
26748
+ try {
26749
+ progress = await this.deps.replayMints() > 0 || progress;
26750
+ } catch (err) {
26751
+ retryAfterMs ??= retryAfterMsOf(err);
26752
+ }
26753
+ return { progress, retryAfterMs };
26754
+ }
26755
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
26756
+ async pendingWork() {
26757
+ const [backstop, journal, mints] = await Promise.all([
26758
+ this.deps.stores.backstop.list(),
26759
+ this.deps.stores.deliveryJournal.list(),
26760
+ this.deps.stores.mintJournal.list()
26761
+ ]);
26762
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
26763
+ }
26764
+ async resumePass() {
26765
+ const report = await resumeAll(this.filteredMachineDeps(), {
26766
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
26767
+ });
26768
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
26769
+ await this.deps.reconcile({
26770
+ resumed: report.resumed,
26771
+ conflicted: report.conflicted,
26772
+ open: report.failed
26773
+ });
26774
+ if (progress) await this.deps.refreshView().catch(() => void 0);
26775
+ return progress;
26776
+ }
26777
+ filteredMachineDeps() {
26778
+ const intents = this.deps.machineDeps.intents;
26779
+ return {
26780
+ ...this.deps.machineDeps,
26781
+ intents: {
26782
+ ...intents,
26783
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
26784
+ }
26785
+ };
26786
+ }
26787
+ replayDeps() {
26788
+ return {
26789
+ delivery: this.deps.delivery,
26790
+ now: () => this.deps.now(),
26791
+ attention: (transferId, code, detail) => {
26792
+ this.deps.emit(
26793
+ "transfer:attention",
26794
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
26795
+ );
26796
+ }
26797
+ };
26798
+ }
26799
+ };
26800
+ async function derivePendingTransfers(stores, decryptPayload) {
26801
+ const [backstop, journal, shortfalls] = await Promise.all([
26802
+ stores.backstop.list(),
26803
+ stores.deliveryJournal.list(),
26804
+ stores.shortfalls.list()
26805
+ ]);
26806
+ const byTransfer = groupJournal(journal);
26807
+ const out = [];
26808
+ const seen = /* @__PURE__ */ new Set();
26809
+ for (const entry of backstop) {
26810
+ if (entry.disposition !== "open") continue;
26811
+ seen.add(entry.transferId);
26812
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
26813
+ }
26814
+ for (const [transferId, entries] of byTransfer) {
26815
+ if (seen.has(transferId)) continue;
26816
+ seen.add(transferId);
26817
+ out.push(journalOnlyRow(transferId, entries));
26818
+ }
26819
+ for (const entry of shortfalls) {
26820
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
26821
+ }
26822
+ return out;
26823
+ }
26824
+ function groupJournal(journal) {
26825
+ const byTransfer = /* @__PURE__ */ new Map();
26826
+ for (const entry of journal) {
26827
+ const mine = byTransfer.get(entry.transferId);
26828
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
26829
+ else mine.push(entry);
26830
+ }
26831
+ return byTransfer;
26832
+ }
26833
+ async function openRow(entry, journal, decryptPayload) {
26834
+ let payload = null;
26835
+ try {
26836
+ const raw = await decryptPayload(entry.payloadEnvelope);
26837
+ if (raw !== null && typeof raw === "object") payload = raw;
26838
+ } catch {
26839
+ payload = null;
26840
+ }
26841
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
26842
+ const certified = distinctOpCount(journal);
26843
+ return {
26844
+ transferId: entry.transferId,
26845
+ kind: "open",
26846
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
26847
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
26848
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
26849
+ legs: { certified, total: Math.max(total, certified) },
26850
+ deliveryPending: journal.length > 0,
26851
+ createdAt: entry.createdAt
26852
+ };
26853
+ }
26854
+ function journalOnlyRow(transferId, entries) {
26855
+ const certified = distinctOpCount(entries);
26856
+ return {
26857
+ transferId,
26858
+ kind: "open",
26859
+ recipient: entries[0].recipientPubkey,
26860
+ coinId: "",
26861
+ amount: "",
26862
+ legs: { certified, total: certified },
26863
+ deliveryPending: true,
26864
+ createdAt: 0
26865
+ };
26866
+ }
26867
+ function shortfallRow(entry) {
26868
+ return {
26869
+ transferId: entry.transferId,
26870
+ kind: "shortfall",
26871
+ recipient: entry.recipient,
26872
+ coinId: entry.coinId,
26873
+ amount: entry.remainingAmount,
26874
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
26875
+ deliveryPending: false,
26876
+ createdAt: entry.createdAt
26877
+ };
26878
+ }
26879
+ function distinctOpCount(entries) {
26880
+ return new Set(entries.map((entry) => entry.opIndex)).size;
26881
+ }
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
+
26631
27013
  // modules/payments-v2/history/History.ts
26632
27014
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
26633
27015
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -26686,13 +27068,14 @@ var History = class {
26686
27068
  }
26687
27069
  // ── internals ────────────────────────────────────────────────────────────────
26688
27070
  async post(build) {
27071
+ const record = build();
26689
27072
  try {
26690
- await this.deps.client.postHistory([build()]);
27073
+ await this.deps.client.postHistory([record]);
26691
27074
  } catch (err) {
26692
27075
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
26693
27076
  return;
26694
27077
  }
26695
- this.deps.emit("history:updated");
27078
+ this.deps.emit("history:updated", this.toEntry(record));
26696
27079
  }
26697
27080
  ts(timestamp) {
26698
27081
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -26749,93 +27132,9 @@ var History = class {
26749
27132
  }
26750
27133
  };
26751
27134
 
26752
- // modules/payments-v2/inventory/presentation.ts
26753
- function toToken(tokenId, snapshot, asset, registry, flags) {
26754
- const iconUrl = registry.getIconUrl(asset.coinId);
26755
- return {
26756
- id: tokenId,
26757
- coinId: asset.coinId,
26758
- symbol: registry.getSymbol(asset.coinId),
26759
- name: registry.getName(asset.coinId),
26760
- decimals: registry.getDecimals(asset.coinId),
26761
- ...iconUrl !== null ? { iconUrl } : {},
26762
- amount: asset.amount,
26763
- status: flags.transferring ? "transferring" : "confirmed",
26764
- createdAt: snapshot.createdAt,
26765
- updatedAt: snapshot.updatedAt,
26766
- lazy: true,
26767
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
26768
- };
26769
- }
26770
- function aggregateAssets(entries, isTransferring, registry) {
26771
- const groups = /* @__PURE__ */ new Map();
26772
- for (const [tokenId, entry] of entries) {
26773
- const moving = isTransferring(tokenId);
26774
- for (const asset of entry.assets) {
26775
- let group = groups.get(asset.coinId);
26776
- if (!group) {
26777
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
26778
- groups.set(asset.coinId, group);
26779
- }
26780
- if (moving) {
26781
- group.transferring += BigInt(asset.amount);
26782
- group.transferringCount += 1;
26783
- } else {
26784
- group.total += BigInt(asset.amount);
26785
- group.count += 1;
26786
- }
26787
- }
26788
- }
26789
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
26790
- }
26791
- function toAsset(coinId, totals, registry) {
26792
- const iconUrl = registry.getIconUrl(coinId);
26793
- return {
26794
- coinId,
26795
- symbol: registry.getSymbol(coinId),
26796
- name: registry.getName(coinId),
26797
- decimals: registry.getDecimals(coinId),
26798
- ...iconUrl !== null ? { iconUrl } : {},
26799
- totalAmount: totals.total.toString(),
26800
- tokenCount: totals.count,
26801
- confirmedAmount: totals.total.toString(),
26802
- unconfirmedAmount: "0",
26803
- confirmedTokenCount: totals.count,
26804
- unconfirmedTokenCount: 0,
26805
- transferringTokenCount: totals.transferringCount,
26806
- transferringAmount: totals.transferring.toString(),
26807
- priceUsd: null,
26808
- priceEur: null,
26809
- change24h: null,
26810
- fiatValueUsd: null,
26811
- fiatValueEur: null
26812
- };
26813
- }
26814
- async function withPrices(raw, registry, price) {
26815
- try {
26816
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
26817
- const quotes = await price.getPrices(names);
26818
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
26819
- } catch {
26820
- return raw;
26821
- }
26822
- }
26823
- function priceOne(asset, quote) {
26824
- if (!quote) return asset;
26825
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
26826
- return {
26827
- ...asset,
26828
- priceUsd: quote.priceUsd,
26829
- priceEur: quote.priceEur ?? null,
26830
- change24h: quote.change24h ?? null,
26831
- fiatValueUsd: human * quote.priceUsd,
26832
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
26833
- };
26834
- }
26835
-
26836
27135
  // modules/payments-v2/inventory/InventoryView.ts
26837
- var SUSPECTED_KEY = "suspected-spent";
26838
- var KNOWN_SPENDS_KEY = "known-spends";
27136
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
27137
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
26839
27138
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
26840
27139
  function stateKey(tokenId, stateHash) {
26841
27140
  return `${tokenId}:${stateHash}`;
@@ -27139,19 +27438,24 @@ var Receive = class {
27139
27438
  const engine = deps.engine();
27140
27439
  const stored = [];
27141
27440
  const pending = [];
27142
- let epoch = "";
27441
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
27143
27442
  try {
27144
- epoch = deps.syncEpoch();
27145
27443
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
27146
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
27147
- for await (const entry of deps.delivery.incoming(since)) {
27148
- await this.processEntry(deps, engine, entry, pending, stored);
27149
- 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;
27150
27455
  }
27151
- await flushAcks(deps, pending, epoch);
27152
27456
  } catch (err) {
27153
27457
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
27154
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
27458
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
27155
27459
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
27156
27460
  });
27157
27461
  }
@@ -27224,7 +27528,7 @@ async function announce(deps, entry, record) {
27224
27528
  receivedAt
27225
27529
  };
27226
27530
  }
27227
- async function flushAcks(deps, pending, epoch) {
27531
+ async function flushAcks(deps, pending, epochOf) {
27228
27532
  let lastAcked = null;
27229
27533
  try {
27230
27534
  while (pending.length > 0) {
@@ -27235,7 +27539,7 @@ async function flushAcks(deps, pending, epoch) {
27235
27539
  }
27236
27540
  } finally {
27237
27541
  if (lastAcked !== null) {
27238
- const record = { cursor: lastAcked, syncEpoch: epoch };
27542
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
27239
27543
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
27240
27544
  }
27241
27545
  }
@@ -27336,9 +27640,19 @@ var Requests = class {
27336
27640
  }
27337
27641
  });
27338
27642
  }
27643
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
27339
27644
  writeLink(requestId, transferId, committed) {
27340
27645
  return this.mutateJournal((m) => {
27341
- 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);
27342
27656
  return true;
27343
27657
  });
27344
27658
  }
@@ -27491,23 +27805,46 @@ var Requests = class {
27491
27805
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
27492
27806
  if (transferId !== void 0) {
27493
27807
  const committed = error instanceof PartialSendConflictError;
27494
- await this.writeLink(id, transferId, committed);
27495
- this.setStatus(id, "settling");
27808
+ await this.settle(id, transferId, { committed, respond: false });
27496
27809
  } else {
27497
27810
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
27498
27811
  this.setStatus(id, "paid");
27499
27812
  }
27500
27813
  } else {
27501
- await this.clearLink(id);
27502
- this.setStatus(id, "pending");
27814
+ await this.revertPayable(id);
27503
27815
  }
27504
27816
  throw error;
27505
27817
  }
27506
- this.setStatus(id, "paid");
27507
- await this.respondPaid(id, result.id);
27508
- await this.clearLink(id);
27818
+ await this.settle(id, result.id, { committed: true, respond: true });
27509
27819
  return result;
27510
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
+ }
27511
27848
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
27512
27849
  async respondPaid(id, transferId) {
27513
27850
  try {
@@ -27543,32 +27880,32 @@ var Requests = class {
27543
27880
  let aborted = null;
27544
27881
  for (const [requestId, link] of [...this.journal]) {
27545
27882
  if (link.committed || resumed.has(link.transferId)) {
27546
- await this.resolvePaid(requestId, link.transferId);
27883
+ await this.resolvePaid(requestId, link);
27547
27884
  } else if (conflicted.has(link.transferId)) {
27548
27885
  await this.revertPayable(requestId);
27549
27886
  } else if (open.has(link.transferId)) {
27550
27887
  } else {
27551
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
27888
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
27552
27889
  }
27553
27890
  }
27554
27891
  }
27555
27892
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
27556
27893
  // a probe failure defers the link to the next session.
27557
- async reconcileUnaccounted(requestId, transferId, aborted) {
27894
+ async reconcileUnaccounted(requestId, link, aborted) {
27558
27895
  try {
27559
27896
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
27560
- if (aborted.has(transferId)) await this.revertPayable(requestId);
27561
- else await this.resolvePaid(requestId, transferId);
27897
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
27898
+ else await this.resolvePaid(requestId, link);
27562
27899
  } catch (err) {
27563
27900
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
27564
27901
  }
27565
27902
  return aborted;
27566
27903
  }
27567
- async resolvePaid(requestId, transferId) {
27568
- if (!await this.respondPaid(requestId, transferId)) return;
27569
- await this.clearLink(requestId);
27570
- 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 });
27571
27907
  }
27908
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
27572
27909
  async revertPayable(requestId) {
27573
27910
  await this.clearLink(requestId);
27574
27911
  this.setStatus(requestId, "pending");
@@ -27892,11 +28229,12 @@ function composeFacadeParts(deps, hooks) {
27892
28229
  client: deps.client,
27893
28230
  fieldKey: deps.fieldKey,
27894
28231
  registry: deps.registry,
27895
- emit: (event) => deps.emit(event, {}),
28232
+ emit: (event, entry) => deps.emit(event, entry),
27896
28233
  ...deps.now !== void 0 ? { now: deps.now } : {},
27897
28234
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
27898
28235
  });
27899
28236
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
28237
+ const machineStores = createMachineStores(deps.kv);
27900
28238
  const heldStates = /* @__PURE__ */ new Map();
27901
28239
  return {
27902
28240
  ownPubkeyBytes,
@@ -27904,12 +28242,29 @@ function composeFacadeParts(deps, hooks) {
27904
28242
  ledger,
27905
28243
  queue,
27906
28244
  historyStore,
27907
- machineStores: createMachineStores(deps.kv),
28245
+ machineStores,
27908
28246
  machineDeps,
27909
28247
  machine: new TransferMachine(machineDeps),
27910
28248
  heldStates,
27911
28249
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
27912
- 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
+ }
27913
28268
  };
27914
28269
  }
27915
28270
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -27931,11 +28286,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
27931
28286
  ownPubkey: hexToBytes4(deps.ownPubkey),
27932
28287
  emit: deps.emit,
27933
28288
  now: deps.now ?? Date.now,
27934
- recordHistory: async ({ transferId, payload }) => {
28289
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
27935
28290
  await historyStore.recordSent({
27936
28291
  transferId,
27937
28292
  coinId: payload.coinId,
27938
- 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,
27939
28296
  recipientPubkey: payload.recipient,
27940
28297
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
27941
28298
  });
@@ -27962,7 +28319,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
27962
28319
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
27963
28320
  );
27964
28321
  },
27965
- syncEpoch: deps.syncEpoch ?? (() => ""),
28322
+ syncEpoch: deps.syncEpoch,
27966
28323
  ...deps.now !== void 0 ? { now: deps.now } : {}
27967
28324
  });
27968
28325
  }
@@ -28015,8 +28372,24 @@ var PaymentsFacade = class {
28015
28372
  this.receiveLoop = parts.receiveLoop;
28016
28373
  this.heldStates = parts.heldStates;
28017
28374
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
28375
+ this.restoreDeps = parts.restoreDeps;
28018
28376
  this.requests = parts.requests;
28019
28377
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
28378
+ this.heartbeat = new ConvergenceHeartbeat({
28379
+ now: () => this.nowMs(),
28380
+ onTick: () => this.trackTail(this.runConvergencePass())
28381
+ });
28382
+ this.converger = new Converger({
28383
+ machineDeps: this.machineDeps,
28384
+ stores: this.machineStores,
28385
+ delivery: deps.deliveryPort,
28386
+ reconcile: (report) => this.requests.reconcile(report),
28387
+ refreshView: () => this.view.delta(),
28388
+ replayMints: () => this.replayMints(),
28389
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
28390
+ emit: deps.emit,
28391
+ now: () => this.nowMs()
28392
+ });
28020
28393
  }
28021
28394
  view;
28022
28395
  ledger;
@@ -28028,17 +28401,28 @@ var PaymentsFacade = class {
28028
28401
  receiveLoop;
28029
28402
  heldStates;
28030
28403
  ownPubkeyBytes;
28404
+ restoreDeps;
28031
28405
  requests;
28032
28406
  currentEngine = null;
28033
28407
  pendingOps = /* @__PURE__ */ new Set();
28034
28408
  unsubscribers = [];
28035
28409
  started = false;
28410
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
28411
+ heartbeat;
28412
+ converger;
28413
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
28414
+ resumeFlight = new SingleFlight();
28415
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
28416
+ passChain = new SerialChain();
28417
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
28418
+ activeMoneyOps = /* @__PURE__ */ new Set();
28036
28419
  // ── lifecycle ──────────────────────────────────────────────────────────────
28037
28420
  async start() {
28038
28421
  if (this.started) return;
28039
28422
  this.started = true;
28040
- await this.deps.session.start();
28423
+ this.heartbeat.start();
28041
28424
  this.unsubscribers.push(
28425
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
28042
28426
  this.deps.session.subscribeStream("mailbox", () => {
28043
28427
  this.trackTail(this.receiveLoop.drainOnce());
28044
28428
  }),
@@ -28049,20 +28433,12 @@ var PaymentsFacade = class {
28049
28433
  this.trackTail(this.requests.drainIncoming());
28050
28434
  })
28051
28435
  );
28052
- this.trackTail(this.runResume());
28053
- this.trackTail(
28054
- this.machineStores.deliveryJournal.replay({
28055
- delivery: this.deps.deliveryPort,
28056
- now: this.deps.now ?? Date.now,
28057
- attention: (transferId, code, detail) => {
28058
- this.deps.emit(
28059
- "transfer:attention",
28060
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
28061
- );
28062
- }
28063
- })
28064
- );
28065
- this.trackTail(this.replayMints());
28436
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
28437
+ if (status === "connected") this.heartbeat.recovered();
28438
+ });
28439
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
28440
+ await this.deps.session.start();
28441
+ this.trackTail(this.runConvergencePass());
28066
28442
  this.trackTail(this.seedHeldStates());
28067
28443
  await this.view.fullPull().catch(() => void 0);
28068
28444
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -28070,6 +28446,7 @@ var PaymentsFacade = class {
28070
28446
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
28071
28447
  async stop() {
28072
28448
  this.started = false;
28449
+ this.heartbeat.stop();
28073
28450
  this.receiveLoop.stop();
28074
28451
  for (const unsubscribe of this.unsubscribers) unsubscribe();
28075
28452
  this.unsubscribers = [];
@@ -28095,9 +28472,13 @@ var PaymentsFacade = class {
28095
28472
  history(page) {
28096
28473
  return this.historyStore.page(page ?? {});
28097
28474
  }
28475
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
28476
+ pendingTransfers() {
28477
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
28478
+ }
28098
28479
  // ── money movement ─────────────────────────────────────────────────────────
28099
28480
  send(request) {
28100
- return this.track(this.sendWithPolicy(request));
28481
+ return this.track(this.sendOutcome(request));
28101
28482
  }
28102
28483
  async receive() {
28103
28484
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -28107,9 +28488,32 @@ var PaymentsFacade = class {
28107
28488
  mint(coinId, amount) {
28108
28489
  return this.track(this.mintInner(coinId, amount));
28109
28490
  }
28491
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
28492
+ async resumeNow() {
28493
+ if (!this.started) return;
28494
+ await this.track(this.runConvergencePass());
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
+ }
28501
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
28502
+ runConvergencePass() {
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);
28509
+ }
28510
+ nowMs() {
28511
+ return (this.deps.now ?? Date.now)();
28512
+ }
28110
28513
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
28111
- async sendWithPolicy(request) {
28112
- 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) {
28113
28517
  const run = {
28114
28518
  amount: request.amount,
28115
28519
  delivered: [],
@@ -28117,12 +28521,36 @@ var PaymentsFacade = class {
28117
28521
  tokenTransfers: [],
28118
28522
  settledShortfalls: [],
28119
28523
  firstPartialId: void 0,
28120
- pendingAny: false
28524
+ pendingAny: false,
28525
+ lastTransferId: ""
28121
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)
28545
+ };
28546
+ this.deps.emit("transfer:updated", failed);
28547
+ }
28548
+ async sendWithPolicy(request, run) {
28549
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
28122
28550
  for (let attempt = 0; ; attempt++) {
28123
28551
  let ctx;
28124
28552
  try {
28125
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
28553
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
28126
28554
  } catch (err) {
28127
28555
  throw this.partialize(err, run);
28128
28556
  }
@@ -28156,21 +28584,24 @@ var PaymentsFacade = class {
28156
28584
  return recipient;
28157
28585
  }
28158
28586
  async runAttempt(ctx) {
28587
+ this.activeMoneyOps.add(ctx.transferId);
28159
28588
  try {
28160
28589
  const { deliveryPending } = await this.machine.run(ctx.plan);
28161
28590
  this.settleSuccess(ctx, ctx.sourceIds);
28162
28591
  return { kind: "success", deliveryPending };
28163
28592
  } catch (err) {
28164
- return this.disposeFailedAttempt(ctx, err);
28593
+ return await this.disposeFailedAttempt(ctx, err);
28594
+ } finally {
28595
+ this.activeMoneyOps.delete(ctx.transferId);
28165
28596
  }
28166
28597
  }
28167
- /**
28168
- * Partial outcome (#677/#690): the shortfall is already durable (written by
28169
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
28170
- * the remainder under a NEW transferId — never the full amount.
28171
- */
28598
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
28599
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
28172
28600
  async consumePartial(ctx, disposition, run, attempt) {
28173
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
28601
+ if (disposition.deliveryPending) {
28602
+ run.pendingAny = true;
28603
+ this.heartbeat.arm();
28604
+ }
28174
28605
  const partial = disposition.entry;
28175
28606
  run.delivered.push(...partial.committedTokenIds);
28176
28607
  run.firstPartialId ??= partial.transferId;
@@ -28191,11 +28622,8 @@ var PaymentsFacade = class {
28191
28622
  run.amount = partial.remainingAmount;
28192
28623
  return null;
28193
28624
  }
28194
- /**
28195
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
28196
- * backstop still 'open' (committed>0) → converge via the same machine's
28197
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
28198
- */
28625
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
28626
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
28199
28627
  async disposeFailedAttempt(ctx, err) {
28200
28628
  if (isPossiblyCommittedSendOutcome(err)) {
28201
28629
  this.settleKeepOpen(ctx);
@@ -28244,8 +28672,9 @@ var PaymentsFacade = class {
28244
28672
  }
28245
28673
  return { kind: "rethrow", error: err };
28246
28674
  }
28247
- async planAndMaterialize(recipientPubkey, request) {
28675
+ async planAndMaterialize(recipientPubkey, request, run) {
28248
28676
  const transferId = this.newId();
28677
+ run.lastTransferId = transferId;
28249
28678
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
28250
28679
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
28251
28680
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -28328,6 +28757,7 @@ var PaymentsFacade = class {
28328
28757
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
28329
28758
  settleKeepOpen(ctx) {
28330
28759
  this.queue.clearExpectedChange(ctx.transferId);
28760
+ this.heartbeat.arm();
28331
28761
  }
28332
28762
  settlePartial(ctx, partial) {
28333
28763
  this.ledger.cancel(ctx.transferId);
@@ -28364,6 +28794,7 @@ var PaymentsFacade = class {
28364
28794
  for (const id of run.settledShortfalls.splice(0)) {
28365
28795
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
28366
28796
  }
28797
+ if (run.pendingAny) this.heartbeat.arm();
28367
28798
  const result = {
28368
28799
  id: transferId,
28369
28800
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -28375,11 +28806,7 @@ var PaymentsFacade = class {
28375
28806
  this.deps.emit("transfer:updated", result);
28376
28807
  return result;
28377
28808
  }
28378
- /**
28379
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
28380
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
28381
- * over the accumulated settled set — never bare, never a full-amount retry.
28382
- */
28809
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
28383
28810
  partialize(err, run) {
28384
28811
  if (run.delivered.length === 0) return err;
28385
28812
  return new PartialSendConflictError(
@@ -28408,21 +28835,29 @@ var PaymentsFacade = class {
28408
28835
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
28409
28836
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
28410
28837
  }
28411
- const engine = this.engine();
28412
28838
  const mintId = this.newId();
28413
- const params = this.mintParams(coinId, amount);
28839
+ this.activeMoneyOps.add(mintId);
28840
+ try {
28841
+ return await this.mintUnderJournal(mintId, coinId, amount);
28842
+ } finally {
28843
+ this.activeMoneyOps.delete(mintId);
28844
+ }
28845
+ }
28846
+ async mintUnderJournal(mintId, coinId, amount) {
28847
+ const engine = this.engine();
28414
28848
  const entry = {
28415
28849
  mintId,
28416
28850
  coinId,
28417
28851
  amount: amount.toString(),
28418
28852
  tokenId: "",
28419
- createdAt: (this.deps.now ?? Date.now)()
28853
+ createdAt: this.nowMs()
28420
28854
  };
28421
28855
  await this.machineStores.mintJournal.upsert(entry);
28422
28856
  let token;
28423
28857
  try {
28424
- token = await engine.mint(params, { transferId: mintId });
28858
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
28425
28859
  } catch (err) {
28860
+ this.heartbeat.arm();
28426
28861
  return { success: false, error: messageOf(err) };
28427
28862
  }
28428
28863
  if (token.blob.tokenId !== entry.tokenId) {
@@ -28431,6 +28866,7 @@ var PaymentsFacade = class {
28431
28866
  try {
28432
28867
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
28433
28868
  } catch (err) {
28869
+ this.heartbeat.arm();
28434
28870
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
28435
28871
  }
28436
28872
  return { success: true, tokenId: token.blob.tokenId };
@@ -28453,18 +28889,22 @@ var PaymentsFacade = class {
28453
28889
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
28454
28890
  this.trackTail(this.view.delta());
28455
28891
  }
28892
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
28456
28893
  async replayMints() {
28894
+ let resolved = 0;
28457
28895
  for (const entry of await this.machineStores.mintJournal.list()) {
28896
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
28458
28897
  try {
28459
- await this.replayMint(entry);
28898
+ if (await this.replayMint(entry)) resolved += 1;
28460
28899
  } catch {
28461
28900
  }
28462
28901
  }
28902
+ return resolved;
28463
28903
  }
28464
28904
  async replayMint(entry) {
28465
28905
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
28466
28906
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
28467
- return;
28907
+ return true;
28468
28908
  }
28469
28909
  const engine = this.engine();
28470
28910
  if (!supportsDeterministicMint(engine)) {
@@ -28472,7 +28912,7 @@ var PaymentsFacade = class {
28472
28912
  transferId: entry.mintId,
28473
28913
  code: ATTENTION_MINT_UNRESOLVED
28474
28914
  });
28475
- return;
28915
+ return false;
28476
28916
  }
28477
28917
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
28478
28918
  transferId: entry.mintId
@@ -28481,6 +28921,7 @@ var PaymentsFacade = class {
28481
28921
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
28482
28922
  }
28483
28923
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
28924
+ return true;
28484
28925
  }
28485
28926
  mintParams(coinId, amount) {
28486
28927
  return {
@@ -28506,17 +28947,6 @@ var PaymentsFacade = class {
28506
28947
  newId() {
28507
28948
  return (this.deps.newId ?? randomUUID)();
28508
28949
  }
28509
- async runResume() {
28510
- const report = await resumeAll(this.machineDeps);
28511
- await this.requests.reconcile({
28512
- resumed: report.resumed,
28513
- conflicted: report.conflicted,
28514
- open: report.failed
28515
- });
28516
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
28517
- await this.view.delta().catch(() => void 0);
28518
- }
28519
- }
28520
28950
  async seedHeldStates() {
28521
28951
  let page = await this.deps.storagePort.listInventory();
28522
28952
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -28528,22 +28958,14 @@ var PaymentsFacade = class {
28528
28958
  }
28529
28959
  }
28530
28960
  toUiToken(tokenId, coinId, amount) {
28531
- const registry = this.deps.registry;
28532
- const iconUrl = registry.getIconUrl(coinId);
28533
- const now = (this.deps.now ?? Date.now)();
28534
- return {
28535
- id: tokenId,
28536
- coinId,
28537
- symbol: registry.getSymbol(coinId),
28538
- name: registry.getName(coinId),
28539
- decimals: registry.getDecimals(coinId),
28540
- ...iconUrl !== null ? { iconUrl } : {},
28541
- amount: amount.toString(),
28542
- status: "transferring",
28543
- createdAt: now,
28544
- updatedAt: now,
28545
- lazy: true
28546
- };
28961
+ const now = this.nowMs();
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
+ );
28547
28969
  }
28548
28970
  track(op) {
28549
28971
  const settled = op.then(
@@ -28558,27 +28980,6 @@ var PaymentsFacade = class {
28558
28980
  this.track(op).catch(() => void 0);
28559
28981
  }
28560
28982
  };
28561
- function buildPayload(recipientPubkey, request, spend, spentStates) {
28562
- return {
28563
- v: 2,
28564
- recipient: recipientPubkey,
28565
- coinId: request.coinId,
28566
- amount: request.amount,
28567
- ...request.memo !== void 0 ? { memo: request.memo } : {},
28568
- direct: [...spend.plan.direct],
28569
- ...spend.plan.split !== void 0 ? {
28570
- split: {
28571
- tokenId: spend.plan.split.tokenId,
28572
- splitAmount: spend.plan.split.splitAmount.toString(),
28573
- remainderAmount: spend.plan.split.remainderAmount.toString()
28574
- }
28575
- } : {},
28576
- spentStates
28577
- };
28578
- }
28579
- function messageOf(err) {
28580
- return err instanceof Error ? err.message : String(err);
28581
- }
28582
28983
 
28583
28984
  // impl/wallet-api-v2/http.ts
28584
28985
  var WalletApiHttpError = class extends Error {
@@ -28924,7 +29325,9 @@ var WalletApiSession = class {
28924
29325
  epochLatch = null;
28925
29326
  reseedChain = Promise.resolve();
28926
29327
  reseedsPending = 0;
29328
+ epochHandlers = /* @__PURE__ */ new Set();
28927
29329
  handlers = /* @__PURE__ */ new Map();
29330
+ statusHandlers = /* @__PURE__ */ new Set();
28928
29331
  socket = null;
28929
29332
  started = false;
28930
29333
  stopped = false;
@@ -29025,6 +29428,18 @@ var WalletApiSession = class {
29025
29428
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
29026
29429
  }
29027
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
+ }
29028
29443
  noteEpoch(epoch) {
29029
29444
  if (this.epochLatch === epoch) return;
29030
29445
  const previous = this.epochLatch;
@@ -29038,7 +29453,7 @@ var WalletApiSession = class {
29038
29453
  }
29039
29454
  async runReseed(epoch, previous) {
29040
29455
  try {
29041
- await this.deps.onEpochChange(epoch);
29456
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
29042
29457
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
29043
29458
  } catch {
29044
29459
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -29075,10 +29490,16 @@ var WalletApiSession = class {
29075
29490
  status() {
29076
29491
  return this.lastStatus ?? "offline";
29077
29492
  }
29493
+ /** Transition feed off the existing emitStatus point (FacadeSession.subscribeStatus). */
29494
+ subscribeStatus(handler) {
29495
+ this.statusHandlers.add(handler);
29496
+ return () => this.statusHandlers.delete(handler);
29497
+ }
29078
29498
  emitStatus(status) {
29079
29499
  if (this.stopped || this.lastStatus === status) return;
29080
29500
  this.lastStatus = status;
29081
29501
  this.deps.emitStatus(status);
29502
+ for (const handler of this.statusHandlers) handler(status);
29082
29503
  }
29083
29504
  async connect() {
29084
29505
  if (this.stopped) return;
@@ -29294,6 +29715,7 @@ var WalletApiDeliveryPort = class {
29294
29715
  wake;
29295
29716
  deriveFn;
29296
29717
  seenChain = new SerialChain();
29718
+ lastIncomingEpoch = null;
29297
29719
  constructor(config) {
29298
29720
  this.client = config.client;
29299
29721
  this.kv = config.kv;
@@ -29388,6 +29810,7 @@ var WalletApiDeliveryPort = class {
29388
29810
  const seen = await this.readSeen();
29389
29811
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
29390
29812
  for (; ; ) {
29813
+ this.lastIncomingEpoch = String(page.syncEpoch);
29391
29814
  for (const entry of page.entries) {
29392
29815
  if (entry.status !== "unclaimed") continue;
29393
29816
  if (seen.has(entry.entryId)) continue;
@@ -29397,6 +29820,10 @@ var WalletApiDeliveryPort = class {
29397
29820
  page = await this.client.listMailbox(page.cursor);
29398
29821
  }
29399
29822
  }
29823
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
29824
+ incomingEpoch() {
29825
+ return this.lastIncomingEpoch;
29826
+ }
29400
29827
  toIncoming(entry) {
29401
29828
  const bundle = this.openEnvelope(entry);
29402
29829
  return {
@@ -29474,7 +29901,7 @@ var WalletApiSplitCheckpointStore = class {
29474
29901
  return utf8(`${transferId}:${String(opIndex)}`);
29475
29902
  }
29476
29903
  async put(transferId, opIndex, bytes) {
29477
- const { client, kv, fieldKey, signProgress } = this.config;
29904
+ const { kv, fieldKey } = this.config;
29478
29905
  const aad = this.aad(transferId, opIndex);
29479
29906
  const cacheKey = this.cacheKey(transferId, opIndex);
29480
29907
  let envelope = await kv.get(cacheKey);
@@ -29482,10 +29909,21 @@ var WalletApiSplitCheckpointStore = class {
29482
29909
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
29483
29910
  await kv.set(cacheKey, envelope);
29484
29911
  }
29485
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
29486
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
29912
+ const record = await this.post(transferId, opIndex, envelope);
29487
29913
  return this.open(record.payload, aad);
29488
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
+ }
29489
29927
  async get(transferId, opIndex) {
29490
29928
  const records = await this.config.client.getProgress(transferId);
29491
29929
  const record = records.find((r) => r.opIndex === opIndex);
@@ -29508,7 +29946,7 @@ var WalletApiSplitCheckpointStore = class {
29508
29946
  };
29509
29947
 
29510
29948
  // core/payments-v2-wiring.ts
29511
- function resolvePaymentsV2Composition(walletApi) {
29949
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
29512
29950
  const source = walletApi;
29513
29951
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
29514
29952
  if (!source || network === null) {
@@ -29517,6 +29955,18 @@ function resolvePaymentsV2Composition(walletApi) {
29517
29955
  "INVALID_CONFIG"
29518
29956
  );
29519
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
+ }
29520
29970
  if (typeof source.paymentsV2Transport === "function") {
29521
29971
  return { network, factory: source.paymentsV2Transport.bind(source) };
29522
29972
  }
@@ -29582,8 +30032,7 @@ function defaultPaymentsV2Transport(config) {
29582
30032
  deviceId: `${config.deviceId}:pv2`,
29583
30033
  kv: args.kv,
29584
30034
  webSocketFactory,
29585
- emitStatus: args.emitStatus,
29586
- onEpochChange: args.onEpochChange
30035
+ emitStatus: args.emitStatus
29587
30036
  });
29588
30037
  return { session, client: authedWireClient(session, client) };
29589
30038
  };
@@ -29653,8 +30102,7 @@ function composePaymentsV2(spec) {
29653
30102
  identity: identitySlice,
29654
30103
  network,
29655
30104
  kv,
29656
- emitStatus: (status) => host.emit("connection:status", { status }),
29657
- onEpochChange: async () => void 0
30105
+ emitStatus: (status) => host.emit("connection:status", { status })
29658
30106
  });
29659
30107
  const deps = {
29660
30108
  session: transport.session,
@@ -29684,7 +30132,10 @@ function composePaymentsV2(spec) {
29684
30132
  network,
29685
30133
  ownPubkey: identity.chainPubkey,
29686
30134
  ownNametag: host.nametag,
29687
- 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()
29688
30139
  };
29689
30140
  return new PaymentsFacade(deps);
29690
30141
  }
@@ -30014,18 +30465,20 @@ var Sphere = class _Sphere {
30014
30465
  }
30015
30466
  /**
30016
30467
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
30017
- * 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.
30018
30472
  */
30019
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
30020
- if (flag !== true) return false;
30473
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
30474
+ if (flag !== true) return null;
30021
30475
  if (accountingConfig || swapConfig) {
30022
30476
  throw new SphereError(
30023
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.",
30024
30478
  "INVALID_CONFIG"
30025
30479
  );
30026
30480
  }
30027
- resolvePaymentsV2Composition(walletApi);
30028
- return true;
30481
+ return resolvePaymentsV2Composition(walletApi, network);
30029
30482
  }
30030
30483
  /**
30031
30484
  * Configure TokenRegistry in the main bundle context.
@@ -30080,12 +30533,14 @@ var Sphere = class _Sphere {
30080
30533
  options.walletApi
30081
30534
  );
30082
30535
  sphere._verification = options.verification;
30083
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30536
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30084
30537
  options.paymentsV2,
30085
30538
  accountingConfig,
30086
30539
  swapConfig,
30087
- options.walletApi
30540
+ options.walletApi,
30541
+ options.network
30088
30542
  );
30543
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30089
30544
  sphere._password = options.password ?? null;
30090
30545
  sphere._passwordProtected = sphere._password !== null;
30091
30546
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -30152,12 +30607,14 @@ var Sphere = class _Sphere {
30152
30607
  options.walletApi
30153
30608
  );
30154
30609
  sphere._verification = options.verification;
30155
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30610
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30156
30611
  options.paymentsV2,
30157
30612
  accountingConfig,
30158
30613
  swapConfig,
30159
- options.walletApi
30614
+ options.walletApi,
30615
+ options.network
30160
30616
  );
30617
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30161
30618
  sphere._password = options.password ?? null;
30162
30619
  sphere._passwordProtected = sphere._password !== null;
30163
30620
  if (!options.storage.isConnected()) {
@@ -30231,12 +30688,14 @@ var Sphere = class _Sphere {
30231
30688
  options.walletApi
30232
30689
  );
30233
30690
  sphere._verification = options.verification;
30234
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30691
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30235
30692
  options.paymentsV2,
30236
30693
  accountingConfig,
30237
30694
  swapConfig,
30238
- options.walletApi
30695
+ options.walletApi,
30696
+ options.network
30239
30697
  );
30698
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30240
30699
  sphere._password = options.password ?? null;
30241
30700
  sphere._passwordProtected = sphere._password !== null;
30242
30701
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -32894,7 +33353,9 @@ var Sphere = class _Sphere {
32894
33353
  * setOracleApiKey rebuild is what future operations snapshot.
32895
33354
  */
32896
33355
  async startPaymentsV2Inner(index, identity) {
32897
- 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
+ }
32898
33359
  if (!this._addressModules.get(index)?.tokenEngine) {
32899
33360
  throw new SphereError(
32900
33361
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",