@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.
package/dist/index.js CHANGED
@@ -12083,14 +12083,14 @@ var IntentResume = class {
12083
12083
  }
12084
12084
  let changeOutput = null;
12085
12085
  if (payload.split) {
12086
- const splitOpIndex = payload.direct.length;
12086
+ const splitOpIndex2 = payload.direct.length;
12087
12087
  let splitDelivered = false;
12088
12088
  {
12089
12089
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
12090
12090
  const outcome = await this.host.certifyOperation(
12091
12091
  {
12092
12092
  kind: "split",
12093
- opIndex: splitOpIndex,
12093
+ opIndex: splitOpIndex2,
12094
12094
  uiTokenId: `v2_${payload.split.tokenId}`,
12095
12095
  genesisId: payload.split.tokenId,
12096
12096
  sourceSdkData: "",
@@ -22201,6 +22201,7 @@ var AccountingModule = class _AccountingModule {
22201
22201
  const unsubHistory = deps.on(
22202
22202
  "history:updated",
22203
22203
  (entry) => {
22204
+ if (!("dedupKey" in entry)) return;
22204
22205
  this._handleHistoryUpdated(entry).catch((err) => {
22205
22206
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
22206
22207
  });
@@ -27463,7 +27464,10 @@ var STORE_KEYS = {
27463
27464
  shortfalls: "shortfalls",
27464
27465
  settlingLinks: "settling",
27465
27466
  streamCursor: (s) => `cursor:${s}`,
27466
- epochLatch: "epoch-latch"
27467
+ epochLatch: "epoch-latch",
27468
+ // §5.2 InventoryView durable overlays (#625/#679).
27469
+ suspectedSpent: "suspected-spent",
27470
+ knownSpends: "known-spends"
27467
27471
  };
27468
27472
 
27469
27473
  // modules/payments-v2/machine/journal.ts
@@ -27486,6 +27490,10 @@ function isValidationReject(err) {
27486
27490
  if (e === null) return false;
27487
27491
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
27488
27492
  }
27493
+ function retryAfterMsOf(err) {
27494
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
27495
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
27496
+ }
27489
27497
  var chainRegistry = /* @__PURE__ */ new WeakMap();
27490
27498
  var replayRegistry = /* @__PURE__ */ new WeakMap();
27491
27499
  function registryFor(registry, kv, storeKey, make) {
@@ -27709,7 +27717,7 @@ var TransferMachine = class {
27709
27717
  }
27710
27718
  if (cls === null) {
27711
27719
  await this.applyCommitted(engine, plan.transferId, committed);
27712
- this.closeTail(plan.transferId, plan.payload);
27720
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
27713
27721
  return { committed, deliveryPending };
27714
27722
  }
27715
27723
  if (cls === "conflict" && committed.length > 0) {
@@ -27735,10 +27743,10 @@ var TransferMachine = class {
27735
27743
  }
27736
27744
  await this.applyCommitted(engine, r.transferId, committed);
27737
27745
  if (conflicts.length > 0) {
27738
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
27746
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
27739
27747
  }
27740
27748
  await this.completeIntent(r.transferId, r.payload);
27741
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27749
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27742
27750
  return { deliveryPending, partial: null };
27743
27751
  }
27744
27752
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -27920,7 +27928,7 @@ var TransferMachine = class {
27920
27928
  }
27921
27929
  return added;
27922
27930
  }
27923
- async settlePartial(r, committed, conflicts) {
27931
+ async settlePartial(engine, r, committed, conflicts) {
27924
27932
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
27925
27933
  const entry = {
27926
27934
  transferId: r.transferId,
@@ -27932,26 +27940,27 @@ var TransferMachine = class {
27932
27940
  };
27933
27941
  await this.stores.shortfalls.upsert(entry);
27934
27942
  await this.completeIntent(r.transferId, r.payload);
27935
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27943
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27936
27944
  return entry;
27937
27945
  }
27938
- closeTail(transferId, payload) {
27946
+ closeTail(engine, transferId, payload, committed) {
27939
27947
  void (async () => {
27940
27948
  try {
27941
27949
  await this.completeIntent(transferId, payload);
27942
27950
  } catch {
27943
27951
  return;
27944
27952
  }
27945
- await this.finishBookkeeping(transferId, payload, "sent");
27953
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
27946
27954
  })();
27947
27955
  }
27948
27956
  async completeIntent(transferId, payload) {
27949
27957
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
27950
27958
  await this.deps.intents.complete(transferId, signature);
27951
27959
  }
27952
- async finishBookkeeping(transferId, payload, phase) {
27960
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
27953
27961
  try {
27954
- await this.deps.recordHistory?.({ transferId, payload, phase });
27962
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
27963
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
27955
27964
  } catch {
27956
27965
  }
27957
27966
  try {
@@ -27959,6 +27968,26 @@ var TransferMachine = class {
27959
27968
  } catch {
27960
27969
  }
27961
27970
  }
27971
+ /**
27972
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
27973
+ * certified recipient blobs (summarize()'s committed set is the only source;
27974
+ * a journal-rehydrated resume leg has no source token, but every committed
27975
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
27976
+ */
27977
+ async settledAmount(engine, coinId, committed) {
27978
+ let total = 0n;
27979
+ for (const outcome of committed) {
27980
+ if (outcome.recipientBlob === void 0) continue;
27981
+ const token = await engine.decodeToken({
27982
+ v: TOKEN_BLOB_VERSION,
27983
+ network: 0,
27984
+ tokenId: "",
27985
+ token: outcome.recipientBlob
27986
+ });
27987
+ total += engine.balanceOf(token, coinId);
27988
+ }
27989
+ return total.toString();
27990
+ }
27962
27991
  async disposeOnFailure(transferId, committed, cls) {
27963
27992
  if (committed.length > 0 || cls === "keep-open") return;
27964
27993
  try {
@@ -27983,7 +28012,7 @@ function conflictAmount(engine, payload, op, source) {
27983
28012
  }
27984
28013
 
27985
28014
  // modules/payments-v2/machine/resume.ts
27986
- async function resumeAll(deps) {
28015
+ async function resumeAll(deps, options = {}) {
27987
28016
  const report = { resumed: [], conflicted: [], failed: [] };
27988
28017
  const stores = createMachineStores(deps.kv);
27989
28018
  const open = await deps.intents.listOpen();
@@ -27993,7 +28022,12 @@ async function resumeAll(deps) {
27993
28022
  } catch {
27994
28023
  return report;
27995
28024
  }
27996
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
28025
+ await dropStaleBackstops(
28026
+ stores,
28027
+ locals,
28028
+ new Set(open.map((i) => i.transferId)),
28029
+ options.isLocallyActive ?? (() => false)
28030
+ );
27997
28031
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
27998
28032
  const ctx = {
27999
28033
  deps,
@@ -28006,11 +28040,16 @@ async function resumeAll(deps) {
28006
28040
  }
28007
28041
  return report;
28008
28042
  }
28009
- async function dropStaleLocalAborts(stores, locals, openIds) {
28043
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
28010
28044
  for (const entry of locals.values()) {
28011
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
28045
+ if (openIds.has(entry.transferId)) continue;
28046
+ if (entry.disposition !== "open") {
28012
28047
  await stores.backstop.removeByKey(entry.transferId);
28048
+ continue;
28013
28049
  }
28050
+ if (isLocallyActive(entry.transferId)) continue;
28051
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
28052
+ await stores.backstop.removeByKey(entry.transferId);
28014
28053
  }
28015
28054
  }
28016
28055
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -28123,6 +28162,349 @@ function validatePayload(raw) {
28123
28162
  return p;
28124
28163
  }
28125
28164
 
28165
+ // modules/payments-v2/convergence.ts
28166
+ var HEARTBEAT_SEED_MS = 5e3;
28167
+ var HEARTBEAT_CAP_MS = 12e4;
28168
+ var ConvergenceHeartbeat = class {
28169
+ constructor(deps) {
28170
+ this.deps = deps;
28171
+ }
28172
+ timer = null;
28173
+ delayMs = HEARTBEAT_SEED_MS;
28174
+ notBefore = 0;
28175
+ running = false;
28176
+ start() {
28177
+ this.running = true;
28178
+ this.delayMs = HEARTBEAT_SEED_MS;
28179
+ this.notBefore = 0;
28180
+ }
28181
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
28182
+ stop() {
28183
+ this.running = false;
28184
+ this.clearTimer();
28185
+ }
28186
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
28187
+ settle(outcome, pending) {
28188
+ if (outcome.retryAfterMs !== null) {
28189
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
28190
+ }
28191
+ if (!pending) {
28192
+ this.delayMs = HEARTBEAT_SEED_MS;
28193
+ return;
28194
+ }
28195
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
28196
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
28197
+ this.schedule(delay);
28198
+ }
28199
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
28200
+ arm() {
28201
+ if (!this.running) return;
28202
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
28203
+ this.schedule(HEARTBEAT_SEED_MS);
28204
+ }
28205
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
28206
+ recovered() {
28207
+ this.delayMs = HEARTBEAT_SEED_MS;
28208
+ if (this.timer !== null) this.arm();
28209
+ }
28210
+ schedule(delayMs) {
28211
+ this.clearTimer();
28212
+ if (!this.running) return;
28213
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
28214
+ this.timer = setTimeout(() => {
28215
+ this.timer = null;
28216
+ this.deps.onTick();
28217
+ }, floored);
28218
+ }
28219
+ clearTimer() {
28220
+ if (this.timer !== null) clearTimeout(this.timer);
28221
+ this.timer = null;
28222
+ }
28223
+ };
28224
+ var Converger = class {
28225
+ constructor(deps) {
28226
+ this.deps = deps;
28227
+ }
28228
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
28229
+ async convergeOnce() {
28230
+ let progress = false;
28231
+ let retryAfterMs = null;
28232
+ try {
28233
+ progress = await this.resumePass();
28234
+ } catch (err) {
28235
+ retryAfterMs = retryAfterMsOf(err);
28236
+ }
28237
+ try {
28238
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
28239
+ progress = progress || stats.delivered > 0;
28240
+ } catch (err) {
28241
+ retryAfterMs ??= retryAfterMsOf(err);
28242
+ }
28243
+ try {
28244
+ progress = await this.deps.replayMints() > 0 || progress;
28245
+ } catch (err) {
28246
+ retryAfterMs ??= retryAfterMsOf(err);
28247
+ }
28248
+ return { progress, retryAfterMs };
28249
+ }
28250
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
28251
+ async pendingWork() {
28252
+ const [backstop, journal, mints] = await Promise.all([
28253
+ this.deps.stores.backstop.list(),
28254
+ this.deps.stores.deliveryJournal.list(),
28255
+ this.deps.stores.mintJournal.list()
28256
+ ]);
28257
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
28258
+ }
28259
+ async resumePass() {
28260
+ const report = await resumeAll(this.filteredMachineDeps(), {
28261
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
28262
+ });
28263
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
28264
+ await this.deps.reconcile({
28265
+ resumed: report.resumed,
28266
+ conflicted: report.conflicted,
28267
+ open: report.failed
28268
+ });
28269
+ if (progress) await this.deps.refreshView().catch(() => void 0);
28270
+ return progress;
28271
+ }
28272
+ filteredMachineDeps() {
28273
+ const intents = this.deps.machineDeps.intents;
28274
+ return {
28275
+ ...this.deps.machineDeps,
28276
+ intents: {
28277
+ ...intents,
28278
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
28279
+ }
28280
+ };
28281
+ }
28282
+ replayDeps() {
28283
+ return {
28284
+ delivery: this.deps.delivery,
28285
+ now: () => this.deps.now(),
28286
+ attention: (transferId, code, detail) => {
28287
+ this.deps.emit(
28288
+ "transfer:attention",
28289
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28290
+ );
28291
+ }
28292
+ };
28293
+ }
28294
+ };
28295
+ async function derivePendingTransfers(stores, decryptPayload) {
28296
+ const [backstop, journal, shortfalls] = await Promise.all([
28297
+ stores.backstop.list(),
28298
+ stores.deliveryJournal.list(),
28299
+ stores.shortfalls.list()
28300
+ ]);
28301
+ const byTransfer = groupJournal(journal);
28302
+ const out = [];
28303
+ const seen = /* @__PURE__ */ new Set();
28304
+ for (const entry of backstop) {
28305
+ if (entry.disposition !== "open") continue;
28306
+ seen.add(entry.transferId);
28307
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
28308
+ }
28309
+ for (const [transferId, entries] of byTransfer) {
28310
+ if (seen.has(transferId)) continue;
28311
+ seen.add(transferId);
28312
+ out.push(journalOnlyRow(transferId, entries));
28313
+ }
28314
+ for (const entry of shortfalls) {
28315
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
28316
+ }
28317
+ return out;
28318
+ }
28319
+ function groupJournal(journal) {
28320
+ const byTransfer = /* @__PURE__ */ new Map();
28321
+ for (const entry of journal) {
28322
+ const mine = byTransfer.get(entry.transferId);
28323
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
28324
+ else mine.push(entry);
28325
+ }
28326
+ return byTransfer;
28327
+ }
28328
+ async function openRow(entry, journal, decryptPayload) {
28329
+ let payload = null;
28330
+ try {
28331
+ const raw = await decryptPayload(entry.payloadEnvelope);
28332
+ if (raw !== null && typeof raw === "object") payload = raw;
28333
+ } catch {
28334
+ payload = null;
28335
+ }
28336
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
28337
+ const certified = distinctOpCount(journal);
28338
+ return {
28339
+ transferId: entry.transferId,
28340
+ kind: "open",
28341
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
28342
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
28343
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
28344
+ legs: { certified, total: Math.max(total, certified) },
28345
+ deliveryPending: journal.length > 0,
28346
+ createdAt: entry.createdAt
28347
+ };
28348
+ }
28349
+ function journalOnlyRow(transferId, entries) {
28350
+ const certified = distinctOpCount(entries);
28351
+ return {
28352
+ transferId,
28353
+ kind: "open",
28354
+ recipient: entries[0].recipientPubkey,
28355
+ coinId: "",
28356
+ amount: "",
28357
+ legs: { certified, total: certified },
28358
+ deliveryPending: true,
28359
+ createdAt: 0
28360
+ };
28361
+ }
28362
+ function shortfallRow(entry) {
28363
+ return {
28364
+ transferId: entry.transferId,
28365
+ kind: "shortfall",
28366
+ recipient: entry.recipient,
28367
+ coinId: entry.coinId,
28368
+ amount: entry.remainingAmount,
28369
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
28370
+ deliveryPending: false,
28371
+ createdAt: entry.createdAt
28372
+ };
28373
+ }
28374
+ function distinctOpCount(entries) {
28375
+ return new Set(entries.map((entry) => entry.opIndex)).size;
28376
+ }
28377
+
28378
+ // modules/payments-v2/restore.ts
28379
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
28380
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
28381
+ async function reseedAndReset(deps) {
28382
+ for (const entry of await deps.stores.backstop.list()) {
28383
+ if (entry.disposition !== "open") continue;
28384
+ try {
28385
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
28386
+ if (entry.requiresSeedClose) {
28387
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
28388
+ }
28389
+ } catch (err) {
28390
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
28391
+ }
28392
+ }
28393
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
28394
+ await deps.fullRePull();
28395
+ }
28396
+ async function splitOpIndex(deps, envelope) {
28397
+ const payload = await deps.decryptPayload(envelope);
28398
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
28399
+ }
28400
+
28401
+ // modules/payments-v2/inventory/presentation.ts
28402
+ function toToken(tokenId, snapshot, asset, registry, flags) {
28403
+ const iconUrl = registry.getIconUrl(asset.coinId);
28404
+ return {
28405
+ id: tokenId,
28406
+ coinId: asset.coinId,
28407
+ symbol: registry.getSymbol(asset.coinId),
28408
+ name: registry.getName(asset.coinId),
28409
+ decimals: registry.getDecimals(asset.coinId),
28410
+ ...iconUrl !== null ? { iconUrl } : {},
28411
+ amount: asset.amount,
28412
+ status: flags.transferring ? "transferring" : "confirmed",
28413
+ createdAt: snapshot.createdAt,
28414
+ updatedAt: snapshot.updatedAt,
28415
+ lazy: true,
28416
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28417
+ };
28418
+ }
28419
+ function aggregateAssets(entries, isTransferring, registry) {
28420
+ const groups = /* @__PURE__ */ new Map();
28421
+ for (const [tokenId, entry] of entries) {
28422
+ const moving = isTransferring(tokenId);
28423
+ for (const asset of entry.assets) {
28424
+ let group = groups.get(asset.coinId);
28425
+ if (!group) {
28426
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28427
+ groups.set(asset.coinId, group);
28428
+ }
28429
+ if (moving) {
28430
+ group.transferring += BigInt(asset.amount);
28431
+ group.transferringCount += 1;
28432
+ } else {
28433
+ group.total += BigInt(asset.amount);
28434
+ group.count += 1;
28435
+ }
28436
+ }
28437
+ }
28438
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28439
+ }
28440
+ function toAsset(coinId, totals, registry) {
28441
+ const iconUrl = registry.getIconUrl(coinId);
28442
+ return {
28443
+ coinId,
28444
+ symbol: registry.getSymbol(coinId),
28445
+ name: registry.getName(coinId),
28446
+ decimals: registry.getDecimals(coinId),
28447
+ ...iconUrl !== null ? { iconUrl } : {},
28448
+ totalAmount: totals.total.toString(),
28449
+ tokenCount: totals.count,
28450
+ confirmedAmount: totals.total.toString(),
28451
+ unconfirmedAmount: "0",
28452
+ confirmedTokenCount: totals.count,
28453
+ unconfirmedTokenCount: 0,
28454
+ transferringTokenCount: totals.transferringCount,
28455
+ transferringAmount: totals.transferring.toString(),
28456
+ priceUsd: null,
28457
+ priceEur: null,
28458
+ change24h: null,
28459
+ fiatValueUsd: null,
28460
+ fiatValueEur: null
28461
+ };
28462
+ }
28463
+ async function withPrices(raw, registry, price) {
28464
+ try {
28465
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28466
+ const quotes = await price.getPrices(names);
28467
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28468
+ } catch {
28469
+ return raw;
28470
+ }
28471
+ }
28472
+ function priceOne(asset, quote) {
28473
+ if (!quote) return asset;
28474
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28475
+ return {
28476
+ ...asset,
28477
+ priceUsd: quote.priceUsd,
28478
+ priceEur: quote.priceEur ?? null,
28479
+ change24h: quote.change24h ?? null,
28480
+ fiatValueUsd: human * quote.priceUsd,
28481
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28482
+ };
28483
+ }
28484
+
28485
+ // modules/payments-v2/machine/payload.ts
28486
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
28487
+ return {
28488
+ v: 2,
28489
+ recipient: recipientPubkey,
28490
+ coinId: request.coinId,
28491
+ amount: request.amount,
28492
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
28493
+ direct: [...spend.plan.direct],
28494
+ ...spend.plan.split !== void 0 ? {
28495
+ split: {
28496
+ tokenId: spend.plan.split.tokenId,
28497
+ splitAmount: spend.plan.split.splitAmount.toString(),
28498
+ remainderAmount: spend.plan.split.remainderAmount.toString()
28499
+ }
28500
+ } : {},
28501
+ spentStates
28502
+ };
28503
+ }
28504
+ function messageOf(err) {
28505
+ return err instanceof Error ? err.message : String(err);
28506
+ }
28507
+
28126
28508
  // modules/payments-v2/history/History.ts
28127
28509
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
28128
28510
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -28181,13 +28563,14 @@ var History = class {
28181
28563
  }
28182
28564
  // ── internals ────────────────────────────────────────────────────────────────
28183
28565
  async post(build) {
28566
+ const record = build();
28184
28567
  try {
28185
- await this.deps.client.postHistory([build()]);
28568
+ await this.deps.client.postHistory([record]);
28186
28569
  } catch (err) {
28187
28570
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
28188
28571
  return;
28189
28572
  }
28190
- this.deps.emit("history:updated");
28573
+ this.deps.emit("history:updated", this.toEntry(record));
28191
28574
  }
28192
28575
  ts(timestamp) {
28193
28576
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -28244,93 +28627,9 @@ var History = class {
28244
28627
  }
28245
28628
  };
28246
28629
 
28247
- // modules/payments-v2/inventory/presentation.ts
28248
- function toToken(tokenId, snapshot, asset, registry, flags) {
28249
- const iconUrl = registry.getIconUrl(asset.coinId);
28250
- return {
28251
- id: tokenId,
28252
- coinId: asset.coinId,
28253
- symbol: registry.getSymbol(asset.coinId),
28254
- name: registry.getName(asset.coinId),
28255
- decimals: registry.getDecimals(asset.coinId),
28256
- ...iconUrl !== null ? { iconUrl } : {},
28257
- amount: asset.amount,
28258
- status: flags.transferring ? "transferring" : "confirmed",
28259
- createdAt: snapshot.createdAt,
28260
- updatedAt: snapshot.updatedAt,
28261
- lazy: true,
28262
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28263
- };
28264
- }
28265
- function aggregateAssets(entries, isTransferring, registry) {
28266
- const groups = /* @__PURE__ */ new Map();
28267
- for (const [tokenId, entry] of entries) {
28268
- const moving = isTransferring(tokenId);
28269
- for (const asset of entry.assets) {
28270
- let group = groups.get(asset.coinId);
28271
- if (!group) {
28272
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28273
- groups.set(asset.coinId, group);
28274
- }
28275
- if (moving) {
28276
- group.transferring += BigInt(asset.amount);
28277
- group.transferringCount += 1;
28278
- } else {
28279
- group.total += BigInt(asset.amount);
28280
- group.count += 1;
28281
- }
28282
- }
28283
- }
28284
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28285
- }
28286
- function toAsset(coinId, totals, registry) {
28287
- const iconUrl = registry.getIconUrl(coinId);
28288
- return {
28289
- coinId,
28290
- symbol: registry.getSymbol(coinId),
28291
- name: registry.getName(coinId),
28292
- decimals: registry.getDecimals(coinId),
28293
- ...iconUrl !== null ? { iconUrl } : {},
28294
- totalAmount: totals.total.toString(),
28295
- tokenCount: totals.count,
28296
- confirmedAmount: totals.total.toString(),
28297
- unconfirmedAmount: "0",
28298
- confirmedTokenCount: totals.count,
28299
- unconfirmedTokenCount: 0,
28300
- transferringTokenCount: totals.transferringCount,
28301
- transferringAmount: totals.transferring.toString(),
28302
- priceUsd: null,
28303
- priceEur: null,
28304
- change24h: null,
28305
- fiatValueUsd: null,
28306
- fiatValueEur: null
28307
- };
28308
- }
28309
- async function withPrices(raw, registry, price) {
28310
- try {
28311
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28312
- const quotes = await price.getPrices(names);
28313
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28314
- } catch {
28315
- return raw;
28316
- }
28317
- }
28318
- function priceOne(asset, quote) {
28319
- if (!quote) return asset;
28320
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28321
- return {
28322
- ...asset,
28323
- priceUsd: quote.priceUsd,
28324
- priceEur: quote.priceEur ?? null,
28325
- change24h: quote.change24h ?? null,
28326
- fiatValueUsd: human * quote.priceUsd,
28327
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28328
- };
28329
- }
28330
-
28331
28630
  // modules/payments-v2/inventory/InventoryView.ts
28332
- var SUSPECTED_KEY = "suspected-spent";
28333
- var KNOWN_SPENDS_KEY = "known-spends";
28631
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
28632
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
28334
28633
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
28335
28634
  function stateKey(tokenId, stateHash) {
28336
28635
  return `${tokenId}:${stateHash}`;
@@ -28634,19 +28933,24 @@ var Receive = class {
28634
28933
  const engine = deps.engine();
28635
28934
  const stored = [];
28636
28935
  const pending = [];
28637
- let epoch = "";
28936
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
28638
28937
  try {
28639
- epoch = deps.syncEpoch();
28640
28938
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
28641
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
28642
- for await (const entry of deps.delivery.incoming(since)) {
28643
- await this.processEntry(deps, engine, entry, pending, stored);
28644
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
28939
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
28940
+ for (let pass = 0; pass < 2; pass++) {
28941
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
28942
+ await this.processEntry(deps, engine, entry, pending, stored);
28943
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
28944
+ }
28945
+ await flushAcks(deps, pending, pageEpoch);
28946
+ const served = deps.delivery.incomingEpoch();
28947
+ if (basis === null || served === null || served === basis.syncEpoch) break;
28948
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
28949
+ basis = null;
28645
28950
  }
28646
- await flushAcks(deps, pending, epoch);
28647
28951
  } catch (err) {
28648
28952
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
28649
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
28953
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
28650
28954
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
28651
28955
  });
28652
28956
  }
@@ -28719,7 +29023,7 @@ async function announce(deps, entry, record) {
28719
29023
  receivedAt
28720
29024
  };
28721
29025
  }
28722
- async function flushAcks(deps, pending, epoch) {
29026
+ async function flushAcks(deps, pending, epochOf) {
28723
29027
  let lastAcked = null;
28724
29028
  try {
28725
29029
  while (pending.length > 0) {
@@ -28730,7 +29034,7 @@ async function flushAcks(deps, pending, epoch) {
28730
29034
  }
28731
29035
  } finally {
28732
29036
  if (lastAcked !== null) {
28733
- const record = { cursor: lastAcked, syncEpoch: epoch };
29037
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
28734
29038
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
28735
29039
  }
28736
29040
  }
@@ -28831,9 +29135,19 @@ var Requests = class {
28831
29135
  }
28832
29136
  });
28833
29137
  }
29138
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
28834
29139
  writeLink(requestId, transferId, committed) {
28835
29140
  return this.mutateJournal((m) => {
28836
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
29141
+ const prev = m.get(requestId);
29142
+ const same = prev !== void 0 && prev.transferId === transferId;
29143
+ const next = {
29144
+ requestId,
29145
+ transferId,
29146
+ committed: committed || same && prev.committed,
29147
+ createdAt: same ? prev.createdAt : this.now()
29148
+ };
29149
+ if (same && prev.committed === next.committed) return false;
29150
+ m.set(requestId, next);
28837
29151
  return true;
28838
29152
  });
28839
29153
  }
@@ -28986,23 +29300,46 @@ var Requests = class {
28986
29300
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
28987
29301
  if (transferId !== void 0) {
28988
29302
  const committed = error instanceof PartialSendConflictError;
28989
- await this.writeLink(id, transferId, committed);
28990
- this.setStatus(id, "settling");
29303
+ await this.settle(id, transferId, { committed, respond: false });
28991
29304
  } else {
28992
29305
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
28993
29306
  this.setStatus(id, "paid");
28994
29307
  }
28995
29308
  } else {
28996
- await this.clearLink(id);
28997
- this.setStatus(id, "pending");
29309
+ await this.revertPayable(id);
28998
29310
  }
28999
29311
  throw error;
29000
29312
  }
29001
- this.setStatus(id, "paid");
29002
- await this.respondPaid(id, result.id);
29003
- await this.clearLink(id);
29313
+ await this.settle(id, result.id, { committed: true, respond: true });
29004
29314
  return result;
29005
29315
  }
29316
+ /**
29317
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
29318
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
29319
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
29320
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
29321
+ * not a reload. Every path that binds a request to a transfer outcome funnels
29322
+ * through here — pay()'s clean success (respond now), pay()'s
29323
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
29324
+ * — so a failed respond always leaves the link + 'settling' and the next
29325
+ * reconcile pass (the committed-link override) retries the respond.
29326
+ */
29327
+ async settle(requestId, transferId, opts) {
29328
+ let journalDown = false;
29329
+ try {
29330
+ await this.writeLink(requestId, transferId, opts.committed);
29331
+ } catch (err) {
29332
+ if (!opts.respond) throw err;
29333
+ journalDown = true;
29334
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
29335
+ }
29336
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
29337
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
29338
+ return;
29339
+ }
29340
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
29341
+ this.setStatus(requestId, "paid");
29342
+ }
29006
29343
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
29007
29344
  async respondPaid(id, transferId) {
29008
29345
  try {
@@ -29038,32 +29375,32 @@ var Requests = class {
29038
29375
  let aborted = null;
29039
29376
  for (const [requestId, link] of [...this.journal]) {
29040
29377
  if (link.committed || resumed.has(link.transferId)) {
29041
- await this.resolvePaid(requestId, link.transferId);
29378
+ await this.resolvePaid(requestId, link);
29042
29379
  } else if (conflicted.has(link.transferId)) {
29043
29380
  await this.revertPayable(requestId);
29044
29381
  } else if (open.has(link.transferId)) {
29045
29382
  } else {
29046
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
29383
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
29047
29384
  }
29048
29385
  }
29049
29386
  }
29050
29387
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
29051
29388
  // a probe failure defers the link to the next session.
29052
- async reconcileUnaccounted(requestId, transferId, aborted) {
29389
+ async reconcileUnaccounted(requestId, link, aborted) {
29053
29390
  try {
29054
29391
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
29055
- if (aborted.has(transferId)) await this.revertPayable(requestId);
29056
- else await this.resolvePaid(requestId, transferId);
29392
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
29393
+ else await this.resolvePaid(requestId, link);
29057
29394
  } catch (err) {
29058
29395
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
29059
29396
  }
29060
29397
  return aborted;
29061
29398
  }
29062
- async resolvePaid(requestId, transferId) {
29063
- if (!await this.respondPaid(requestId, transferId)) return;
29064
- await this.clearLink(requestId);
29065
- this.setStatus(requestId, "paid");
29399
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
29400
+ resolvePaid(requestId, link) {
29401
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
29066
29402
  }
29403
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
29067
29404
  async revertPayable(requestId) {
29068
29405
  await this.clearLink(requestId);
29069
29406
  this.setStatus(requestId, "pending");
@@ -29387,11 +29724,12 @@ function composeFacadeParts(deps, hooks) {
29387
29724
  client: deps.client,
29388
29725
  fieldKey: deps.fieldKey,
29389
29726
  registry: deps.registry,
29390
- emit: (event) => deps.emit(event, {}),
29727
+ emit: (event, entry) => deps.emit(event, entry),
29391
29728
  ...deps.now !== void 0 ? { now: deps.now } : {},
29392
29729
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
29393
29730
  });
29394
29731
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
29732
+ const machineStores = createMachineStores(deps.kv);
29395
29733
  const heldStates = /* @__PURE__ */ new Map();
29396
29734
  return {
29397
29735
  ownPubkeyBytes,
@@ -29399,12 +29737,29 @@ function composeFacadeParts(deps, hooks) {
29399
29737
  ledger,
29400
29738
  queue,
29401
29739
  historyStore,
29402
- machineStores: createMachineStores(deps.kv),
29740
+ machineStores,
29403
29741
  machineDeps,
29404
29742
  machine: new TransferMachine(machineDeps),
29405
29743
  heldStates,
29406
29744
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
29407
- requests: buildRequests(deps, hooks)
29745
+ requests: buildRequests(deps, hooks),
29746
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
29747
+ };
29748
+ }
29749
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
29750
+ return {
29751
+ stores: machineStores,
29752
+ kv: deps.kv,
29753
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
29754
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
29755
+ decryptPayload: machineDeps.decryptPayload,
29756
+ fullRePull: () => view.onEpochReset(),
29757
+ attention: (transferId, code, detail) => {
29758
+ deps.emit(
29759
+ "transfer:attention",
29760
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
29761
+ );
29762
+ }
29408
29763
  };
29409
29764
  }
29410
29765
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -29426,11 +29781,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
29426
29781
  ownPubkey: hexToBytes4(deps.ownPubkey),
29427
29782
  emit: deps.emit,
29428
29783
  now: deps.now ?? Date.now,
29429
- recordHistory: async ({ transferId, payload }) => {
29784
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
29430
29785
  await historyStore.recordSent({
29431
29786
  transferId,
29432
29787
  coinId: payload.coinId,
29433
- amount: payload.amount,
29788
+ // §5.9: the SETTLED amount (machine-computed from the certified
29789
+ // recipient blobs), never payload.amount — the plan.
29790
+ amount: committedAmount,
29434
29791
  recipientPubkey: payload.recipient,
29435
29792
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
29436
29793
  });
@@ -29457,7 +29814,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
29457
29814
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
29458
29815
  );
29459
29816
  },
29460
- syncEpoch: deps.syncEpoch ?? (() => ""),
29817
+ syncEpoch: deps.syncEpoch,
29461
29818
  ...deps.now !== void 0 ? { now: deps.now } : {}
29462
29819
  });
29463
29820
  }
@@ -29510,8 +29867,24 @@ var PaymentsFacade = class {
29510
29867
  this.receiveLoop = parts.receiveLoop;
29511
29868
  this.heldStates = parts.heldStates;
29512
29869
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
29870
+ this.restoreDeps = parts.restoreDeps;
29513
29871
  this.requests = parts.requests;
29514
29872
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
29873
+ this.heartbeat = new ConvergenceHeartbeat({
29874
+ now: () => this.nowMs(),
29875
+ onTick: () => this.trackTail(this.runConvergencePass())
29876
+ });
29877
+ this.converger = new Converger({
29878
+ machineDeps: this.machineDeps,
29879
+ stores: this.machineStores,
29880
+ delivery: deps.deliveryPort,
29881
+ reconcile: (report) => this.requests.reconcile(report),
29882
+ refreshView: () => this.view.delta(),
29883
+ replayMints: () => this.replayMints(),
29884
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
29885
+ emit: deps.emit,
29886
+ now: () => this.nowMs()
29887
+ });
29515
29888
  }
29516
29889
  view;
29517
29890
  ledger;
@@ -29523,17 +29896,28 @@ var PaymentsFacade = class {
29523
29896
  receiveLoop;
29524
29897
  heldStates;
29525
29898
  ownPubkeyBytes;
29899
+ restoreDeps;
29526
29900
  requests;
29527
29901
  currentEngine = null;
29528
29902
  pendingOps = /* @__PURE__ */ new Set();
29529
29903
  unsubscribers = [];
29530
29904
  started = false;
29905
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
29906
+ heartbeat;
29907
+ converger;
29908
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
29909
+ resumeFlight = new SingleFlight();
29910
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
29911
+ passChain = new SerialChain();
29912
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
29913
+ activeMoneyOps = /* @__PURE__ */ new Set();
29531
29914
  // ── lifecycle ──────────────────────────────────────────────────────────────
29532
29915
  async start() {
29533
29916
  if (this.started) return;
29534
29917
  this.started = true;
29535
- await this.deps.session.start();
29918
+ this.heartbeat.start();
29536
29919
  this.unsubscribers.push(
29920
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
29537
29921
  this.deps.session.subscribeStream("mailbox", () => {
29538
29922
  this.trackTail(this.receiveLoop.drainOnce());
29539
29923
  }),
@@ -29544,20 +29928,12 @@ var PaymentsFacade = class {
29544
29928
  this.trackTail(this.requests.drainIncoming());
29545
29929
  })
29546
29930
  );
29547
- this.trackTail(this.runResume());
29548
- this.trackTail(
29549
- this.machineStores.deliveryJournal.replay({
29550
- delivery: this.deps.deliveryPort,
29551
- now: this.deps.now ?? Date.now,
29552
- attention: (transferId, code, detail) => {
29553
- this.deps.emit(
29554
- "transfer:attention",
29555
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
29556
- );
29557
- }
29558
- })
29559
- );
29560
- this.trackTail(this.replayMints());
29931
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
29932
+ if (status === "connected") this.heartbeat.recovered();
29933
+ });
29934
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
29935
+ await this.deps.session.start();
29936
+ this.trackTail(this.runConvergencePass());
29561
29937
  this.trackTail(this.seedHeldStates());
29562
29938
  await this.view.fullPull().catch(() => void 0);
29563
29939
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -29565,6 +29941,7 @@ var PaymentsFacade = class {
29565
29941
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
29566
29942
  async stop() {
29567
29943
  this.started = false;
29944
+ this.heartbeat.stop();
29568
29945
  this.receiveLoop.stop();
29569
29946
  for (const unsubscribe of this.unsubscribers) unsubscribe();
29570
29947
  this.unsubscribers = [];
@@ -29590,9 +29967,13 @@ var PaymentsFacade = class {
29590
29967
  history(page) {
29591
29968
  return this.historyStore.page(page ?? {});
29592
29969
  }
29970
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
29971
+ pendingTransfers() {
29972
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
29973
+ }
29593
29974
  // ── money movement ─────────────────────────────────────────────────────────
29594
29975
  send(request) {
29595
- return this.track(this.sendWithPolicy(request));
29976
+ return this.track(this.sendOutcome(request));
29596
29977
  }
29597
29978
  async receive() {
29598
29979
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -29602,9 +29983,32 @@ var PaymentsFacade = class {
29602
29983
  mint(coinId, amount) {
29603
29984
  return this.track(this.mintInner(coinId, amount));
29604
29985
  }
29986
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
29987
+ async resumeNow() {
29988
+ if (!this.started) return;
29989
+ await this.track(this.runConvergencePass());
29990
+ }
29991
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
29992
+ async handleEpochChange(_newEpoch) {
29993
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
29994
+ await this.passChain.enqueue(() => this.convergeBody());
29995
+ }
29996
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
29997
+ runConvergencePass() {
29998
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
29999
+ }
30000
+ async convergeBody() {
30001
+ const outcome = await this.converger.convergeOnce();
30002
+ const pending = await this.converger.pendingWork().catch(() => true);
30003
+ this.heartbeat.settle(outcome, pending);
30004
+ }
30005
+ nowMs() {
30006
+ return (this.deps.now ?? Date.now)();
30007
+ }
29605
30008
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
29606
- async sendWithPolicy(request) {
29607
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
30009
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
30010
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
30011
+ async sendOutcome(request) {
29608
30012
  const run = {
29609
30013
  amount: request.amount,
29610
30014
  delivered: [],
@@ -29612,12 +30016,36 @@ var PaymentsFacade = class {
29612
30016
  tokenTransfers: [],
29613
30017
  settledShortfalls: [],
29614
30018
  firstPartialId: void 0,
29615
- pendingAny: false
30019
+ pendingAny: false,
30020
+ lastTransferId: ""
29616
30021
  };
30022
+ try {
30023
+ return await this.sendWithPolicy(request, run);
30024
+ } catch (err) {
30025
+ this.emitCleanFailure(err, run.lastTransferId);
30026
+ throw err;
30027
+ }
30028
+ }
30029
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
30030
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
30031
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
30032
+ emitCleanFailure(err, transferId) {
30033
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
30034
+ const failed = {
30035
+ id: transferId,
30036
+ status: "failed",
30037
+ tokens: [],
30038
+ tokenTransfers: [],
30039
+ error: messageOf(err)
30040
+ };
30041
+ this.deps.emit("transfer:updated", failed);
30042
+ }
30043
+ async sendWithPolicy(request, run) {
30044
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
29617
30045
  for (let attempt = 0; ; attempt++) {
29618
30046
  let ctx;
29619
30047
  try {
29620
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
30048
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
29621
30049
  } catch (err) {
29622
30050
  throw this.partialize(err, run);
29623
30051
  }
@@ -29651,21 +30079,24 @@ var PaymentsFacade = class {
29651
30079
  return recipient;
29652
30080
  }
29653
30081
  async runAttempt(ctx) {
30082
+ this.activeMoneyOps.add(ctx.transferId);
29654
30083
  try {
29655
30084
  const { deliveryPending } = await this.machine.run(ctx.plan);
29656
30085
  this.settleSuccess(ctx, ctx.sourceIds);
29657
30086
  return { kind: "success", deliveryPending };
29658
30087
  } catch (err) {
29659
- return this.disposeFailedAttempt(ctx, err);
30088
+ return await this.disposeFailedAttempt(ctx, err);
30089
+ } finally {
30090
+ this.activeMoneyOps.delete(ctx.transferId);
29660
30091
  }
29661
30092
  }
29662
- /**
29663
- * Partial outcome (#677/#690): the shortfall is already durable (written by
29664
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
29665
- * the remainder under a NEW transferId — never the full amount.
29666
- */
30093
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
30094
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
29667
30095
  async consumePartial(ctx, disposition, run, attempt) {
29668
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
30096
+ if (disposition.deliveryPending) {
30097
+ run.pendingAny = true;
30098
+ this.heartbeat.arm();
30099
+ }
29669
30100
  const partial = disposition.entry;
29670
30101
  run.delivered.push(...partial.committedTokenIds);
29671
30102
  run.firstPartialId ??= partial.transferId;
@@ -29686,11 +30117,8 @@ var PaymentsFacade = class {
29686
30117
  run.amount = partial.remainingAmount;
29687
30118
  return null;
29688
30119
  }
29689
- /**
29690
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
29691
- * backstop still 'open' (committed>0) → converge via the same machine's
29692
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
29693
- */
30120
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
30121
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
29694
30122
  async disposeFailedAttempt(ctx, err) {
29695
30123
  if (isPossiblyCommittedSendOutcome(err)) {
29696
30124
  this.settleKeepOpen(ctx);
@@ -29739,8 +30167,9 @@ var PaymentsFacade = class {
29739
30167
  }
29740
30168
  return { kind: "rethrow", error: err };
29741
30169
  }
29742
- async planAndMaterialize(recipientPubkey, request) {
30170
+ async planAndMaterialize(recipientPubkey, request, run) {
29743
30171
  const transferId = this.newId();
30172
+ run.lastTransferId = transferId;
29744
30173
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
29745
30174
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
29746
30175
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -29823,6 +30252,7 @@ var PaymentsFacade = class {
29823
30252
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
29824
30253
  settleKeepOpen(ctx) {
29825
30254
  this.queue.clearExpectedChange(ctx.transferId);
30255
+ this.heartbeat.arm();
29826
30256
  }
29827
30257
  settlePartial(ctx, partial) {
29828
30258
  this.ledger.cancel(ctx.transferId);
@@ -29859,6 +30289,7 @@ var PaymentsFacade = class {
29859
30289
  for (const id of run.settledShortfalls.splice(0)) {
29860
30290
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
29861
30291
  }
30292
+ if (run.pendingAny) this.heartbeat.arm();
29862
30293
  const result = {
29863
30294
  id: transferId,
29864
30295
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -29870,11 +30301,7 @@ var PaymentsFacade = class {
29870
30301
  this.deps.emit("transfer:updated", result);
29871
30302
  return result;
29872
30303
  }
29873
- /**
29874
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
29875
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
29876
- * over the accumulated settled set — never bare, never a full-amount retry.
29877
- */
30304
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
29878
30305
  partialize(err, run) {
29879
30306
  if (run.delivered.length === 0) return err;
29880
30307
  return new PartialSendConflictError(
@@ -29903,21 +30330,29 @@ var PaymentsFacade = class {
29903
30330
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
29904
30331
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
29905
30332
  }
29906
- const engine = this.engine();
29907
30333
  const mintId = this.newId();
29908
- const params = this.mintParams(coinId, amount);
30334
+ this.activeMoneyOps.add(mintId);
30335
+ try {
30336
+ return await this.mintUnderJournal(mintId, coinId, amount);
30337
+ } finally {
30338
+ this.activeMoneyOps.delete(mintId);
30339
+ }
30340
+ }
30341
+ async mintUnderJournal(mintId, coinId, amount) {
30342
+ const engine = this.engine();
29909
30343
  const entry = {
29910
30344
  mintId,
29911
30345
  coinId,
29912
30346
  amount: amount.toString(),
29913
30347
  tokenId: "",
29914
- createdAt: (this.deps.now ?? Date.now)()
30348
+ createdAt: this.nowMs()
29915
30349
  };
29916
30350
  await this.machineStores.mintJournal.upsert(entry);
29917
30351
  let token;
29918
30352
  try {
29919
- token = await engine.mint(params, { transferId: mintId });
30353
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
29920
30354
  } catch (err) {
30355
+ this.heartbeat.arm();
29921
30356
  return { success: false, error: messageOf(err) };
29922
30357
  }
29923
30358
  if (token.blob.tokenId !== entry.tokenId) {
@@ -29926,6 +30361,7 @@ var PaymentsFacade = class {
29926
30361
  try {
29927
30362
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
29928
30363
  } catch (err) {
30364
+ this.heartbeat.arm();
29929
30365
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
29930
30366
  }
29931
30367
  return { success: true, tokenId: token.blob.tokenId };
@@ -29948,18 +30384,22 @@ var PaymentsFacade = class {
29948
30384
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
29949
30385
  this.trackTail(this.view.delta());
29950
30386
  }
30387
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
29951
30388
  async replayMints() {
30389
+ let resolved = 0;
29952
30390
  for (const entry of await this.machineStores.mintJournal.list()) {
30391
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
29953
30392
  try {
29954
- await this.replayMint(entry);
30393
+ if (await this.replayMint(entry)) resolved += 1;
29955
30394
  } catch {
29956
30395
  }
29957
30396
  }
30397
+ return resolved;
29958
30398
  }
29959
30399
  async replayMint(entry) {
29960
30400
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
29961
30401
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
29962
- return;
30402
+ return true;
29963
30403
  }
29964
30404
  const engine = this.engine();
29965
30405
  if (!supportsDeterministicMint(engine)) {
@@ -29967,7 +30407,7 @@ var PaymentsFacade = class {
29967
30407
  transferId: entry.mintId,
29968
30408
  code: ATTENTION_MINT_UNRESOLVED
29969
30409
  });
29970
- return;
30410
+ return false;
29971
30411
  }
29972
30412
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
29973
30413
  transferId: entry.mintId
@@ -29976,6 +30416,7 @@ var PaymentsFacade = class {
29976
30416
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
29977
30417
  }
29978
30418
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
30419
+ return true;
29979
30420
  }
29980
30421
  mintParams(coinId, amount) {
29981
30422
  return {
@@ -30001,17 +30442,6 @@ var PaymentsFacade = class {
30001
30442
  newId() {
30002
30443
  return (this.deps.newId ?? randomUUID)();
30003
30444
  }
30004
- async runResume() {
30005
- const report = await resumeAll(this.machineDeps);
30006
- await this.requests.reconcile({
30007
- resumed: report.resumed,
30008
- conflicted: report.conflicted,
30009
- open: report.failed
30010
- });
30011
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
30012
- await this.view.delta().catch(() => void 0);
30013
- }
30014
- }
30015
30445
  async seedHeldStates() {
30016
30446
  let page = await this.deps.storagePort.listInventory();
30017
30447
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -30023,22 +30453,14 @@ var PaymentsFacade = class {
30023
30453
  }
30024
30454
  }
30025
30455
  toUiToken(tokenId, coinId, amount) {
30026
- const registry = this.deps.registry;
30027
- const iconUrl = registry.getIconUrl(coinId);
30028
- const now = (this.deps.now ?? Date.now)();
30029
- return {
30030
- id: tokenId,
30031
- coinId,
30032
- symbol: registry.getSymbol(coinId),
30033
- name: registry.getName(coinId),
30034
- decimals: registry.getDecimals(coinId),
30035
- ...iconUrl !== null ? { iconUrl } : {},
30036
- amount: amount.toString(),
30037
- status: "transferring",
30038
- createdAt: now,
30039
- updatedAt: now,
30040
- lazy: true
30041
- };
30456
+ const now = this.nowMs();
30457
+ return toToken(
30458
+ tokenId,
30459
+ { assets: [], createdAt: now, updatedAt: now },
30460
+ { coinId, amount: amount.toString() },
30461
+ this.deps.registry,
30462
+ { transferring: true, suspectedSpent: false }
30463
+ );
30042
30464
  }
30043
30465
  track(op) {
30044
30466
  const settled = op.then(
@@ -30053,27 +30475,6 @@ var PaymentsFacade = class {
30053
30475
  this.track(op).catch(() => void 0);
30054
30476
  }
30055
30477
  };
30056
- function buildPayload(recipientPubkey, request, spend, spentStates) {
30057
- return {
30058
- v: 2,
30059
- recipient: recipientPubkey,
30060
- coinId: request.coinId,
30061
- amount: request.amount,
30062
- ...request.memo !== void 0 ? { memo: request.memo } : {},
30063
- direct: [...spend.plan.direct],
30064
- ...spend.plan.split !== void 0 ? {
30065
- split: {
30066
- tokenId: spend.plan.split.tokenId,
30067
- splitAmount: spend.plan.split.splitAmount.toString(),
30068
- remainderAmount: spend.plan.split.remainderAmount.toString()
30069
- }
30070
- } : {},
30071
- spentStates
30072
- };
30073
- }
30074
- function messageOf(err) {
30075
- return err instanceof Error ? err.message : String(err);
30076
- }
30077
30478
 
30078
30479
  // impl/wallet-api-v2/http.ts
30079
30480
  var WalletApiHttpError = class extends Error {
@@ -30419,7 +30820,9 @@ var WalletApiSession = class {
30419
30820
  epochLatch = null;
30420
30821
  reseedChain = Promise.resolve();
30421
30822
  reseedsPending = 0;
30823
+ epochHandlers = /* @__PURE__ */ new Set();
30422
30824
  handlers = /* @__PURE__ */ new Map();
30825
+ statusHandlers = /* @__PURE__ */ new Set();
30423
30826
  socket = null;
30424
30827
  started = false;
30425
30828
  stopped = false;
@@ -30520,6 +30923,18 @@ var WalletApiSession = class {
30520
30923
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
30521
30924
  }
30522
30925
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
30926
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
30927
+ currentEpoch() {
30928
+ return this.epochLatch ?? "";
30929
+ }
30930
+ /**
30931
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
30932
+ * the reseed chain, BEFORE any stream nudge resumes.
30933
+ */
30934
+ subscribeEpochChange(handler) {
30935
+ this.epochHandlers.add(handler);
30936
+ return () => this.epochHandlers.delete(handler);
30937
+ }
30523
30938
  noteEpoch(epoch) {
30524
30939
  if (this.epochLatch === epoch) return;
30525
30940
  const previous = this.epochLatch;
@@ -30533,7 +30948,7 @@ var WalletApiSession = class {
30533
30948
  }
30534
30949
  async runReseed(epoch, previous) {
30535
30950
  try {
30536
- await this.deps.onEpochChange(epoch);
30951
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
30537
30952
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
30538
30953
  } catch {
30539
30954
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -30570,10 +30985,16 @@ var WalletApiSession = class {
30570
30985
  status() {
30571
30986
  return this.lastStatus ?? "offline";
30572
30987
  }
30988
+ /** Transition feed off the existing emitStatus point (FacadeSession.subscribeStatus). */
30989
+ subscribeStatus(handler) {
30990
+ this.statusHandlers.add(handler);
30991
+ return () => this.statusHandlers.delete(handler);
30992
+ }
30573
30993
  emitStatus(status) {
30574
30994
  if (this.stopped || this.lastStatus === status) return;
30575
30995
  this.lastStatus = status;
30576
30996
  this.deps.emitStatus(status);
30997
+ for (const handler of this.statusHandlers) handler(status);
30577
30998
  }
30578
30999
  async connect() {
30579
31000
  if (this.stopped) return;
@@ -30789,6 +31210,7 @@ var WalletApiDeliveryPort = class {
30789
31210
  wake;
30790
31211
  deriveFn;
30791
31212
  seenChain = new SerialChain();
31213
+ lastIncomingEpoch = null;
30792
31214
  constructor(config) {
30793
31215
  this.client = config.client;
30794
31216
  this.kv = config.kv;
@@ -30883,6 +31305,7 @@ var WalletApiDeliveryPort = class {
30883
31305
  const seen = await this.readSeen();
30884
31306
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
30885
31307
  for (; ; ) {
31308
+ this.lastIncomingEpoch = String(page.syncEpoch);
30886
31309
  for (const entry of page.entries) {
30887
31310
  if (entry.status !== "unclaimed") continue;
30888
31311
  if (seen.has(entry.entryId)) continue;
@@ -30892,6 +31315,10 @@ var WalletApiDeliveryPort = class {
30892
31315
  page = await this.client.listMailbox(page.cursor);
30893
31316
  }
30894
31317
  }
31318
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
31319
+ incomingEpoch() {
31320
+ return this.lastIncomingEpoch;
31321
+ }
30895
31322
  toIncoming(entry) {
30896
31323
  const bundle = this.openEnvelope(entry);
30897
31324
  return {
@@ -30969,7 +31396,7 @@ var WalletApiSplitCheckpointStore = class {
30969
31396
  return utf8(`${transferId}:${String(opIndex)}`);
30970
31397
  }
30971
31398
  async put(transferId, opIndex, bytes) {
30972
- const { client, kv, fieldKey, signProgress } = this.config;
31399
+ const { kv, fieldKey } = this.config;
30973
31400
  const aad = this.aad(transferId, opIndex);
30974
31401
  const cacheKey = this.cacheKey(transferId, opIndex);
30975
31402
  let envelope = await kv.get(cacheKey);
@@ -30977,10 +31404,21 @@ var WalletApiSplitCheckpointStore = class {
30977
31404
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
30978
31405
  await kv.set(cacheKey, envelope);
30979
31406
  }
30980
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
30981
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
31407
+ const record = await this.post(transferId, opIndex, envelope);
30982
31408
  return this.open(record.payload, aad);
30983
31409
  }
31410
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
31411
+ async reseedCheckpoint(transferId, opIndex) {
31412
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
31413
+ if (envelope === null) return false;
31414
+ await this.post(transferId, opIndex, envelope);
31415
+ return true;
31416
+ }
31417
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
31418
+ async post(transferId, opIndex, envelope) {
31419
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
31420
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
31421
+ }
30984
31422
  async get(transferId, opIndex) {
30985
31423
  const records = await this.config.client.getProgress(transferId);
30986
31424
  const record = records.find((r) => r.opIndex === opIndex);
@@ -31003,7 +31441,7 @@ var WalletApiSplitCheckpointStore = class {
31003
31441
  };
31004
31442
 
31005
31443
  // core/payments-v2-wiring.ts
31006
- function resolvePaymentsV2Composition(walletApi) {
31444
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
31007
31445
  const source = walletApi;
31008
31446
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
31009
31447
  if (!source || network === null) {
@@ -31012,6 +31450,18 @@ function resolvePaymentsV2Composition(walletApi) {
31012
31450
  "INVALID_CONFIG"
31013
31451
  );
31014
31452
  }
31453
+ if (!(network in NETWORKS)) {
31454
+ throw new SphereError(
31455
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
31456
+ "INVALID_CONFIG"
31457
+ );
31458
+ }
31459
+ if (network !== sphereNetwork) {
31460
+ throw new SphereError(
31461
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
31462
+ "INVALID_CONFIG"
31463
+ );
31464
+ }
31015
31465
  if (typeof source.paymentsV2Transport === "function") {
31016
31466
  return { network, factory: source.paymentsV2Transport.bind(source) };
31017
31467
  }
@@ -31077,8 +31527,7 @@ function defaultPaymentsV2Transport(config) {
31077
31527
  deviceId: `${config.deviceId}:pv2`,
31078
31528
  kv: args.kv,
31079
31529
  webSocketFactory,
31080
- emitStatus: args.emitStatus,
31081
- onEpochChange: args.onEpochChange
31530
+ emitStatus: args.emitStatus
31082
31531
  });
31083
31532
  return { session, client: authedWireClient(session, client) };
31084
31533
  };
@@ -31148,8 +31597,7 @@ function composePaymentsV2(spec) {
31148
31597
  identity: identitySlice,
31149
31598
  network,
31150
31599
  kv,
31151
- emitStatus: (status) => host.emit("connection:status", { status }),
31152
- onEpochChange: async () => void 0
31600
+ emitStatus: (status) => host.emit("connection:status", { status })
31153
31601
  });
31154
31602
  const deps = {
31155
31603
  session: transport.session,
@@ -31179,7 +31627,10 @@ function composePaymentsV2(spec) {
31179
31627
  network,
31180
31628
  ownPubkey: identity.chainPubkey,
31181
31629
  ownNametag: host.nametag,
31182
- requestMemo: requestMemoCodec(identity.privateKey)
31630
+ requestMemo: requestMemoCodec(identity.privateKey),
31631
+ // §5.1: the session is the epoch authority — the facade registers its
31632
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
31633
+ syncEpoch: () => transport.session.currentEpoch()
31183
31634
  };
31184
31635
  return new PaymentsFacade(deps);
31185
31636
  }
@@ -31509,18 +31960,20 @@ var Sphere = class _Sphere {
31509
31960
  }
31510
31961
  /**
31511
31962
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
31512
- * fail-closed at init, never a silently-degraded composition.
31963
+ * fail-closed at init, never a silently-degraded composition. Returns the
31964
+ * resolved composition (null = flag off); #728: resolving here also asserts
31965
+ * the single-network invariant (walletApi.network known + equal to the
31966
+ * Sphere network) before any session/KV/provider exists.
31513
31967
  */
31514
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
31515
- if (flag !== true) return false;
31968
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
31969
+ if (flag !== true) return null;
31516
31970
  if (accountingConfig || swapConfig) {
31517
31971
  throw new SphereError(
31518
31972
  "paymentsV2 is incompatible with the accounting/swap modules (both are built on the legacy PaymentsModule and are deleted with the P11 flip) \u2014 disable them or the flag.",
31519
31973
  "INVALID_CONFIG"
31520
31974
  );
31521
31975
  }
31522
- resolvePaymentsV2Composition(walletApi);
31523
- return true;
31976
+ return resolvePaymentsV2Composition(walletApi, network);
31524
31977
  }
31525
31978
  /**
31526
31979
  * Configure TokenRegistry in the main bundle context.
@@ -31575,12 +32028,14 @@ var Sphere = class _Sphere {
31575
32028
  options.walletApi
31576
32029
  );
31577
32030
  sphere._verification = options.verification;
31578
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32031
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31579
32032
  options.paymentsV2,
31580
32033
  accountingConfig,
31581
32034
  swapConfig,
31582
- options.walletApi
32035
+ options.walletApi,
32036
+ options.network
31583
32037
  );
32038
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31584
32039
  sphere._password = options.password ?? null;
31585
32040
  sphere._passwordProtected = sphere._password !== null;
31586
32041
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -31647,12 +32102,14 @@ var Sphere = class _Sphere {
31647
32102
  options.walletApi
31648
32103
  );
31649
32104
  sphere._verification = options.verification;
31650
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32105
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31651
32106
  options.paymentsV2,
31652
32107
  accountingConfig,
31653
32108
  swapConfig,
31654
- options.walletApi
32109
+ options.walletApi,
32110
+ options.network
31655
32111
  );
32112
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31656
32113
  sphere._password = options.password ?? null;
31657
32114
  sphere._passwordProtected = sphere._password !== null;
31658
32115
  if (!options.storage.isConnected()) {
@@ -31726,12 +32183,14 @@ var Sphere = class _Sphere {
31726
32183
  options.walletApi
31727
32184
  );
31728
32185
  sphere._verification = options.verification;
31729
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32186
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31730
32187
  options.paymentsV2,
31731
32188
  accountingConfig,
31732
32189
  swapConfig,
31733
- options.walletApi
32190
+ options.walletApi,
32191
+ options.network
31734
32192
  );
32193
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31735
32194
  sphere._password = options.password ?? null;
31736
32195
  sphere._passwordProtected = sphere._password !== null;
31737
32196
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -34389,7 +34848,9 @@ var Sphere = class _Sphere {
34389
34848
  * setOracleApiKey rebuild is what future operations snapshot.
34390
34849
  */
34391
34850
  async startPaymentsV2Inner(index, identity) {
34392
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
34851
+ if (this._paymentsV2Composition === null) {
34852
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
34853
+ }
34393
34854
  if (!this._addressModules.get(index)?.tokenEngine) {
34394
34855
  throw new SphereError(
34395
34856
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",