@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.
@@ -10674,14 +10674,14 @@ var IntentResume = class {
10674
10674
  }
10675
10675
  let changeOutput = null;
10676
10676
  if (payload.split) {
10677
- const splitOpIndex = payload.direct.length;
10677
+ const splitOpIndex2 = payload.direct.length;
10678
10678
  let splitDelivered = false;
10679
10679
  {
10680
10680
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
10681
10681
  const outcome = await this.host.certifyOperation(
10682
10682
  {
10683
10683
  kind: "split",
10684
- opIndex: splitOpIndex,
10684
+ opIndex: splitOpIndex2,
10685
10685
  uiTokenId: `v2_${payload.split.tokenId}`,
10686
10686
  genesisId: payload.split.tokenId,
10687
10687
  sourceSdkData: "",
@@ -20785,6 +20785,7 @@ var AccountingModule = class _AccountingModule {
20785
20785
  const unsubHistory = deps.on(
20786
20786
  "history:updated",
20787
20787
  (entry) => {
20788
+ if (!("dedupKey" in entry)) return;
20788
20789
  this._handleHistoryUpdated(entry).catch((err) => {
20789
20790
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
20790
20791
  });
@@ -26054,7 +26055,10 @@ var STORE_KEYS = {
26054
26055
  shortfalls: "shortfalls",
26055
26056
  settlingLinks: "settling",
26056
26057
  streamCursor: (s) => `cursor:${s}`,
26057
- epochLatch: "epoch-latch"
26058
+ epochLatch: "epoch-latch",
26059
+ // §5.2 InventoryView durable overlays (#625/#679).
26060
+ suspectedSpent: "suspected-spent",
26061
+ knownSpends: "known-spends"
26058
26062
  };
26059
26063
 
26060
26064
  // modules/payments-v2/machine/journal.ts
@@ -26077,6 +26081,10 @@ function isValidationReject(err) {
26077
26081
  if (e === null) return false;
26078
26082
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
26079
26083
  }
26084
+ function retryAfterMsOf(err) {
26085
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
26086
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
26087
+ }
26080
26088
  var chainRegistry = /* @__PURE__ */ new WeakMap();
26081
26089
  var replayRegistry = /* @__PURE__ */ new WeakMap();
26082
26090
  function registryFor(registry, kv, storeKey, make) {
@@ -26300,7 +26308,7 @@ var TransferMachine = class {
26300
26308
  }
26301
26309
  if (cls === null) {
26302
26310
  await this.applyCommitted(engine, plan.transferId, committed);
26303
- this.closeTail(plan.transferId, plan.payload);
26311
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
26304
26312
  return { committed, deliveryPending };
26305
26313
  }
26306
26314
  if (cls === "conflict" && committed.length > 0) {
@@ -26326,10 +26334,10 @@ var TransferMachine = class {
26326
26334
  }
26327
26335
  await this.applyCommitted(engine, r.transferId, committed);
26328
26336
  if (conflicts.length > 0) {
26329
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
26337
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
26330
26338
  }
26331
26339
  await this.completeIntent(r.transferId, r.payload);
26332
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26340
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26333
26341
  return { deliveryPending, partial: null };
26334
26342
  }
26335
26343
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -26511,7 +26519,7 @@ var TransferMachine = class {
26511
26519
  }
26512
26520
  return added;
26513
26521
  }
26514
- async settlePartial(r, committed, conflicts) {
26522
+ async settlePartial(engine, r, committed, conflicts) {
26515
26523
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
26516
26524
  const entry = {
26517
26525
  transferId: r.transferId,
@@ -26523,26 +26531,27 @@ var TransferMachine = class {
26523
26531
  };
26524
26532
  await this.stores.shortfalls.upsert(entry);
26525
26533
  await this.completeIntent(r.transferId, r.payload);
26526
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
26534
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
26527
26535
  return entry;
26528
26536
  }
26529
- closeTail(transferId, payload) {
26537
+ closeTail(engine, transferId, payload, committed) {
26530
26538
  void (async () => {
26531
26539
  try {
26532
26540
  await this.completeIntent(transferId, payload);
26533
26541
  } catch {
26534
26542
  return;
26535
26543
  }
26536
- await this.finishBookkeeping(transferId, payload, "sent");
26544
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
26537
26545
  })();
26538
26546
  }
26539
26547
  async completeIntent(transferId, payload) {
26540
26548
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
26541
26549
  await this.deps.intents.complete(transferId, signature);
26542
26550
  }
26543
- async finishBookkeeping(transferId, payload, phase) {
26551
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
26544
26552
  try {
26545
- await this.deps.recordHistory?.({ transferId, payload, phase });
26553
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
26554
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
26546
26555
  } catch {
26547
26556
  }
26548
26557
  try {
@@ -26550,6 +26559,26 @@ var TransferMachine = class {
26550
26559
  } catch {
26551
26560
  }
26552
26561
  }
26562
+ /**
26563
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
26564
+ * certified recipient blobs (summarize()'s committed set is the only source;
26565
+ * a journal-rehydrated resume leg has no source token, but every committed
26566
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
26567
+ */
26568
+ async settledAmount(engine, coinId, committed) {
26569
+ let total = 0n;
26570
+ for (const outcome of committed) {
26571
+ if (outcome.recipientBlob === void 0) continue;
26572
+ const token = await engine.decodeToken({
26573
+ v: TOKEN_BLOB_VERSION,
26574
+ network: 0,
26575
+ tokenId: "",
26576
+ token: outcome.recipientBlob
26577
+ });
26578
+ total += engine.balanceOf(token, coinId);
26579
+ }
26580
+ return total.toString();
26581
+ }
26553
26582
  async disposeOnFailure(transferId, committed, cls) {
26554
26583
  if (committed.length > 0 || cls === "keep-open") return;
26555
26584
  try {
@@ -26574,7 +26603,7 @@ function conflictAmount(engine, payload, op, source) {
26574
26603
  }
26575
26604
 
26576
26605
  // modules/payments-v2/machine/resume.ts
26577
- async function resumeAll(deps) {
26606
+ async function resumeAll(deps, options = {}) {
26578
26607
  const report = { resumed: [], conflicted: [], failed: [] };
26579
26608
  const stores = createMachineStores(deps.kv);
26580
26609
  const open = await deps.intents.listOpen();
@@ -26584,7 +26613,12 @@ async function resumeAll(deps) {
26584
26613
  } catch {
26585
26614
  return report;
26586
26615
  }
26587
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
26616
+ await dropStaleBackstops(
26617
+ stores,
26618
+ locals,
26619
+ new Set(open.map((i) => i.transferId)),
26620
+ options.isLocallyActive ?? (() => false)
26621
+ );
26588
26622
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
26589
26623
  const ctx = {
26590
26624
  deps,
@@ -26597,11 +26631,16 @@ async function resumeAll(deps) {
26597
26631
  }
26598
26632
  return report;
26599
26633
  }
26600
- async function dropStaleLocalAborts(stores, locals, openIds) {
26634
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
26601
26635
  for (const entry of locals.values()) {
26602
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
26636
+ if (openIds.has(entry.transferId)) continue;
26637
+ if (entry.disposition !== "open") {
26603
26638
  await stores.backstop.removeByKey(entry.transferId);
26639
+ continue;
26604
26640
  }
26641
+ if (isLocallyActive(entry.transferId)) continue;
26642
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
26643
+ await stores.backstop.removeByKey(entry.transferId);
26605
26644
  }
26606
26645
  }
26607
26646
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -26714,6 +26753,349 @@ function validatePayload(raw) {
26714
26753
  return p;
26715
26754
  }
26716
26755
 
26756
+ // modules/payments-v2/convergence.ts
26757
+ var HEARTBEAT_SEED_MS = 5e3;
26758
+ var HEARTBEAT_CAP_MS = 12e4;
26759
+ var ConvergenceHeartbeat = class {
26760
+ constructor(deps) {
26761
+ this.deps = deps;
26762
+ }
26763
+ timer = null;
26764
+ delayMs = HEARTBEAT_SEED_MS;
26765
+ notBefore = 0;
26766
+ running = false;
26767
+ start() {
26768
+ this.running = true;
26769
+ this.delayMs = HEARTBEAT_SEED_MS;
26770
+ this.notBefore = 0;
26771
+ }
26772
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
26773
+ stop() {
26774
+ this.running = false;
26775
+ this.clearTimer();
26776
+ }
26777
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
26778
+ settle(outcome, pending) {
26779
+ if (outcome.retryAfterMs !== null) {
26780
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
26781
+ }
26782
+ if (!pending) {
26783
+ this.delayMs = HEARTBEAT_SEED_MS;
26784
+ return;
26785
+ }
26786
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
26787
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
26788
+ this.schedule(delay);
26789
+ }
26790
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
26791
+ arm() {
26792
+ if (!this.running) return;
26793
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
26794
+ this.schedule(HEARTBEAT_SEED_MS);
26795
+ }
26796
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
26797
+ recovered() {
26798
+ this.delayMs = HEARTBEAT_SEED_MS;
26799
+ if (this.timer !== null) this.arm();
26800
+ }
26801
+ schedule(delayMs) {
26802
+ this.clearTimer();
26803
+ if (!this.running) return;
26804
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
26805
+ this.timer = setTimeout(() => {
26806
+ this.timer = null;
26807
+ this.deps.onTick();
26808
+ }, floored);
26809
+ }
26810
+ clearTimer() {
26811
+ if (this.timer !== null) clearTimeout(this.timer);
26812
+ this.timer = null;
26813
+ }
26814
+ };
26815
+ var Converger = class {
26816
+ constructor(deps) {
26817
+ this.deps = deps;
26818
+ }
26819
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
26820
+ async convergeOnce() {
26821
+ let progress = false;
26822
+ let retryAfterMs = null;
26823
+ try {
26824
+ progress = await this.resumePass();
26825
+ } catch (err) {
26826
+ retryAfterMs = retryAfterMsOf(err);
26827
+ }
26828
+ try {
26829
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
26830
+ progress = progress || stats.delivered > 0;
26831
+ } catch (err) {
26832
+ retryAfterMs ??= retryAfterMsOf(err);
26833
+ }
26834
+ try {
26835
+ progress = await this.deps.replayMints() > 0 || progress;
26836
+ } catch (err) {
26837
+ retryAfterMs ??= retryAfterMsOf(err);
26838
+ }
26839
+ return { progress, retryAfterMs };
26840
+ }
26841
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
26842
+ async pendingWork() {
26843
+ const [backstop, journal, mints] = await Promise.all([
26844
+ this.deps.stores.backstop.list(),
26845
+ this.deps.stores.deliveryJournal.list(),
26846
+ this.deps.stores.mintJournal.list()
26847
+ ]);
26848
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
26849
+ }
26850
+ async resumePass() {
26851
+ const report = await resumeAll(this.filteredMachineDeps(), {
26852
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
26853
+ });
26854
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
26855
+ await this.deps.reconcile({
26856
+ resumed: report.resumed,
26857
+ conflicted: report.conflicted,
26858
+ open: report.failed
26859
+ });
26860
+ if (progress) await this.deps.refreshView().catch(() => void 0);
26861
+ return progress;
26862
+ }
26863
+ filteredMachineDeps() {
26864
+ const intents = this.deps.machineDeps.intents;
26865
+ return {
26866
+ ...this.deps.machineDeps,
26867
+ intents: {
26868
+ ...intents,
26869
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
26870
+ }
26871
+ };
26872
+ }
26873
+ replayDeps() {
26874
+ return {
26875
+ delivery: this.deps.delivery,
26876
+ now: () => this.deps.now(),
26877
+ attention: (transferId, code, detail) => {
26878
+ this.deps.emit(
26879
+ "transfer:attention",
26880
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
26881
+ );
26882
+ }
26883
+ };
26884
+ }
26885
+ };
26886
+ async function derivePendingTransfers(stores, decryptPayload) {
26887
+ const [backstop, journal, shortfalls] = await Promise.all([
26888
+ stores.backstop.list(),
26889
+ stores.deliveryJournal.list(),
26890
+ stores.shortfalls.list()
26891
+ ]);
26892
+ const byTransfer = groupJournal(journal);
26893
+ const out = [];
26894
+ const seen = /* @__PURE__ */ new Set();
26895
+ for (const entry of backstop) {
26896
+ if (entry.disposition !== "open") continue;
26897
+ seen.add(entry.transferId);
26898
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
26899
+ }
26900
+ for (const [transferId, entries] of byTransfer) {
26901
+ if (seen.has(transferId)) continue;
26902
+ seen.add(transferId);
26903
+ out.push(journalOnlyRow(transferId, entries));
26904
+ }
26905
+ for (const entry of shortfalls) {
26906
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
26907
+ }
26908
+ return out;
26909
+ }
26910
+ function groupJournal(journal) {
26911
+ const byTransfer = /* @__PURE__ */ new Map();
26912
+ for (const entry of journal) {
26913
+ const mine = byTransfer.get(entry.transferId);
26914
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
26915
+ else mine.push(entry);
26916
+ }
26917
+ return byTransfer;
26918
+ }
26919
+ async function openRow(entry, journal, decryptPayload) {
26920
+ let payload = null;
26921
+ try {
26922
+ const raw = await decryptPayload(entry.payloadEnvelope);
26923
+ if (raw !== null && typeof raw === "object") payload = raw;
26924
+ } catch {
26925
+ payload = null;
26926
+ }
26927
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
26928
+ const certified = distinctOpCount(journal);
26929
+ return {
26930
+ transferId: entry.transferId,
26931
+ kind: "open",
26932
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
26933
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
26934
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
26935
+ legs: { certified, total: Math.max(total, certified) },
26936
+ deliveryPending: journal.length > 0,
26937
+ createdAt: entry.createdAt
26938
+ };
26939
+ }
26940
+ function journalOnlyRow(transferId, entries) {
26941
+ const certified = distinctOpCount(entries);
26942
+ return {
26943
+ transferId,
26944
+ kind: "open",
26945
+ recipient: entries[0].recipientPubkey,
26946
+ coinId: "",
26947
+ amount: "",
26948
+ legs: { certified, total: certified },
26949
+ deliveryPending: true,
26950
+ createdAt: 0
26951
+ };
26952
+ }
26953
+ function shortfallRow(entry) {
26954
+ return {
26955
+ transferId: entry.transferId,
26956
+ kind: "shortfall",
26957
+ recipient: entry.recipient,
26958
+ coinId: entry.coinId,
26959
+ amount: entry.remainingAmount,
26960
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
26961
+ deliveryPending: false,
26962
+ createdAt: entry.createdAt
26963
+ };
26964
+ }
26965
+ function distinctOpCount(entries) {
26966
+ return new Set(entries.map((entry) => entry.opIndex)).size;
26967
+ }
26968
+
26969
+ // modules/payments-v2/restore.ts
26970
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
26971
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
26972
+ async function reseedAndReset(deps) {
26973
+ for (const entry of await deps.stores.backstop.list()) {
26974
+ if (entry.disposition !== "open") continue;
26975
+ try {
26976
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
26977
+ if (entry.requiresSeedClose) {
26978
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
26979
+ }
26980
+ } catch (err) {
26981
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
26982
+ }
26983
+ }
26984
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
26985
+ await deps.fullRePull();
26986
+ }
26987
+ async function splitOpIndex(deps, envelope) {
26988
+ const payload = await deps.decryptPayload(envelope);
26989
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
26990
+ }
26991
+
26992
+ // modules/payments-v2/inventory/presentation.ts
26993
+ function toToken(tokenId, snapshot, asset, registry, flags) {
26994
+ const iconUrl = registry.getIconUrl(asset.coinId);
26995
+ return {
26996
+ id: tokenId,
26997
+ coinId: asset.coinId,
26998
+ symbol: registry.getSymbol(asset.coinId),
26999
+ name: registry.getName(asset.coinId),
27000
+ decimals: registry.getDecimals(asset.coinId),
27001
+ ...iconUrl !== null ? { iconUrl } : {},
27002
+ amount: asset.amount,
27003
+ status: flags.transferring ? "transferring" : "confirmed",
27004
+ createdAt: snapshot.createdAt,
27005
+ updatedAt: snapshot.updatedAt,
27006
+ lazy: true,
27007
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
27008
+ };
27009
+ }
27010
+ function aggregateAssets(entries, isTransferring, registry) {
27011
+ const groups = /* @__PURE__ */ new Map();
27012
+ for (const [tokenId, entry] of entries) {
27013
+ const moving = isTransferring(tokenId);
27014
+ for (const asset of entry.assets) {
27015
+ let group = groups.get(asset.coinId);
27016
+ if (!group) {
27017
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
27018
+ groups.set(asset.coinId, group);
27019
+ }
27020
+ if (moving) {
27021
+ group.transferring += BigInt(asset.amount);
27022
+ group.transferringCount += 1;
27023
+ } else {
27024
+ group.total += BigInt(asset.amount);
27025
+ group.count += 1;
27026
+ }
27027
+ }
27028
+ }
27029
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
27030
+ }
27031
+ function toAsset(coinId, totals, registry) {
27032
+ const iconUrl = registry.getIconUrl(coinId);
27033
+ return {
27034
+ coinId,
27035
+ symbol: registry.getSymbol(coinId),
27036
+ name: registry.getName(coinId),
27037
+ decimals: registry.getDecimals(coinId),
27038
+ ...iconUrl !== null ? { iconUrl } : {},
27039
+ totalAmount: totals.total.toString(),
27040
+ tokenCount: totals.count,
27041
+ confirmedAmount: totals.total.toString(),
27042
+ unconfirmedAmount: "0",
27043
+ confirmedTokenCount: totals.count,
27044
+ unconfirmedTokenCount: 0,
27045
+ transferringTokenCount: totals.transferringCount,
27046
+ transferringAmount: totals.transferring.toString(),
27047
+ priceUsd: null,
27048
+ priceEur: null,
27049
+ change24h: null,
27050
+ fiatValueUsd: null,
27051
+ fiatValueEur: null
27052
+ };
27053
+ }
27054
+ async function withPrices(raw, registry, price) {
27055
+ try {
27056
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
27057
+ const quotes = await price.getPrices(names);
27058
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
27059
+ } catch {
27060
+ return raw;
27061
+ }
27062
+ }
27063
+ function priceOne(asset, quote) {
27064
+ if (!quote) return asset;
27065
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
27066
+ return {
27067
+ ...asset,
27068
+ priceUsd: quote.priceUsd,
27069
+ priceEur: quote.priceEur ?? null,
27070
+ change24h: quote.change24h ?? null,
27071
+ fiatValueUsd: human * quote.priceUsd,
27072
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
27073
+ };
27074
+ }
27075
+
27076
+ // modules/payments-v2/machine/payload.ts
27077
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
27078
+ return {
27079
+ v: 2,
27080
+ recipient: recipientPubkey,
27081
+ coinId: request.coinId,
27082
+ amount: request.amount,
27083
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
27084
+ direct: [...spend.plan.direct],
27085
+ ...spend.plan.split !== void 0 ? {
27086
+ split: {
27087
+ tokenId: spend.plan.split.tokenId,
27088
+ splitAmount: spend.plan.split.splitAmount.toString(),
27089
+ remainderAmount: spend.plan.split.remainderAmount.toString()
27090
+ }
27091
+ } : {},
27092
+ spentStates
27093
+ };
27094
+ }
27095
+ function messageOf(err) {
27096
+ return err instanceof Error ? err.message : String(err);
27097
+ }
27098
+
26717
27099
  // modules/payments-v2/history/History.ts
26718
27100
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
26719
27101
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -26772,13 +27154,14 @@ var History = class {
26772
27154
  }
26773
27155
  // ── internals ────────────────────────────────────────────────────────────────
26774
27156
  async post(build) {
27157
+ const record = build();
26775
27158
  try {
26776
- await this.deps.client.postHistory([build()]);
27159
+ await this.deps.client.postHistory([record]);
26777
27160
  } catch (err) {
26778
27161
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
26779
27162
  return;
26780
27163
  }
26781
- this.deps.emit("history:updated");
27164
+ this.deps.emit("history:updated", this.toEntry(record));
26782
27165
  }
26783
27166
  ts(timestamp) {
26784
27167
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -26835,93 +27218,9 @@ var History = class {
26835
27218
  }
26836
27219
  };
26837
27220
 
26838
- // modules/payments-v2/inventory/presentation.ts
26839
- function toToken(tokenId, snapshot, asset, registry, flags) {
26840
- const iconUrl = registry.getIconUrl(asset.coinId);
26841
- return {
26842
- id: tokenId,
26843
- coinId: asset.coinId,
26844
- symbol: registry.getSymbol(asset.coinId),
26845
- name: registry.getName(asset.coinId),
26846
- decimals: registry.getDecimals(asset.coinId),
26847
- ...iconUrl !== null ? { iconUrl } : {},
26848
- amount: asset.amount,
26849
- status: flags.transferring ? "transferring" : "confirmed",
26850
- createdAt: snapshot.createdAt,
26851
- updatedAt: snapshot.updatedAt,
26852
- lazy: true,
26853
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
26854
- };
26855
- }
26856
- function aggregateAssets(entries, isTransferring, registry) {
26857
- const groups = /* @__PURE__ */ new Map();
26858
- for (const [tokenId, entry] of entries) {
26859
- const moving = isTransferring(tokenId);
26860
- for (const asset of entry.assets) {
26861
- let group = groups.get(asset.coinId);
26862
- if (!group) {
26863
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
26864
- groups.set(asset.coinId, group);
26865
- }
26866
- if (moving) {
26867
- group.transferring += BigInt(asset.amount);
26868
- group.transferringCount += 1;
26869
- } else {
26870
- group.total += BigInt(asset.amount);
26871
- group.count += 1;
26872
- }
26873
- }
26874
- }
26875
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
26876
- }
26877
- function toAsset(coinId, totals, registry) {
26878
- const iconUrl = registry.getIconUrl(coinId);
26879
- return {
26880
- coinId,
26881
- symbol: registry.getSymbol(coinId),
26882
- name: registry.getName(coinId),
26883
- decimals: registry.getDecimals(coinId),
26884
- ...iconUrl !== null ? { iconUrl } : {},
26885
- totalAmount: totals.total.toString(),
26886
- tokenCount: totals.count,
26887
- confirmedAmount: totals.total.toString(),
26888
- unconfirmedAmount: "0",
26889
- confirmedTokenCount: totals.count,
26890
- unconfirmedTokenCount: 0,
26891
- transferringTokenCount: totals.transferringCount,
26892
- transferringAmount: totals.transferring.toString(),
26893
- priceUsd: null,
26894
- priceEur: null,
26895
- change24h: null,
26896
- fiatValueUsd: null,
26897
- fiatValueEur: null
26898
- };
26899
- }
26900
- async function withPrices(raw, registry, price) {
26901
- try {
26902
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
26903
- const quotes = await price.getPrices(names);
26904
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
26905
- } catch {
26906
- return raw;
26907
- }
26908
- }
26909
- function priceOne(asset, quote) {
26910
- if (!quote) return asset;
26911
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
26912
- return {
26913
- ...asset,
26914
- priceUsd: quote.priceUsd,
26915
- priceEur: quote.priceEur ?? null,
26916
- change24h: quote.change24h ?? null,
26917
- fiatValueUsd: human * quote.priceUsd,
26918
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
26919
- };
26920
- }
26921
-
26922
27221
  // modules/payments-v2/inventory/InventoryView.ts
26923
- var SUSPECTED_KEY = "suspected-spent";
26924
- var KNOWN_SPENDS_KEY = "known-spends";
27222
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
27223
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
26925
27224
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
26926
27225
  function stateKey(tokenId, stateHash) {
26927
27226
  return `${tokenId}:${stateHash}`;
@@ -27225,19 +27524,24 @@ var Receive = class {
27225
27524
  const engine = deps.engine();
27226
27525
  const stored = [];
27227
27526
  const pending = [];
27228
- let epoch = "";
27527
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
27229
27528
  try {
27230
- epoch = deps.syncEpoch();
27231
27529
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
27232
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
27233
- for await (const entry of deps.delivery.incoming(since)) {
27234
- await this.processEntry(deps, engine, entry, pending, stored);
27235
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
27530
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
27531
+ for (let pass = 0; pass < 2; pass++) {
27532
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
27533
+ await this.processEntry(deps, engine, entry, pending, stored);
27534
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
27535
+ }
27536
+ await flushAcks(deps, pending, pageEpoch);
27537
+ const served = deps.delivery.incomingEpoch();
27538
+ if (basis === null || served === null || served === basis.syncEpoch) break;
27539
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
27540
+ basis = null;
27236
27541
  }
27237
- await flushAcks(deps, pending, epoch);
27238
27542
  } catch (err) {
27239
27543
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
27240
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
27544
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
27241
27545
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
27242
27546
  });
27243
27547
  }
@@ -27310,7 +27614,7 @@ async function announce(deps, entry, record) {
27310
27614
  receivedAt
27311
27615
  };
27312
27616
  }
27313
- async function flushAcks(deps, pending, epoch) {
27617
+ async function flushAcks(deps, pending, epochOf) {
27314
27618
  let lastAcked = null;
27315
27619
  try {
27316
27620
  while (pending.length > 0) {
@@ -27321,7 +27625,7 @@ async function flushAcks(deps, pending, epoch) {
27321
27625
  }
27322
27626
  } finally {
27323
27627
  if (lastAcked !== null) {
27324
- const record = { cursor: lastAcked, syncEpoch: epoch };
27628
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
27325
27629
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
27326
27630
  }
27327
27631
  }
@@ -27422,9 +27726,19 @@ var Requests = class {
27422
27726
  }
27423
27727
  });
27424
27728
  }
27729
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
27425
27730
  writeLink(requestId, transferId, committed) {
27426
27731
  return this.mutateJournal((m) => {
27427
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
27732
+ const prev = m.get(requestId);
27733
+ const same = prev !== void 0 && prev.transferId === transferId;
27734
+ const next = {
27735
+ requestId,
27736
+ transferId,
27737
+ committed: committed || same && prev.committed,
27738
+ createdAt: same ? prev.createdAt : this.now()
27739
+ };
27740
+ if (same && prev.committed === next.committed) return false;
27741
+ m.set(requestId, next);
27428
27742
  return true;
27429
27743
  });
27430
27744
  }
@@ -27577,23 +27891,46 @@ var Requests = class {
27577
27891
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
27578
27892
  if (transferId !== void 0) {
27579
27893
  const committed = error instanceof PartialSendConflictError;
27580
- await this.writeLink(id, transferId, committed);
27581
- this.setStatus(id, "settling");
27894
+ await this.settle(id, transferId, { committed, respond: false });
27582
27895
  } else {
27583
27896
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
27584
27897
  this.setStatus(id, "paid");
27585
27898
  }
27586
27899
  } else {
27587
- await this.clearLink(id);
27588
- this.setStatus(id, "pending");
27900
+ await this.revertPayable(id);
27589
27901
  }
27590
27902
  throw error;
27591
27903
  }
27592
- this.setStatus(id, "paid");
27593
- await this.respondPaid(id, result.id);
27594
- await this.clearLink(id);
27904
+ await this.settle(id, result.id, { committed: true, respond: true });
27595
27905
  return result;
27596
27906
  }
27907
+ /**
27908
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
27909
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
27910
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
27911
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
27912
+ * not a reload. Every path that binds a request to a transfer outcome funnels
27913
+ * through here — pay()'s clean success (respond now), pay()'s
27914
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
27915
+ * — so a failed respond always leaves the link + 'settling' and the next
27916
+ * reconcile pass (the committed-link override) retries the respond.
27917
+ */
27918
+ async settle(requestId, transferId, opts) {
27919
+ let journalDown = false;
27920
+ try {
27921
+ await this.writeLink(requestId, transferId, opts.committed);
27922
+ } catch (err) {
27923
+ if (!opts.respond) throw err;
27924
+ journalDown = true;
27925
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
27926
+ }
27927
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
27928
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
27929
+ return;
27930
+ }
27931
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
27932
+ this.setStatus(requestId, "paid");
27933
+ }
27597
27934
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
27598
27935
  async respondPaid(id, transferId) {
27599
27936
  try {
@@ -27629,32 +27966,32 @@ var Requests = class {
27629
27966
  let aborted = null;
27630
27967
  for (const [requestId, link] of [...this.journal]) {
27631
27968
  if (link.committed || resumed.has(link.transferId)) {
27632
- await this.resolvePaid(requestId, link.transferId);
27969
+ await this.resolvePaid(requestId, link);
27633
27970
  } else if (conflicted.has(link.transferId)) {
27634
27971
  await this.revertPayable(requestId);
27635
27972
  } else if (open.has(link.transferId)) {
27636
27973
  } else {
27637
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
27974
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
27638
27975
  }
27639
27976
  }
27640
27977
  }
27641
27978
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
27642
27979
  // a probe failure defers the link to the next session.
27643
- async reconcileUnaccounted(requestId, transferId, aborted) {
27980
+ async reconcileUnaccounted(requestId, link, aborted) {
27644
27981
  try {
27645
27982
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
27646
- if (aborted.has(transferId)) await this.revertPayable(requestId);
27647
- else await this.resolvePaid(requestId, transferId);
27983
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
27984
+ else await this.resolvePaid(requestId, link);
27648
27985
  } catch (err) {
27649
27986
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
27650
27987
  }
27651
27988
  return aborted;
27652
27989
  }
27653
- async resolvePaid(requestId, transferId) {
27654
- if (!await this.respondPaid(requestId, transferId)) return;
27655
- await this.clearLink(requestId);
27656
- this.setStatus(requestId, "paid");
27990
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
27991
+ resolvePaid(requestId, link) {
27992
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
27657
27993
  }
27994
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
27658
27995
  async revertPayable(requestId) {
27659
27996
  await this.clearLink(requestId);
27660
27997
  this.setStatus(requestId, "pending");
@@ -27978,11 +28315,12 @@ function composeFacadeParts(deps, hooks) {
27978
28315
  client: deps.client,
27979
28316
  fieldKey: deps.fieldKey,
27980
28317
  registry: deps.registry,
27981
- emit: (event) => deps.emit(event, {}),
28318
+ emit: (event, entry) => deps.emit(event, entry),
27982
28319
  ...deps.now !== void 0 ? { now: deps.now } : {},
27983
28320
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
27984
28321
  });
27985
28322
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
28323
+ const machineStores = createMachineStores(deps.kv);
27986
28324
  const heldStates = /* @__PURE__ */ new Map();
27987
28325
  return {
27988
28326
  ownPubkeyBytes,
@@ -27990,12 +28328,29 @@ function composeFacadeParts(deps, hooks) {
27990
28328
  ledger,
27991
28329
  queue,
27992
28330
  historyStore,
27993
- machineStores: createMachineStores(deps.kv),
28331
+ machineStores,
27994
28332
  machineDeps,
27995
28333
  machine: new TransferMachine(machineDeps),
27996
28334
  heldStates,
27997
28335
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
27998
- requests: buildRequests(deps, hooks)
28336
+ requests: buildRequests(deps, hooks),
28337
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
28338
+ };
28339
+ }
28340
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
28341
+ return {
28342
+ stores: machineStores,
28343
+ kv: deps.kv,
28344
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
28345
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
28346
+ decryptPayload: machineDeps.decryptPayload,
28347
+ fullRePull: () => view.onEpochReset(),
28348
+ attention: (transferId, code, detail) => {
28349
+ deps.emit(
28350
+ "transfer:attention",
28351
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28352
+ );
28353
+ }
27999
28354
  };
28000
28355
  }
28001
28356
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -28017,11 +28372,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
28017
28372
  ownPubkey: hexToBytes4(deps.ownPubkey),
28018
28373
  emit: deps.emit,
28019
28374
  now: deps.now ?? Date.now,
28020
- recordHistory: async ({ transferId, payload }) => {
28375
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
28021
28376
  await historyStore.recordSent({
28022
28377
  transferId,
28023
28378
  coinId: payload.coinId,
28024
- amount: payload.amount,
28379
+ // §5.9: the SETTLED amount (machine-computed from the certified
28380
+ // recipient blobs), never payload.amount — the plan.
28381
+ amount: committedAmount,
28025
28382
  recipientPubkey: payload.recipient,
28026
28383
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
28027
28384
  });
@@ -28048,7 +28405,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
28048
28405
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
28049
28406
  );
28050
28407
  },
28051
- syncEpoch: deps.syncEpoch ?? (() => ""),
28408
+ syncEpoch: deps.syncEpoch,
28052
28409
  ...deps.now !== void 0 ? { now: deps.now } : {}
28053
28410
  });
28054
28411
  }
@@ -28101,8 +28458,24 @@ var PaymentsFacade = class {
28101
28458
  this.receiveLoop = parts.receiveLoop;
28102
28459
  this.heldStates = parts.heldStates;
28103
28460
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
28461
+ this.restoreDeps = parts.restoreDeps;
28104
28462
  this.requests = parts.requests;
28105
28463
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
28464
+ this.heartbeat = new ConvergenceHeartbeat({
28465
+ now: () => this.nowMs(),
28466
+ onTick: () => this.trackTail(this.runConvergencePass())
28467
+ });
28468
+ this.converger = new Converger({
28469
+ machineDeps: this.machineDeps,
28470
+ stores: this.machineStores,
28471
+ delivery: deps.deliveryPort,
28472
+ reconcile: (report) => this.requests.reconcile(report),
28473
+ refreshView: () => this.view.delta(),
28474
+ replayMints: () => this.replayMints(),
28475
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
28476
+ emit: deps.emit,
28477
+ now: () => this.nowMs()
28478
+ });
28106
28479
  }
28107
28480
  view;
28108
28481
  ledger;
@@ -28114,17 +28487,28 @@ var PaymentsFacade = class {
28114
28487
  receiveLoop;
28115
28488
  heldStates;
28116
28489
  ownPubkeyBytes;
28490
+ restoreDeps;
28117
28491
  requests;
28118
28492
  currentEngine = null;
28119
28493
  pendingOps = /* @__PURE__ */ new Set();
28120
28494
  unsubscribers = [];
28121
28495
  started = false;
28496
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
28497
+ heartbeat;
28498
+ converger;
28499
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
28500
+ resumeFlight = new SingleFlight();
28501
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
28502
+ passChain = new SerialChain();
28503
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
28504
+ activeMoneyOps = /* @__PURE__ */ new Set();
28122
28505
  // ── lifecycle ──────────────────────────────────────────────────────────────
28123
28506
  async start() {
28124
28507
  if (this.started) return;
28125
28508
  this.started = true;
28126
- await this.deps.session.start();
28509
+ this.heartbeat.start();
28127
28510
  this.unsubscribers.push(
28511
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
28128
28512
  this.deps.session.subscribeStream("mailbox", () => {
28129
28513
  this.trackTail(this.receiveLoop.drainOnce());
28130
28514
  }),
@@ -28135,20 +28519,12 @@ var PaymentsFacade = class {
28135
28519
  this.trackTail(this.requests.drainIncoming());
28136
28520
  })
28137
28521
  );
28138
- this.trackTail(this.runResume());
28139
- this.trackTail(
28140
- this.machineStores.deliveryJournal.replay({
28141
- delivery: this.deps.deliveryPort,
28142
- now: this.deps.now ?? Date.now,
28143
- attention: (transferId, code, detail) => {
28144
- this.deps.emit(
28145
- "transfer:attention",
28146
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
28147
- );
28148
- }
28149
- })
28150
- );
28151
- this.trackTail(this.replayMints());
28522
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
28523
+ if (status === "connected") this.heartbeat.recovered();
28524
+ });
28525
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
28526
+ await this.deps.session.start();
28527
+ this.trackTail(this.runConvergencePass());
28152
28528
  this.trackTail(this.seedHeldStates());
28153
28529
  await this.view.fullPull().catch(() => void 0);
28154
28530
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -28156,6 +28532,7 @@ var PaymentsFacade = class {
28156
28532
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
28157
28533
  async stop() {
28158
28534
  this.started = false;
28535
+ this.heartbeat.stop();
28159
28536
  this.receiveLoop.stop();
28160
28537
  for (const unsubscribe of this.unsubscribers) unsubscribe();
28161
28538
  this.unsubscribers = [];
@@ -28181,9 +28558,13 @@ var PaymentsFacade = class {
28181
28558
  history(page) {
28182
28559
  return this.historyStore.page(page ?? {});
28183
28560
  }
28561
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
28562
+ pendingTransfers() {
28563
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
28564
+ }
28184
28565
  // ── money movement ─────────────────────────────────────────────────────────
28185
28566
  send(request) {
28186
- return this.track(this.sendWithPolicy(request));
28567
+ return this.track(this.sendOutcome(request));
28187
28568
  }
28188
28569
  async receive() {
28189
28570
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -28193,9 +28574,32 @@ var PaymentsFacade = class {
28193
28574
  mint(coinId, amount) {
28194
28575
  return this.track(this.mintInner(coinId, amount));
28195
28576
  }
28577
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
28578
+ async resumeNow() {
28579
+ if (!this.started) return;
28580
+ await this.track(this.runConvergencePass());
28581
+ }
28582
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
28583
+ async handleEpochChange(_newEpoch) {
28584
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
28585
+ await this.passChain.enqueue(() => this.convergeBody());
28586
+ }
28587
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
28588
+ runConvergencePass() {
28589
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
28590
+ }
28591
+ async convergeBody() {
28592
+ const outcome = await this.converger.convergeOnce();
28593
+ const pending = await this.converger.pendingWork().catch(() => true);
28594
+ this.heartbeat.settle(outcome, pending);
28595
+ }
28596
+ nowMs() {
28597
+ return (this.deps.now ?? Date.now)();
28598
+ }
28196
28599
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
28197
- async sendWithPolicy(request) {
28198
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
28600
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
28601
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
28602
+ async sendOutcome(request) {
28199
28603
  const run = {
28200
28604
  amount: request.amount,
28201
28605
  delivered: [],
@@ -28203,12 +28607,36 @@ var PaymentsFacade = class {
28203
28607
  tokenTransfers: [],
28204
28608
  settledShortfalls: [],
28205
28609
  firstPartialId: void 0,
28206
- pendingAny: false
28610
+ pendingAny: false,
28611
+ lastTransferId: ""
28207
28612
  };
28613
+ try {
28614
+ return await this.sendWithPolicy(request, run);
28615
+ } catch (err) {
28616
+ this.emitCleanFailure(err, run.lastTransferId);
28617
+ throw err;
28618
+ }
28619
+ }
28620
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
28621
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
28622
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
28623
+ emitCleanFailure(err, transferId) {
28624
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
28625
+ const failed = {
28626
+ id: transferId,
28627
+ status: "failed",
28628
+ tokens: [],
28629
+ tokenTransfers: [],
28630
+ error: messageOf(err)
28631
+ };
28632
+ this.deps.emit("transfer:updated", failed);
28633
+ }
28634
+ async sendWithPolicy(request, run) {
28635
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
28208
28636
  for (let attempt = 0; ; attempt++) {
28209
28637
  let ctx;
28210
28638
  try {
28211
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
28639
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
28212
28640
  } catch (err) {
28213
28641
  throw this.partialize(err, run);
28214
28642
  }
@@ -28242,21 +28670,24 @@ var PaymentsFacade = class {
28242
28670
  return recipient;
28243
28671
  }
28244
28672
  async runAttempt(ctx) {
28673
+ this.activeMoneyOps.add(ctx.transferId);
28245
28674
  try {
28246
28675
  const { deliveryPending } = await this.machine.run(ctx.plan);
28247
28676
  this.settleSuccess(ctx, ctx.sourceIds);
28248
28677
  return { kind: "success", deliveryPending };
28249
28678
  } catch (err) {
28250
- return this.disposeFailedAttempt(ctx, err);
28679
+ return await this.disposeFailedAttempt(ctx, err);
28680
+ } finally {
28681
+ this.activeMoneyOps.delete(ctx.transferId);
28251
28682
  }
28252
28683
  }
28253
- /**
28254
- * Partial outcome (#677/#690): the shortfall is already durable (written by
28255
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
28256
- * the remainder under a NEW transferId — never the full amount.
28257
- */
28684
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
28685
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
28258
28686
  async consumePartial(ctx, disposition, run, attempt) {
28259
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
28687
+ if (disposition.deliveryPending) {
28688
+ run.pendingAny = true;
28689
+ this.heartbeat.arm();
28690
+ }
28260
28691
  const partial = disposition.entry;
28261
28692
  run.delivered.push(...partial.committedTokenIds);
28262
28693
  run.firstPartialId ??= partial.transferId;
@@ -28277,11 +28708,8 @@ var PaymentsFacade = class {
28277
28708
  run.amount = partial.remainingAmount;
28278
28709
  return null;
28279
28710
  }
28280
- /**
28281
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
28282
- * backstop still 'open' (committed>0) → converge via the same machine's
28283
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
28284
- */
28711
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
28712
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
28285
28713
  async disposeFailedAttempt(ctx, err) {
28286
28714
  if (isPossiblyCommittedSendOutcome(err)) {
28287
28715
  this.settleKeepOpen(ctx);
@@ -28330,8 +28758,9 @@ var PaymentsFacade = class {
28330
28758
  }
28331
28759
  return { kind: "rethrow", error: err };
28332
28760
  }
28333
- async planAndMaterialize(recipientPubkey, request) {
28761
+ async planAndMaterialize(recipientPubkey, request, run) {
28334
28762
  const transferId = this.newId();
28763
+ run.lastTransferId = transferId;
28335
28764
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
28336
28765
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
28337
28766
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -28414,6 +28843,7 @@ var PaymentsFacade = class {
28414
28843
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
28415
28844
  settleKeepOpen(ctx) {
28416
28845
  this.queue.clearExpectedChange(ctx.transferId);
28846
+ this.heartbeat.arm();
28417
28847
  }
28418
28848
  settlePartial(ctx, partial) {
28419
28849
  this.ledger.cancel(ctx.transferId);
@@ -28450,6 +28880,7 @@ var PaymentsFacade = class {
28450
28880
  for (const id of run.settledShortfalls.splice(0)) {
28451
28881
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
28452
28882
  }
28883
+ if (run.pendingAny) this.heartbeat.arm();
28453
28884
  const result = {
28454
28885
  id: transferId,
28455
28886
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -28461,11 +28892,7 @@ var PaymentsFacade = class {
28461
28892
  this.deps.emit("transfer:updated", result);
28462
28893
  return result;
28463
28894
  }
28464
- /**
28465
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
28466
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
28467
- * over the accumulated settled set — never bare, never a full-amount retry.
28468
- */
28895
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
28469
28896
  partialize(err, run) {
28470
28897
  if (run.delivered.length === 0) return err;
28471
28898
  return new PartialSendConflictError(
@@ -28494,21 +28921,29 @@ var PaymentsFacade = class {
28494
28921
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
28495
28922
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
28496
28923
  }
28497
- const engine = this.engine();
28498
28924
  const mintId = this.newId();
28499
- const params = this.mintParams(coinId, amount);
28925
+ this.activeMoneyOps.add(mintId);
28926
+ try {
28927
+ return await this.mintUnderJournal(mintId, coinId, amount);
28928
+ } finally {
28929
+ this.activeMoneyOps.delete(mintId);
28930
+ }
28931
+ }
28932
+ async mintUnderJournal(mintId, coinId, amount) {
28933
+ const engine = this.engine();
28500
28934
  const entry = {
28501
28935
  mintId,
28502
28936
  coinId,
28503
28937
  amount: amount.toString(),
28504
28938
  tokenId: "",
28505
- createdAt: (this.deps.now ?? Date.now)()
28939
+ createdAt: this.nowMs()
28506
28940
  };
28507
28941
  await this.machineStores.mintJournal.upsert(entry);
28508
28942
  let token;
28509
28943
  try {
28510
- token = await engine.mint(params, { transferId: mintId });
28944
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
28511
28945
  } catch (err) {
28946
+ this.heartbeat.arm();
28512
28947
  return { success: false, error: messageOf(err) };
28513
28948
  }
28514
28949
  if (token.blob.tokenId !== entry.tokenId) {
@@ -28517,6 +28952,7 @@ var PaymentsFacade = class {
28517
28952
  try {
28518
28953
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
28519
28954
  } catch (err) {
28955
+ this.heartbeat.arm();
28520
28956
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
28521
28957
  }
28522
28958
  return { success: true, tokenId: token.blob.tokenId };
@@ -28539,18 +28975,22 @@ var PaymentsFacade = class {
28539
28975
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
28540
28976
  this.trackTail(this.view.delta());
28541
28977
  }
28978
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
28542
28979
  async replayMints() {
28980
+ let resolved = 0;
28543
28981
  for (const entry of await this.machineStores.mintJournal.list()) {
28982
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
28544
28983
  try {
28545
- await this.replayMint(entry);
28984
+ if (await this.replayMint(entry)) resolved += 1;
28546
28985
  } catch {
28547
28986
  }
28548
28987
  }
28988
+ return resolved;
28549
28989
  }
28550
28990
  async replayMint(entry) {
28551
28991
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
28552
28992
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
28553
- return;
28993
+ return true;
28554
28994
  }
28555
28995
  const engine = this.engine();
28556
28996
  if (!supportsDeterministicMint(engine)) {
@@ -28558,7 +28998,7 @@ var PaymentsFacade = class {
28558
28998
  transferId: entry.mintId,
28559
28999
  code: ATTENTION_MINT_UNRESOLVED
28560
29000
  });
28561
- return;
29001
+ return false;
28562
29002
  }
28563
29003
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
28564
29004
  transferId: entry.mintId
@@ -28567,6 +29007,7 @@ var PaymentsFacade = class {
28567
29007
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
28568
29008
  }
28569
29009
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
29010
+ return true;
28570
29011
  }
28571
29012
  mintParams(coinId, amount) {
28572
29013
  return {
@@ -28592,17 +29033,6 @@ var PaymentsFacade = class {
28592
29033
  newId() {
28593
29034
  return (this.deps.newId ?? randomUUID)();
28594
29035
  }
28595
- async runResume() {
28596
- const report = await resumeAll(this.machineDeps);
28597
- await this.requests.reconcile({
28598
- resumed: report.resumed,
28599
- conflicted: report.conflicted,
28600
- open: report.failed
28601
- });
28602
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
28603
- await this.view.delta().catch(() => void 0);
28604
- }
28605
- }
28606
29036
  async seedHeldStates() {
28607
29037
  let page = await this.deps.storagePort.listInventory();
28608
29038
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -28614,22 +29044,14 @@ var PaymentsFacade = class {
28614
29044
  }
28615
29045
  }
28616
29046
  toUiToken(tokenId, coinId, amount) {
28617
- const registry = this.deps.registry;
28618
- const iconUrl = registry.getIconUrl(coinId);
28619
- const now = (this.deps.now ?? Date.now)();
28620
- return {
28621
- id: tokenId,
28622
- coinId,
28623
- symbol: registry.getSymbol(coinId),
28624
- name: registry.getName(coinId),
28625
- decimals: registry.getDecimals(coinId),
28626
- ...iconUrl !== null ? { iconUrl } : {},
28627
- amount: amount.toString(),
28628
- status: "transferring",
28629
- createdAt: now,
28630
- updatedAt: now,
28631
- lazy: true
28632
- };
29047
+ const now = this.nowMs();
29048
+ return toToken(
29049
+ tokenId,
29050
+ { assets: [], createdAt: now, updatedAt: now },
29051
+ { coinId, amount: amount.toString() },
29052
+ this.deps.registry,
29053
+ { transferring: true, suspectedSpent: false }
29054
+ );
28633
29055
  }
28634
29056
  track(op) {
28635
29057
  const settled = op.then(
@@ -28644,27 +29066,6 @@ var PaymentsFacade = class {
28644
29066
  this.track(op).catch(() => void 0);
28645
29067
  }
28646
29068
  };
28647
- function buildPayload(recipientPubkey, request, spend, spentStates) {
28648
- return {
28649
- v: 2,
28650
- recipient: recipientPubkey,
28651
- coinId: request.coinId,
28652
- amount: request.amount,
28653
- ...request.memo !== void 0 ? { memo: request.memo } : {},
28654
- direct: [...spend.plan.direct],
28655
- ...spend.plan.split !== void 0 ? {
28656
- split: {
28657
- tokenId: spend.plan.split.tokenId,
28658
- splitAmount: spend.plan.split.splitAmount.toString(),
28659
- remainderAmount: spend.plan.split.remainderAmount.toString()
28660
- }
28661
- } : {},
28662
- spentStates
28663
- };
28664
- }
28665
- function messageOf(err) {
28666
- return err instanceof Error ? err.message : String(err);
28667
- }
28668
29069
 
28669
29070
  // impl/wallet-api-v2/http.ts
28670
29071
  var WalletApiHttpError = class extends Error {
@@ -29010,7 +29411,9 @@ var WalletApiSession = class {
29010
29411
  epochLatch = null;
29011
29412
  reseedChain = Promise.resolve();
29012
29413
  reseedsPending = 0;
29414
+ epochHandlers = /* @__PURE__ */ new Set();
29013
29415
  handlers = /* @__PURE__ */ new Map();
29416
+ statusHandlers = /* @__PURE__ */ new Set();
29014
29417
  socket = null;
29015
29418
  started = false;
29016
29419
  stopped = false;
@@ -29111,6 +29514,18 @@ var WalletApiSession = class {
29111
29514
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
29112
29515
  }
29113
29516
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
29517
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
29518
+ currentEpoch() {
29519
+ return this.epochLatch ?? "";
29520
+ }
29521
+ /**
29522
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
29523
+ * the reseed chain, BEFORE any stream nudge resumes.
29524
+ */
29525
+ subscribeEpochChange(handler) {
29526
+ this.epochHandlers.add(handler);
29527
+ return () => this.epochHandlers.delete(handler);
29528
+ }
29114
29529
  noteEpoch(epoch) {
29115
29530
  if (this.epochLatch === epoch) return;
29116
29531
  const previous = this.epochLatch;
@@ -29124,7 +29539,7 @@ var WalletApiSession = class {
29124
29539
  }
29125
29540
  async runReseed(epoch, previous) {
29126
29541
  try {
29127
- await this.deps.onEpochChange(epoch);
29542
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
29128
29543
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
29129
29544
  } catch {
29130
29545
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -29161,10 +29576,16 @@ var WalletApiSession = class {
29161
29576
  status() {
29162
29577
  return this.lastStatus ?? "offline";
29163
29578
  }
29579
+ /** Transition feed off the existing emitStatus point (FacadeSession.subscribeStatus). */
29580
+ subscribeStatus(handler) {
29581
+ this.statusHandlers.add(handler);
29582
+ return () => this.statusHandlers.delete(handler);
29583
+ }
29164
29584
  emitStatus(status) {
29165
29585
  if (this.stopped || this.lastStatus === status) return;
29166
29586
  this.lastStatus = status;
29167
29587
  this.deps.emitStatus(status);
29588
+ for (const handler of this.statusHandlers) handler(status);
29168
29589
  }
29169
29590
  async connect() {
29170
29591
  if (this.stopped) return;
@@ -29380,6 +29801,7 @@ var WalletApiDeliveryPort = class {
29380
29801
  wake;
29381
29802
  deriveFn;
29382
29803
  seenChain = new SerialChain();
29804
+ lastIncomingEpoch = null;
29383
29805
  constructor(config) {
29384
29806
  this.client = config.client;
29385
29807
  this.kv = config.kv;
@@ -29474,6 +29896,7 @@ var WalletApiDeliveryPort = class {
29474
29896
  const seen = await this.readSeen();
29475
29897
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
29476
29898
  for (; ; ) {
29899
+ this.lastIncomingEpoch = String(page.syncEpoch);
29477
29900
  for (const entry of page.entries) {
29478
29901
  if (entry.status !== "unclaimed") continue;
29479
29902
  if (seen.has(entry.entryId)) continue;
@@ -29483,6 +29906,10 @@ var WalletApiDeliveryPort = class {
29483
29906
  page = await this.client.listMailbox(page.cursor);
29484
29907
  }
29485
29908
  }
29909
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
29910
+ incomingEpoch() {
29911
+ return this.lastIncomingEpoch;
29912
+ }
29486
29913
  toIncoming(entry) {
29487
29914
  const bundle = this.openEnvelope(entry);
29488
29915
  return {
@@ -29560,7 +29987,7 @@ var WalletApiSplitCheckpointStore = class {
29560
29987
  return utf8(`${transferId}:${String(opIndex)}`);
29561
29988
  }
29562
29989
  async put(transferId, opIndex, bytes) {
29563
- const { client, kv, fieldKey, signProgress } = this.config;
29990
+ const { kv, fieldKey } = this.config;
29564
29991
  const aad = this.aad(transferId, opIndex);
29565
29992
  const cacheKey = this.cacheKey(transferId, opIndex);
29566
29993
  let envelope = await kv.get(cacheKey);
@@ -29568,10 +29995,21 @@ var WalletApiSplitCheckpointStore = class {
29568
29995
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
29569
29996
  await kv.set(cacheKey, envelope);
29570
29997
  }
29571
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
29572
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
29998
+ const record = await this.post(transferId, opIndex, envelope);
29573
29999
  return this.open(record.payload, aad);
29574
30000
  }
30001
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
30002
+ async reseedCheckpoint(transferId, opIndex) {
30003
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
30004
+ if (envelope === null) return false;
30005
+ await this.post(transferId, opIndex, envelope);
30006
+ return true;
30007
+ }
30008
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
30009
+ async post(transferId, opIndex, envelope) {
30010
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
30011
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
30012
+ }
29575
30013
  async get(transferId, opIndex) {
29576
30014
  const records = await this.config.client.getProgress(transferId);
29577
30015
  const record = records.find((r) => r.opIndex === opIndex);
@@ -29594,7 +30032,7 @@ var WalletApiSplitCheckpointStore = class {
29594
30032
  };
29595
30033
 
29596
30034
  // core/payments-v2-wiring.ts
29597
- function resolvePaymentsV2Composition(walletApi) {
30035
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
29598
30036
  const source = walletApi;
29599
30037
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
29600
30038
  if (!source || network === null) {
@@ -29603,6 +30041,18 @@ function resolvePaymentsV2Composition(walletApi) {
29603
30041
  "INVALID_CONFIG"
29604
30042
  );
29605
30043
  }
30044
+ if (!(network in NETWORKS)) {
30045
+ throw new SphereError(
30046
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
30047
+ "INVALID_CONFIG"
30048
+ );
30049
+ }
30050
+ if (network !== sphereNetwork) {
30051
+ throw new SphereError(
30052
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
30053
+ "INVALID_CONFIG"
30054
+ );
30055
+ }
29606
30056
  if (typeof source.paymentsV2Transport === "function") {
29607
30057
  return { network, factory: source.paymentsV2Transport.bind(source) };
29608
30058
  }
@@ -29668,8 +30118,7 @@ function defaultPaymentsV2Transport(config) {
29668
30118
  deviceId: `${config.deviceId}:pv2`,
29669
30119
  kv: args.kv,
29670
30120
  webSocketFactory,
29671
- emitStatus: args.emitStatus,
29672
- onEpochChange: args.onEpochChange
30121
+ emitStatus: args.emitStatus
29673
30122
  });
29674
30123
  return { session, client: authedWireClient(session, client) };
29675
30124
  };
@@ -29739,8 +30188,7 @@ function composePaymentsV2(spec) {
29739
30188
  identity: identitySlice,
29740
30189
  network,
29741
30190
  kv,
29742
- emitStatus: (status) => host.emit("connection:status", { status }),
29743
- onEpochChange: async () => void 0
30191
+ emitStatus: (status) => host.emit("connection:status", { status })
29744
30192
  });
29745
30193
  const deps = {
29746
30194
  session: transport.session,
@@ -29770,7 +30218,10 @@ function composePaymentsV2(spec) {
29770
30218
  network,
29771
30219
  ownPubkey: identity.chainPubkey,
29772
30220
  ownNametag: host.nametag,
29773
- requestMemo: requestMemoCodec(identity.privateKey)
30221
+ requestMemo: requestMemoCodec(identity.privateKey),
30222
+ // §5.1: the session is the epoch authority — the facade registers its
30223
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
30224
+ syncEpoch: () => transport.session.currentEpoch()
29774
30225
  };
29775
30226
  return new PaymentsFacade(deps);
29776
30227
  }
@@ -30100,18 +30551,20 @@ var Sphere = class _Sphere {
30100
30551
  }
30101
30552
  /**
30102
30553
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
30103
- * fail-closed at init, never a silently-degraded composition.
30554
+ * fail-closed at init, never a silently-degraded composition. Returns the
30555
+ * resolved composition (null = flag off); #728: resolving here also asserts
30556
+ * the single-network invariant (walletApi.network known + equal to the
30557
+ * Sphere network) before any session/KV/provider exists.
30104
30558
  */
30105
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
30106
- if (flag !== true) return false;
30559
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
30560
+ if (flag !== true) return null;
30107
30561
  if (accountingConfig || swapConfig) {
30108
30562
  throw new SphereError(
30109
30563
  "paymentsV2 is incompatible with the accounting/swap modules (both are built on the legacy PaymentsModule and are deleted with the P11 flip) \u2014 disable them or the flag.",
30110
30564
  "INVALID_CONFIG"
30111
30565
  );
30112
30566
  }
30113
- resolvePaymentsV2Composition(walletApi);
30114
- return true;
30567
+ return resolvePaymentsV2Composition(walletApi, network);
30115
30568
  }
30116
30569
  /**
30117
30570
  * Configure TokenRegistry in the main bundle context.
@@ -30166,12 +30619,14 @@ var Sphere = class _Sphere {
30166
30619
  options.walletApi
30167
30620
  );
30168
30621
  sphere._verification = options.verification;
30169
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30622
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30170
30623
  options.paymentsV2,
30171
30624
  accountingConfig,
30172
30625
  swapConfig,
30173
- options.walletApi
30626
+ options.walletApi,
30627
+ options.network
30174
30628
  );
30629
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30175
30630
  sphere._password = options.password ?? null;
30176
30631
  sphere._passwordProtected = sphere._password !== null;
30177
30632
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -30238,12 +30693,14 @@ var Sphere = class _Sphere {
30238
30693
  options.walletApi
30239
30694
  );
30240
30695
  sphere._verification = options.verification;
30241
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30696
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30242
30697
  options.paymentsV2,
30243
30698
  accountingConfig,
30244
30699
  swapConfig,
30245
- options.walletApi
30700
+ options.walletApi,
30701
+ options.network
30246
30702
  );
30703
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30247
30704
  sphere._password = options.password ?? null;
30248
30705
  sphere._passwordProtected = sphere._password !== null;
30249
30706
  if (!options.storage.isConnected()) {
@@ -30317,12 +30774,14 @@ var Sphere = class _Sphere {
30317
30774
  options.walletApi
30318
30775
  );
30319
30776
  sphere._verification = options.verification;
30320
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
30777
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
30321
30778
  options.paymentsV2,
30322
30779
  accountingConfig,
30323
30780
  swapConfig,
30324
- options.walletApi
30781
+ options.walletApi,
30782
+ options.network
30325
30783
  );
30784
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
30326
30785
  sphere._password = options.password ?? null;
30327
30786
  sphere._passwordProtected = sphere._password !== null;
30328
30787
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -32980,7 +33439,9 @@ var Sphere = class _Sphere {
32980
33439
  * setOracleApiKey rebuild is what future operations snapshot.
32981
33440
  */
32982
33441
  async startPaymentsV2Inner(index, identity) {
32983
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
33442
+ if (this._paymentsV2Composition === null) {
33443
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
33444
+ }
32984
33445
  if (!this._addressModules.get(index)?.tokenEngine) {
32985
33446
  throw new SphereError(
32986
33447
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",