@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.cjs CHANGED
@@ -12244,14 +12244,14 @@ var IntentResume = class {
12244
12244
  }
12245
12245
  let changeOutput = null;
12246
12246
  if (payload.split) {
12247
- const splitOpIndex = payload.direct.length;
12247
+ const splitOpIndex2 = payload.direct.length;
12248
12248
  let splitDelivered = false;
12249
12249
  {
12250
12250
  const source = await engine.decodeToken(await provider.getToken(payload.split.tokenId));
12251
12251
  const outcome = await this.host.certifyOperation(
12252
12252
  {
12253
12253
  kind: "split",
12254
- opIndex: splitOpIndex,
12254
+ opIndex: splitOpIndex2,
12255
12255
  uiTokenId: `v2_${payload.split.tokenId}`,
12256
12256
  genesisId: payload.split.tokenId,
12257
12257
  sourceSdkData: "",
@@ -22358,6 +22358,7 @@ var AccountingModule = class _AccountingModule {
22358
22358
  const unsubHistory = deps.on(
22359
22359
  "history:updated",
22360
22360
  (entry) => {
22361
+ if (!("dedupKey" in entry)) return;
22361
22362
  this._handleHistoryUpdated(entry).catch((err) => {
22362
22363
  logger.warn(LOG_TAG2, "Error handling history:updated event:", err);
22363
22364
  });
@@ -27620,7 +27621,10 @@ var STORE_KEYS = {
27620
27621
  shortfalls: "shortfalls",
27621
27622
  settlingLinks: "settling",
27622
27623
  streamCursor: (s) => `cursor:${s}`,
27623
- epochLatch: "epoch-latch"
27624
+ epochLatch: "epoch-latch",
27625
+ // §5.2 InventoryView durable overlays (#625/#679).
27626
+ suspectedSpent: "suspected-spent",
27627
+ knownSpends: "known-spends"
27624
27628
  };
27625
27629
 
27626
27630
  // modules/payments-v2/machine/journal.ts
@@ -27643,6 +27647,10 @@ function isValidationReject(err) {
27643
27647
  if (e === null) return false;
27644
27648
  return e.status === 422 || e.statusCode === 422 || e.code === "VALIDATION" || e.code === "VALIDATION_FAILED";
27645
27649
  }
27650
+ function retryAfterMsOf(err) {
27651
+ const retryAfter = err !== null && typeof err === "object" ? err.retryAfter : void 0;
27652
+ return typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null;
27653
+ }
27646
27654
  var chainRegistry = /* @__PURE__ */ new WeakMap();
27647
27655
  var replayRegistry = /* @__PURE__ */ new WeakMap();
27648
27656
  function registryFor(registry, kv, storeKey, make) {
@@ -27866,7 +27874,7 @@ var TransferMachine = class {
27866
27874
  }
27867
27875
  if (cls === null) {
27868
27876
  await this.applyCommitted(engine, plan.transferId, committed);
27869
- this.closeTail(plan.transferId, plan.payload);
27877
+ this.closeTail(engine, plan.transferId, plan.payload, committed);
27870
27878
  return { committed, deliveryPending };
27871
27879
  }
27872
27880
  if (cls === "conflict" && committed.length > 0) {
@@ -27892,10 +27900,10 @@ var TransferMachine = class {
27892
27900
  }
27893
27901
  await this.applyCommitted(engine, r.transferId, committed);
27894
27902
  if (conflicts.length > 0) {
27895
- return { deliveryPending, partial: await this.settlePartial(r, committed, conflicts) };
27903
+ return { deliveryPending, partial: await this.settlePartial(engine, r, committed, conflicts) };
27896
27904
  }
27897
27905
  await this.completeIntent(r.transferId, r.payload);
27898
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
27906
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
27899
27907
  return { deliveryPending, partial: null };
27900
27908
  }
27901
27909
  // ── shared transitions ─────────────────────────────────────────────────────
@@ -28077,7 +28085,7 @@ var TransferMachine = class {
28077
28085
  }
28078
28086
  return added;
28079
28087
  }
28080
- async settlePartial(r, committed, conflicts) {
28088
+ async settlePartial(engine, r, committed, conflicts) {
28081
28089
  const undelivered = conflicts.reduce((sum, c) => sum + c.amount, 0n);
28082
28090
  const entry = {
28083
28091
  transferId: r.transferId,
@@ -28089,26 +28097,27 @@ var TransferMachine = class {
28089
28097
  };
28090
28098
  await this.stores.shortfalls.upsert(entry);
28091
28099
  await this.completeIntent(r.transferId, r.payload);
28092
- await this.finishBookkeeping(r.transferId, r.payload, "resumed");
28100
+ await this.finishBookkeeping(engine, r.transferId, r.payload, "resumed", committed);
28093
28101
  return entry;
28094
28102
  }
28095
- closeTail(transferId, payload) {
28103
+ closeTail(engine, transferId, payload, committed) {
28096
28104
  void (async () => {
28097
28105
  try {
28098
28106
  await this.completeIntent(transferId, payload);
28099
28107
  } catch {
28100
28108
  return;
28101
28109
  }
28102
- await this.finishBookkeeping(transferId, payload, "sent");
28110
+ await this.finishBookkeeping(engine, transferId, payload, "sent", committed);
28103
28111
  })();
28104
28112
  }
28105
28113
  async completeIntent(transferId, payload) {
28106
28114
  const signature = payload.split !== void 0 ? await this.deps.signComplete(transferId) : void 0;
28107
28115
  await this.deps.intents.complete(transferId, signature);
28108
28116
  }
28109
- async finishBookkeeping(transferId, payload, phase) {
28117
+ async finishBookkeeping(engine, transferId, payload, phase, committed) {
28110
28118
  try {
28111
- await this.deps.recordHistory?.({ transferId, payload, phase });
28119
+ const committedAmount = await this.settledAmount(engine, payload.coinId, committed);
28120
+ await this.deps.recordHistory?.({ transferId, payload, phase, committedAmount });
28112
28121
  } catch {
28113
28122
  }
28114
28123
  try {
@@ -28116,6 +28125,26 @@ var TransferMachine = class {
28116
28125
  } catch {
28117
28126
  }
28118
28127
  }
28128
+ /**
28129
+ * §5.9: SENT history records what SETTLED, never the plan — the value of the
28130
+ * certified recipient blobs (summarize()'s committed set is the only source;
28131
+ * a journal-rehydrated resume leg has no source token, but every committed
28132
+ * leg carries its recipient blob, so the outcome-derived rule is uniform).
28133
+ */
28134
+ async settledAmount(engine, coinId, committed) {
28135
+ let total = 0n;
28136
+ for (const outcome of committed) {
28137
+ if (outcome.recipientBlob === void 0) continue;
28138
+ const token = await engine.decodeToken({
28139
+ v: TOKEN_BLOB_VERSION,
28140
+ network: 0,
28141
+ tokenId: "",
28142
+ token: outcome.recipientBlob
28143
+ });
28144
+ total += engine.balanceOf(token, coinId);
28145
+ }
28146
+ return total.toString();
28147
+ }
28119
28148
  async disposeOnFailure(transferId, committed, cls) {
28120
28149
  if (committed.length > 0 || cls === "keep-open") return;
28121
28150
  try {
@@ -28140,7 +28169,7 @@ function conflictAmount(engine, payload, op, source) {
28140
28169
  }
28141
28170
 
28142
28171
  // modules/payments-v2/machine/resume.ts
28143
- async function resumeAll(deps) {
28172
+ async function resumeAll(deps, options = {}) {
28144
28173
  const report = { resumed: [], conflicted: [], failed: [] };
28145
28174
  const stores = createMachineStores(deps.kv);
28146
28175
  const open = await deps.intents.listOpen();
@@ -28150,7 +28179,12 @@ async function resumeAll(deps) {
28150
28179
  } catch {
28151
28180
  return report;
28152
28181
  }
28153
- await dropStaleLocalAborts(stores, locals, new Set(open.map((i) => i.transferId)));
28182
+ await dropStaleBackstops(
28183
+ stores,
28184
+ locals,
28185
+ new Set(open.map((i) => i.transferId)),
28186
+ options.isLocallyActive ?? (() => false)
28187
+ );
28154
28188
  const jobs = await gateAndDecode(deps, stores, open, locals, report);
28155
28189
  const ctx = {
28156
28190
  deps,
@@ -28163,11 +28197,16 @@ async function resumeAll(deps) {
28163
28197
  }
28164
28198
  return report;
28165
28199
  }
28166
- async function dropStaleLocalAborts(stores, locals, openIds) {
28200
+ async function dropStaleBackstops(stores, locals, openIds, isLocallyActive) {
28167
28201
  for (const entry of locals.values()) {
28168
- if (entry.disposition !== "open" && !openIds.has(entry.transferId)) {
28202
+ if (openIds.has(entry.transferId)) continue;
28203
+ if (entry.disposition !== "open") {
28169
28204
  await stores.backstop.removeByKey(entry.transferId);
28205
+ continue;
28170
28206
  }
28207
+ if (isLocallyActive(entry.transferId)) continue;
28208
+ if ((await stores.deliveryJournal.byTransfer(entry.transferId)).size > 0) continue;
28209
+ await stores.backstop.removeByKey(entry.transferId);
28171
28210
  }
28172
28211
  }
28173
28212
  async function gateAndDecode(deps, stores, open, locals, report) {
@@ -28280,6 +28319,349 @@ function validatePayload(raw) {
28280
28319
  return p;
28281
28320
  }
28282
28321
 
28322
+ // modules/payments-v2/convergence.ts
28323
+ var HEARTBEAT_SEED_MS = 5e3;
28324
+ var HEARTBEAT_CAP_MS = 12e4;
28325
+ var ConvergenceHeartbeat = class {
28326
+ constructor(deps) {
28327
+ this.deps = deps;
28328
+ }
28329
+ timer = null;
28330
+ delayMs = HEARTBEAT_SEED_MS;
28331
+ notBefore = 0;
28332
+ running = false;
28333
+ start() {
28334
+ this.running = true;
28335
+ this.delayMs = HEARTBEAT_SEED_MS;
28336
+ this.notBefore = 0;
28337
+ }
28338
+ /** §7 quiescence unchanged: a mid-backoff stop() cancels cleanly. */
28339
+ stop() {
28340
+ this.running = false;
28341
+ this.clearTimer();
28342
+ }
28343
+ /** Reset to seed on progress, ×2 to the cap otherwise; idle stops rescheduling. */
28344
+ settle(outcome, pending) {
28345
+ if (outcome.retryAfterMs !== null) {
28346
+ this.notBefore = Math.max(this.notBefore, this.deps.now() + outcome.retryAfterMs);
28347
+ }
28348
+ if (!pending) {
28349
+ this.delayMs = HEARTBEAT_SEED_MS;
28350
+ return;
28351
+ }
28352
+ const delay = outcome.progress ? HEARTBEAT_SEED_MS : this.delayMs;
28353
+ this.delayMs = Math.min(delay * 2, HEARTBEAT_CAP_MS);
28354
+ this.schedule(delay);
28355
+ }
28356
+ /** New pending work (keep-open park / journaled delivery / mint hold): prompt retry. */
28357
+ arm() {
28358
+ if (!this.running) return;
28359
+ this.delayMs = Math.min(HEARTBEAT_SEED_MS * 2, HEARTBEAT_CAP_MS);
28360
+ this.schedule(HEARTBEAT_SEED_MS);
28361
+ }
28362
+ /** connection:status recovery: the backoff resets; a pending tick re-fires at the seed. */
28363
+ recovered() {
28364
+ this.delayMs = HEARTBEAT_SEED_MS;
28365
+ if (this.timer !== null) this.arm();
28366
+ }
28367
+ schedule(delayMs) {
28368
+ this.clearTimer();
28369
+ if (!this.running) return;
28370
+ const floored = Math.max(delayMs, this.notBefore - this.deps.now());
28371
+ this.timer = setTimeout(() => {
28372
+ this.timer = null;
28373
+ this.deps.onTick();
28374
+ }, floored);
28375
+ }
28376
+ clearTimer() {
28377
+ if (this.timer !== null) clearTimeout(this.timer);
28378
+ this.timer = null;
28379
+ }
28380
+ };
28381
+ var Converger = class {
28382
+ constructor(deps) {
28383
+ this.deps = deps;
28384
+ }
28385
+ /** Resume open intents, then drain the delivery + mint journals (all idempotent). */
28386
+ async convergeOnce() {
28387
+ let progress = false;
28388
+ let retryAfterMs = null;
28389
+ try {
28390
+ progress = await this.resumePass();
28391
+ } catch (err) {
28392
+ retryAfterMs = retryAfterMsOf(err);
28393
+ }
28394
+ try {
28395
+ const stats = await this.deps.stores.deliveryJournal.replay(this.replayDeps());
28396
+ progress = progress || stats.delivered > 0;
28397
+ } catch (err) {
28398
+ retryAfterMs ??= retryAfterMsOf(err);
28399
+ }
28400
+ try {
28401
+ progress = await this.deps.replayMints() > 0 || progress;
28402
+ } catch (err) {
28403
+ retryAfterMs ??= retryAfterMsOf(err);
28404
+ }
28405
+ return { progress, retryAfterMs };
28406
+ }
28407
+ /** §4 heartbeat predicate — read fresh from the §6 stores, never cached. */
28408
+ async pendingWork() {
28409
+ const [backstop, journal, mints] = await Promise.all([
28410
+ this.deps.stores.backstop.list(),
28411
+ this.deps.stores.deliveryJournal.list(),
28412
+ this.deps.stores.mintJournal.list()
28413
+ ]);
28414
+ return backstop.length > 0 || journal.some((e) => e.undeliverable !== true) || mints.length > 0;
28415
+ }
28416
+ async resumePass() {
28417
+ const report = await resumeAll(this.filteredMachineDeps(), {
28418
+ isLocallyActive: (id) => this.deps.isActiveOp(id)
28419
+ });
28420
+ const progress = report.resumed.length > 0 || report.conflicted.length > 0;
28421
+ await this.deps.reconcile({
28422
+ resumed: report.resumed,
28423
+ conflicted: report.conflicted,
28424
+ open: report.failed
28425
+ });
28426
+ if (progress) await this.deps.refreshView().catch(() => void 0);
28427
+ return progress;
28428
+ }
28429
+ filteredMachineDeps() {
28430
+ const intents = this.deps.machineDeps.intents;
28431
+ return {
28432
+ ...this.deps.machineDeps,
28433
+ intents: {
28434
+ ...intents,
28435
+ listOpen: async () => (await intents.listOpen()).filter((intent) => !this.deps.isActiveOp(intent.transferId))
28436
+ }
28437
+ };
28438
+ }
28439
+ replayDeps() {
28440
+ return {
28441
+ delivery: this.deps.delivery,
28442
+ now: () => this.deps.now(),
28443
+ attention: (transferId, code, detail) => {
28444
+ this.deps.emit(
28445
+ "transfer:attention",
28446
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
28447
+ );
28448
+ }
28449
+ };
28450
+ }
28451
+ };
28452
+ async function derivePendingTransfers(stores, decryptPayload) {
28453
+ const [backstop, journal, shortfalls] = await Promise.all([
28454
+ stores.backstop.list(),
28455
+ stores.deliveryJournal.list(),
28456
+ stores.shortfalls.list()
28457
+ ]);
28458
+ const byTransfer = groupJournal(journal);
28459
+ const out = [];
28460
+ const seen = /* @__PURE__ */ new Set();
28461
+ for (const entry of backstop) {
28462
+ if (entry.disposition !== "open") continue;
28463
+ seen.add(entry.transferId);
28464
+ out.push(await openRow(entry, byTransfer.get(entry.transferId) ?? [], decryptPayload));
28465
+ }
28466
+ for (const [transferId, entries] of byTransfer) {
28467
+ if (seen.has(transferId)) continue;
28468
+ seen.add(transferId);
28469
+ out.push(journalOnlyRow(transferId, entries));
28470
+ }
28471
+ for (const entry of shortfalls) {
28472
+ if (!seen.has(entry.transferId)) out.push(shortfallRow(entry));
28473
+ }
28474
+ return out;
28475
+ }
28476
+ function groupJournal(journal) {
28477
+ const byTransfer = /* @__PURE__ */ new Map();
28478
+ for (const entry of journal) {
28479
+ const mine = byTransfer.get(entry.transferId);
28480
+ if (mine === void 0) byTransfer.set(entry.transferId, [entry]);
28481
+ else mine.push(entry);
28482
+ }
28483
+ return byTransfer;
28484
+ }
28485
+ async function openRow(entry, journal, decryptPayload) {
28486
+ let payload = null;
28487
+ try {
28488
+ const raw = await decryptPayload(entry.payloadEnvelope);
28489
+ if (raw !== null && typeof raw === "object") payload = raw;
28490
+ } catch {
28491
+ payload = null;
28492
+ }
28493
+ const total = Array.isArray(payload?.direct) ? payload.direct.length + (payload.split !== void 0 ? 1 : 0) : 0;
28494
+ const certified = distinctOpCount(journal);
28495
+ return {
28496
+ transferId: entry.transferId,
28497
+ kind: "open",
28498
+ recipient: typeof payload?.recipient === "string" ? payload.recipient : "",
28499
+ coinId: typeof payload?.coinId === "string" ? payload.coinId : "",
28500
+ amount: typeof payload?.amount === "string" ? payload.amount : "",
28501
+ legs: { certified, total: Math.max(total, certified) },
28502
+ deliveryPending: journal.length > 0,
28503
+ createdAt: entry.createdAt
28504
+ };
28505
+ }
28506
+ function journalOnlyRow(transferId, entries) {
28507
+ const certified = distinctOpCount(entries);
28508
+ return {
28509
+ transferId,
28510
+ kind: "open",
28511
+ recipient: entries[0].recipientPubkey,
28512
+ coinId: "",
28513
+ amount: "",
28514
+ legs: { certified, total: certified },
28515
+ deliveryPending: true,
28516
+ createdAt: 0
28517
+ };
28518
+ }
28519
+ function shortfallRow(entry) {
28520
+ return {
28521
+ transferId: entry.transferId,
28522
+ kind: "shortfall",
28523
+ recipient: entry.recipient,
28524
+ coinId: entry.coinId,
28525
+ amount: entry.remainingAmount,
28526
+ legs: { certified: entry.committedTokenIds.length, total: entry.committedTokenIds.length },
28527
+ deliveryPending: false,
28528
+ createdAt: entry.createdAt
28529
+ };
28530
+ }
28531
+ function distinctOpCount(entries) {
28532
+ return new Set(entries.map((entry) => entry.opIndex)).size;
28533
+ }
28534
+
28535
+ // modules/payments-v2/restore.ts
28536
+ var ATTENTION_RESEED_REJECTED = "intent:reseed-rejected";
28537
+ var ALL_STREAMS = ["inventory", "mailbox", "payment_requests", "history"];
28538
+ async function reseedAndReset(deps) {
28539
+ for (const entry of await deps.stores.backstop.list()) {
28540
+ if (entry.disposition !== "open") continue;
28541
+ try {
28542
+ await deps.reput(entry.transferId, entry.payloadEnvelope, entry.requiresSeedClose);
28543
+ if (entry.requiresSeedClose) {
28544
+ await deps.reseedCheckpoint(entry.transferId, await splitOpIndex(deps, entry.payloadEnvelope));
28545
+ }
28546
+ } catch (err) {
28547
+ deps.attention(entry.transferId, ATTENTION_RESEED_REJECTED, err instanceof Error ? err.message : String(err));
28548
+ }
28549
+ }
28550
+ for (const stream of ALL_STREAMS) await deps.kv.remove(STORE_KEYS.streamCursor(stream));
28551
+ await deps.fullRePull();
28552
+ }
28553
+ async function splitOpIndex(deps, envelope) {
28554
+ const payload = await deps.decryptPayload(envelope);
28555
+ return Array.isArray(payload.direct) ? payload.direct.length : 0;
28556
+ }
28557
+
28558
+ // modules/payments-v2/inventory/presentation.ts
28559
+ function toToken(tokenId, snapshot, asset, registry, flags) {
28560
+ const iconUrl = registry.getIconUrl(asset.coinId);
28561
+ return {
28562
+ id: tokenId,
28563
+ coinId: asset.coinId,
28564
+ symbol: registry.getSymbol(asset.coinId),
28565
+ name: registry.getName(asset.coinId),
28566
+ decimals: registry.getDecimals(asset.coinId),
28567
+ ...iconUrl !== null ? { iconUrl } : {},
28568
+ amount: asset.amount,
28569
+ status: flags.transferring ? "transferring" : "confirmed",
28570
+ createdAt: snapshot.createdAt,
28571
+ updatedAt: snapshot.updatedAt,
28572
+ lazy: true,
28573
+ ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28574
+ };
28575
+ }
28576
+ function aggregateAssets(entries, isTransferring, registry) {
28577
+ const groups = /* @__PURE__ */ new Map();
28578
+ for (const [tokenId, entry] of entries) {
28579
+ const moving = isTransferring(tokenId);
28580
+ for (const asset of entry.assets) {
28581
+ let group = groups.get(asset.coinId);
28582
+ if (!group) {
28583
+ group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28584
+ groups.set(asset.coinId, group);
28585
+ }
28586
+ if (moving) {
28587
+ group.transferring += BigInt(asset.amount);
28588
+ group.transferringCount += 1;
28589
+ } else {
28590
+ group.total += BigInt(asset.amount);
28591
+ group.count += 1;
28592
+ }
28593
+ }
28594
+ }
28595
+ return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28596
+ }
28597
+ function toAsset(coinId, totals, registry) {
28598
+ const iconUrl = registry.getIconUrl(coinId);
28599
+ return {
28600
+ coinId,
28601
+ symbol: registry.getSymbol(coinId),
28602
+ name: registry.getName(coinId),
28603
+ decimals: registry.getDecimals(coinId),
28604
+ ...iconUrl !== null ? { iconUrl } : {},
28605
+ totalAmount: totals.total.toString(),
28606
+ tokenCount: totals.count,
28607
+ confirmedAmount: totals.total.toString(),
28608
+ unconfirmedAmount: "0",
28609
+ confirmedTokenCount: totals.count,
28610
+ unconfirmedTokenCount: 0,
28611
+ transferringTokenCount: totals.transferringCount,
28612
+ transferringAmount: totals.transferring.toString(),
28613
+ priceUsd: null,
28614
+ priceEur: null,
28615
+ change24h: null,
28616
+ fiatValueUsd: null,
28617
+ fiatValueEur: null
28618
+ };
28619
+ }
28620
+ async function withPrices(raw, registry, price) {
28621
+ try {
28622
+ const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28623
+ const quotes = await price.getPrices(names);
28624
+ return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28625
+ } catch {
28626
+ return raw;
28627
+ }
28628
+ }
28629
+ function priceOne(asset, quote) {
28630
+ if (!quote) return asset;
28631
+ const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28632
+ return {
28633
+ ...asset,
28634
+ priceUsd: quote.priceUsd,
28635
+ priceEur: quote.priceEur ?? null,
28636
+ change24h: quote.change24h ?? null,
28637
+ fiatValueUsd: human * quote.priceUsd,
28638
+ fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28639
+ };
28640
+ }
28641
+
28642
+ // modules/payments-v2/machine/payload.ts
28643
+ function buildPayload(recipientPubkey, request, spend, spentStates) {
28644
+ return {
28645
+ v: 2,
28646
+ recipient: recipientPubkey,
28647
+ coinId: request.coinId,
28648
+ amount: request.amount,
28649
+ ...request.memo !== void 0 ? { memo: request.memo } : {},
28650
+ direct: [...spend.plan.direct],
28651
+ ...spend.plan.split !== void 0 ? {
28652
+ split: {
28653
+ tokenId: spend.plan.split.tokenId,
28654
+ splitAmount: spend.plan.split.splitAmount.toString(),
28655
+ remainderAmount: spend.plan.split.remainderAmount.toString()
28656
+ }
28657
+ } : {},
28658
+ spentStates
28659
+ };
28660
+ }
28661
+ function messageOf(err) {
28662
+ return err instanceof Error ? err.message : String(err);
28663
+ }
28664
+
28283
28665
  // modules/payments-v2/history/History.ts
28284
28666
  var WIRE_TOKEN_ID = /^(?:[0-9a-f]{2}){1,64}$/;
28285
28667
  var WIRE_PUBKEY = /^0[23][0-9a-f]{64}$/;
@@ -28338,13 +28720,14 @@ var History = class {
28338
28720
  }
28339
28721
  // ── internals ────────────────────────────────────────────────────────────────
28340
28722
  async post(build) {
28723
+ const record = build();
28341
28724
  try {
28342
- await this.deps.client.postHistory([build()]);
28725
+ await this.deps.client.postHistory([record]);
28343
28726
  } catch (err) {
28344
28727
  this.deps.log?.("history POST failed (dedupKey makes retry safe)", err);
28345
28728
  return;
28346
28729
  }
28347
- this.deps.emit("history:updated");
28730
+ this.deps.emit("history:updated", this.toEntry(record));
28348
28731
  }
28349
28732
  ts(timestamp) {
28350
28733
  return new Date(timestamp ?? (this.deps.now ?? Date.now)()).toISOString();
@@ -28401,93 +28784,9 @@ var History = class {
28401
28784
  }
28402
28785
  };
28403
28786
 
28404
- // modules/payments-v2/inventory/presentation.ts
28405
- function toToken(tokenId, snapshot, asset, registry, flags) {
28406
- const iconUrl = registry.getIconUrl(asset.coinId);
28407
- return {
28408
- id: tokenId,
28409
- coinId: asset.coinId,
28410
- symbol: registry.getSymbol(asset.coinId),
28411
- name: registry.getName(asset.coinId),
28412
- decimals: registry.getDecimals(asset.coinId),
28413
- ...iconUrl !== null ? { iconUrl } : {},
28414
- amount: asset.amount,
28415
- status: flags.transferring ? "transferring" : "confirmed",
28416
- createdAt: snapshot.createdAt,
28417
- updatedAt: snapshot.updatedAt,
28418
- lazy: true,
28419
- ...flags.suspectedSpent ? { suspectedSpent: true } : {}
28420
- };
28421
- }
28422
- function aggregateAssets(entries, isTransferring, registry) {
28423
- const groups = /* @__PURE__ */ new Map();
28424
- for (const [tokenId, entry] of entries) {
28425
- const moving = isTransferring(tokenId);
28426
- for (const asset of entry.assets) {
28427
- let group = groups.get(asset.coinId);
28428
- if (!group) {
28429
- group = { total: 0n, count: 0, transferring: 0n, transferringCount: 0 };
28430
- groups.set(asset.coinId, group);
28431
- }
28432
- if (moving) {
28433
- group.transferring += BigInt(asset.amount);
28434
- group.transferringCount += 1;
28435
- } else {
28436
- group.total += BigInt(asset.amount);
28437
- group.count += 1;
28438
- }
28439
- }
28440
- }
28441
- return [...groups].map(([coinId, totals]) => toAsset(coinId, totals, registry));
28442
- }
28443
- function toAsset(coinId, totals, registry) {
28444
- const iconUrl = registry.getIconUrl(coinId);
28445
- return {
28446
- coinId,
28447
- symbol: registry.getSymbol(coinId),
28448
- name: registry.getName(coinId),
28449
- decimals: registry.getDecimals(coinId),
28450
- ...iconUrl !== null ? { iconUrl } : {},
28451
- totalAmount: totals.total.toString(),
28452
- tokenCount: totals.count,
28453
- confirmedAmount: totals.total.toString(),
28454
- unconfirmedAmount: "0",
28455
- confirmedTokenCount: totals.count,
28456
- unconfirmedTokenCount: 0,
28457
- transferringTokenCount: totals.transferringCount,
28458
- transferringAmount: totals.transferring.toString(),
28459
- priceUsd: null,
28460
- priceEur: null,
28461
- change24h: null,
28462
- fiatValueUsd: null,
28463
- fiatValueEur: null
28464
- };
28465
- }
28466
- async function withPrices(raw, registry, price) {
28467
- try {
28468
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
28469
- const quotes = await price.getPrices(names);
28470
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
28471
- } catch {
28472
- return raw;
28473
- }
28474
- }
28475
- function priceOne(asset, quote) {
28476
- if (!quote) return asset;
28477
- const human = Number(asset.totalAmount) / Math.pow(10, asset.decimals);
28478
- return {
28479
- ...asset,
28480
- priceUsd: quote.priceUsd,
28481
- priceEur: quote.priceEur ?? null,
28482
- change24h: quote.change24h ?? null,
28483
- fiatValueUsd: human * quote.priceUsd,
28484
- fiatValueEur: quote.priceEur != null ? human * quote.priceEur : null
28485
- };
28486
- }
28487
-
28488
28787
  // modules/payments-v2/inventory/InventoryView.ts
28489
- var SUSPECTED_KEY = "suspected-spent";
28490
- var KNOWN_SPENDS_KEY = "known-spends";
28788
+ var SUSPECTED_KEY = STORE_KEYS.suspectedSpent;
28789
+ var KNOWN_SPENDS_KEY = STORE_KEYS.knownSpends;
28491
28790
  var CURSOR_KEY = STORE_KEYS.streamCursor("inventory");
28492
28791
  function stateKey(tokenId, stateHash) {
28493
28792
  return `${tokenId}:${stateHash}`;
@@ -28791,19 +29090,24 @@ var Receive = class {
28791
29090
  const engine = deps.engine();
28792
29091
  const stored = [];
28793
29092
  const pending = [];
28794
- let epoch = "";
29093
+ const pageEpoch = () => deps.delivery.incomingEpoch() ?? deps.syncEpoch();
28795
29094
  try {
28796
- epoch = deps.syncEpoch();
28797
29095
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
28798
- const since = record !== null && record.syncEpoch === epoch ? String(record.cursor) : void 0;
28799
- for await (const entry of deps.delivery.incoming(since)) {
28800
- await this.processEntry(deps, engine, entry, pending, stored);
28801
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, epoch);
29096
+ let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
29097
+ for (let pass = 0; pass < 2; pass++) {
29098
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
29099
+ await this.processEntry(deps, engine, entry, pending, stored);
29100
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
29101
+ }
29102
+ await flushAcks(deps, pending, pageEpoch);
29103
+ const served = deps.delivery.incomingEpoch();
29104
+ if (basis === null || served === null || served === basis.syncEpoch) break;
29105
+ await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
29106
+ basis = null;
28802
29107
  }
28803
- await flushAcks(deps, pending, epoch);
28804
29108
  } catch (err) {
28805
29109
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
28806
- await flushAcks(deps, pending, epoch).catch((flushErr) => {
29110
+ await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
28807
29111
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
28808
29112
  });
28809
29113
  }
@@ -28876,7 +29180,7 @@ async function announce(deps, entry, record) {
28876
29180
  receivedAt
28877
29181
  };
28878
29182
  }
28879
- async function flushAcks(deps, pending, epoch) {
29183
+ async function flushAcks(deps, pending, epochOf) {
28880
29184
  let lastAcked = null;
28881
29185
  try {
28882
29186
  while (pending.length > 0) {
@@ -28887,7 +29191,7 @@ async function flushAcks(deps, pending, epoch) {
28887
29191
  }
28888
29192
  } finally {
28889
29193
  if (lastAcked !== null) {
28890
- const record = { cursor: lastAcked, syncEpoch: epoch };
29194
+ const record = { cursor: lastAcked, syncEpoch: epochOf() };
28891
29195
  await deps.kv.set(STORE_KEYS.streamCursor("mailbox"), record);
28892
29196
  }
28893
29197
  }
@@ -28988,9 +29292,19 @@ var Requests = class {
28988
29292
  }
28989
29293
  });
28990
29294
  }
29295
+ /** Idempotent for a same-transferId re-write: committed only ratchets up, createdAt kept. */
28991
29296
  writeLink(requestId, transferId, committed) {
28992
29297
  return this.mutateJournal((m) => {
28993
- m.set(requestId, { requestId, transferId, committed, createdAt: this.now() });
29298
+ const prev = m.get(requestId);
29299
+ const same = prev !== void 0 && prev.transferId === transferId;
29300
+ const next = {
29301
+ requestId,
29302
+ transferId,
29303
+ committed: committed || same && prev.committed,
29304
+ createdAt: same ? prev.createdAt : this.now()
29305
+ };
29306
+ if (same && prev.committed === next.committed) return false;
29307
+ m.set(requestId, next);
28994
29308
  return true;
28995
29309
  });
28996
29310
  }
@@ -29143,23 +29457,46 @@ var Requests = class {
29143
29457
  const transferId = error instanceof SphereError && error.transferId !== void 0 ? error.transferId : void 0;
29144
29458
  if (transferId !== void 0) {
29145
29459
  const committed = error instanceof PartialSendConflictError;
29146
- await this.writeLink(id, transferId, committed);
29147
- this.setStatus(id, "settling");
29460
+ await this.settle(id, transferId, { committed, respond: false });
29148
29461
  } else {
29149
29462
  logger.error("PaymentsV2", `possibly-committed send for ${id} carried no transferId \u2014 paid (fail-closed)`);
29150
29463
  this.setStatus(id, "paid");
29151
29464
  }
29152
29465
  } else {
29153
- await this.clearLink(id);
29154
- this.setStatus(id, "pending");
29466
+ await this.revertPayable(id);
29155
29467
  }
29156
29468
  throw error;
29157
29469
  }
29158
- this.setStatus(id, "paid");
29159
- await this.respondPaid(id, result.id);
29160
- await this.clearLink(id);
29470
+ await this.settle(id, result.id, { committed: true, respond: true });
29161
29471
  return result;
29162
29472
  }
29473
+ /**
29474
+ * THE settlement invariant (#441 + the failed-respond P1): a settling link is
29475
+ * removed ONLY by (a) a CONFIRMED paid respond — a 2xx, or the 409
29476
+ * already-resolved absorb — or (b) a proven clean pre-commit failure
29477
+ * (revertPayable). Nothing else removes one: not a network error, not a 5xx,
29478
+ * not a reload. Every path that binds a request to a transfer outcome funnels
29479
+ * through here — pay()'s clean success (respond now), pay()'s
29480
+ * possibly-committed throw (respond deferred), and reconcile's deferred arms
29481
+ * — so a failed respond always leaves the link + 'settling' and the next
29482
+ * reconcile pass (the committed-link override) retries the respond.
29483
+ */
29484
+ async settle(requestId, transferId, opts) {
29485
+ let journalDown = false;
29486
+ try {
29487
+ await this.writeLink(requestId, transferId, opts.committed);
29488
+ } catch (err) {
29489
+ if (!opts.respond) throw err;
29490
+ journalDown = true;
29491
+ logger.error("PaymentsV2", `settling link write failed for ${requestId} \u2014 responding fail-closed:`, err);
29492
+ }
29493
+ if (!(opts.respond && await this.respondPaid(requestId, transferId))) {
29494
+ this.setStatus(requestId, journalDown ? "paid" : "settling");
29495
+ return;
29496
+ }
29497
+ if (!journalDown) await this.clearLink(requestId).catch(() => void 0);
29498
+ this.setStatus(requestId, "paid");
29499
+ }
29163
29500
  /** 'paid' respond leg: 409 = already resolved = idempotent success; other errors defer. */
29164
29501
  async respondPaid(id, transferId) {
29165
29502
  try {
@@ -29195,32 +29532,32 @@ var Requests = class {
29195
29532
  let aborted = null;
29196
29533
  for (const [requestId, link] of [...this.journal]) {
29197
29534
  if (link.committed || resumed.has(link.transferId)) {
29198
- await this.resolvePaid(requestId, link.transferId);
29535
+ await this.resolvePaid(requestId, link);
29199
29536
  } else if (conflicted.has(link.transferId)) {
29200
29537
  await this.revertPayable(requestId);
29201
29538
  } else if (open.has(link.transferId)) {
29202
29539
  } else {
29203
- aborted = await this.reconcileUnaccounted(requestId, link.transferId, aborted);
29540
+ aborted = await this.reconcileUnaccounted(requestId, link, aborted);
29204
29541
  }
29205
29542
  }
29206
29543
  }
29207
29544
  // Unaccounted link: consult the server aborted authority (lazy, memoized);
29208
29545
  // a probe failure defers the link to the next session.
29209
- async reconcileUnaccounted(requestId, transferId, aborted) {
29546
+ async reconcileUnaccounted(requestId, link, aborted) {
29210
29547
  try {
29211
29548
  aborted ??= new Set(await this.deps.listAbortedTransferIds());
29212
- if (aborted.has(transferId)) await this.revertPayable(requestId);
29213
- else await this.resolvePaid(requestId, transferId);
29549
+ if (aborted.has(link.transferId)) await this.revertPayable(requestId);
29550
+ else await this.resolvePaid(requestId, link);
29214
29551
  } catch (err) {
29215
29552
  logger.warn("PaymentsV2", `aborted-intent probe failed \u2014 ${requestId} deferred:`, err);
29216
29553
  }
29217
29554
  return aborted;
29218
29555
  }
29219
- async resolvePaid(requestId, transferId) {
29220
- if (!await this.respondPaid(requestId, transferId)) return;
29221
- await this.clearLink(requestId);
29222
- this.setStatus(requestId, "paid");
29556
+ /** Deferred paid: the ONE settlement path again — a failed respond keeps the link. */
29557
+ resolvePaid(requestId, link) {
29558
+ return this.settle(requestId, link.transferId, { committed: link.committed, respond: true });
29223
29559
  }
29560
+ /** Removal cause (b): a PROVEN clean outcome (pre-commit failure / server-aborted). */
29224
29561
  async revertPayable(requestId) {
29225
29562
  await this.clearLink(requestId);
29226
29563
  this.setStatus(requestId, "pending");
@@ -29544,11 +29881,12 @@ function composeFacadeParts(deps, hooks) {
29544
29881
  client: deps.client,
29545
29882
  fieldKey: deps.fieldKey,
29546
29883
  registry: deps.registry,
29547
- emit: (event) => deps.emit(event, {}),
29884
+ emit: (event, entry) => deps.emit(event, entry),
29548
29885
  ...deps.now !== void 0 ? { now: deps.now } : {},
29549
29886
  ...deps.newId !== void 0 ? { newId: deps.newId } : {}
29550
29887
  });
29551
29888
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
29889
+ const machineStores = createMachineStores(deps.kv);
29552
29890
  const heldStates = /* @__PURE__ */ new Map();
29553
29891
  return {
29554
29892
  ownPubkeyBytes,
@@ -29556,12 +29894,29 @@ function composeFacadeParts(deps, hooks) {
29556
29894
  ledger,
29557
29895
  queue,
29558
29896
  historyStore,
29559
- machineStores: createMachineStores(deps.kv),
29897
+ machineStores,
29560
29898
  machineDeps,
29561
29899
  machine: new TransferMachine(machineDeps),
29562
29900
  heldStates,
29563
29901
  receiveLoop: buildReceive(deps, hooks, historyStore, heldStates),
29564
- requests: buildRequests(deps, hooks)
29902
+ requests: buildRequests(deps, hooks),
29903
+ restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
29904
+ };
29905
+ }
29906
+ function buildRestoreDeps(deps, machineDeps, machineStores, view) {
29907
+ return {
29908
+ stores: machineStores,
29909
+ kv: deps.kv,
29910
+ reput: (transferId, envelope, requiresSeedClose) => machineDeps.intents.put(transferId, envelope, requiresSeedClose),
29911
+ reseedCheckpoint: (transferId, opIndex) => deps.checkpointStore.reseedCheckpoint(transferId, opIndex),
29912
+ decryptPayload: machineDeps.decryptPayload,
29913
+ fullRePull: () => view.onEpochReset(),
29914
+ attention: (transferId, code, detail) => {
29915
+ deps.emit(
29916
+ "transfer:attention",
29917
+ detail === void 0 ? { transferId, code } : { transferId, code, detail }
29918
+ );
29919
+ }
29565
29920
  };
29566
29921
  }
29567
29922
  function buildMachineDeps(deps, hooks, historyStore) {
@@ -29583,11 +29938,13 @@ function buildMachineDeps(deps, hooks, historyStore) {
29583
29938
  ownPubkey: hexToBytes4(deps.ownPubkey),
29584
29939
  emit: deps.emit,
29585
29940
  now: deps.now ?? Date.now,
29586
- recordHistory: async ({ transferId, payload }) => {
29941
+ recordHistory: async ({ transferId, payload, committedAmount }) => {
29587
29942
  await historyStore.recordSent({
29588
29943
  transferId,
29589
29944
  coinId: payload.coinId,
29590
- amount: payload.amount,
29945
+ // §5.9: the SETTLED amount (machine-computed from the certified
29946
+ // recipient blobs), never payload.amount — the plan.
29947
+ amount: committedAmount,
29591
29948
  recipientPubkey: payload.recipient,
29592
29949
  ...payload.memo !== void 0 ? { memo: payload.memo } : {}
29593
29950
  });
@@ -29614,7 +29971,7 @@ function buildReceive(deps, hooks, historyStore, heldStates) {
29614
29971
  detail === void 0 ? { transferId, code } : { transferId, code, detail }
29615
29972
  );
29616
29973
  },
29617
- syncEpoch: deps.syncEpoch ?? (() => ""),
29974
+ syncEpoch: deps.syncEpoch,
29618
29975
  ...deps.now !== void 0 ? { now: deps.now } : {}
29619
29976
  });
29620
29977
  }
@@ -29667,8 +30024,24 @@ var PaymentsFacade = class {
29667
30024
  this.receiveLoop = parts.receiveLoop;
29668
30025
  this.heldStates = parts.heldStates;
29669
30026
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
30027
+ this.restoreDeps = parts.restoreDeps;
29670
30028
  this.requests = parts.requests;
29671
30029
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
30030
+ this.heartbeat = new ConvergenceHeartbeat({
30031
+ now: () => this.nowMs(),
30032
+ onTick: () => this.trackTail(this.runConvergencePass())
30033
+ });
30034
+ this.converger = new Converger({
30035
+ machineDeps: this.machineDeps,
30036
+ stores: this.machineStores,
30037
+ delivery: deps.deliveryPort,
30038
+ reconcile: (report) => this.requests.reconcile(report),
30039
+ refreshView: () => this.view.delta(),
30040
+ replayMints: () => this.replayMints(),
30041
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
30042
+ emit: deps.emit,
30043
+ now: () => this.nowMs()
30044
+ });
29672
30045
  }
29673
30046
  view;
29674
30047
  ledger;
@@ -29680,17 +30053,28 @@ var PaymentsFacade = class {
29680
30053
  receiveLoop;
29681
30054
  heldStates;
29682
30055
  ownPubkeyBytes;
30056
+ restoreDeps;
29683
30057
  requests;
29684
30058
  currentEngine = null;
29685
30059
  pendingOps = /* @__PURE__ */ new Set();
29686
30060
  unsubscribers = [];
29687
30061
  started = false;
30062
+ // §7 heartbeat: in-memory timing cell + pass runner (see convergence.ts).
30063
+ heartbeat;
30064
+ converger;
30065
+ /** Resume single-flight (§7): ticks, start() and resumeNow() coalesce onto ONE pass. */
30066
+ resumeFlight = new SingleFlight();
30067
+ /** §5.1/§7: restore + convergence passes SERIALIZE here. */
30068
+ passChain = new SerialChain();
30069
+ /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
30070
+ activeMoneyOps = /* @__PURE__ */ new Set();
29688
30071
  // ── lifecycle ──────────────────────────────────────────────────────────────
29689
30072
  async start() {
29690
30073
  if (this.started) return;
29691
30074
  this.started = true;
29692
- await this.deps.session.start();
30075
+ this.heartbeat.start();
29693
30076
  this.unsubscribers.push(
30077
+ this.deps.session.subscribeEpochChange((epoch) => this.handleEpochChange(epoch)),
29694
30078
  this.deps.session.subscribeStream("mailbox", () => {
29695
30079
  this.trackTail(this.receiveLoop.drainOnce());
29696
30080
  }),
@@ -29701,20 +30085,12 @@ var PaymentsFacade = class {
29701
30085
  this.trackTail(this.requests.drainIncoming());
29702
30086
  })
29703
30087
  );
29704
- this.trackTail(this.runResume());
29705
- this.trackTail(
29706
- this.machineStores.deliveryJournal.replay({
29707
- delivery: this.deps.deliveryPort,
29708
- now: this.deps.now ?? Date.now,
29709
- attention: (transferId, code, detail) => {
29710
- this.deps.emit(
29711
- "transfer:attention",
29712
- detail === void 0 ? { transferId, code } : { transferId, code, detail }
29713
- );
29714
- }
29715
- })
29716
- );
29717
- this.trackTail(this.replayMints());
30088
+ const statusUnsub = this.deps.session.subscribeStatus?.((status) => {
30089
+ if (status === "connected") this.heartbeat.recovered();
30090
+ });
30091
+ if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
30092
+ await this.deps.session.start();
30093
+ this.trackTail(this.runConvergencePass());
29718
30094
  this.trackTail(this.seedHeldStates());
29719
30095
  await this.view.fullPull().catch(() => void 0);
29720
30096
  this.receiveLoop.start(this.deps.receivePollMs);
@@ -29722,6 +30098,7 @@ var PaymentsFacade = class {
29722
30098
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
29723
30099
  async stop() {
29724
30100
  this.started = false;
30101
+ this.heartbeat.stop();
29725
30102
  this.receiveLoop.stop();
29726
30103
  for (const unsubscribe of this.unsubscribers) unsubscribe();
29727
30104
  this.unsubscribers = [];
@@ -29747,9 +30124,13 @@ var PaymentsFacade = class {
29747
30124
  history(page) {
29748
30125
  return this.historyStore.page(page ?? {});
29749
30126
  }
30127
+ /** §4 pending-transfers UI surface — derived on read, never cached (convergence.ts). */
30128
+ pendingTransfers() {
30129
+ return derivePendingTransfers(this.machineStores, this.machineDeps.decryptPayload);
30130
+ }
29750
30131
  // ── money movement ─────────────────────────────────────────────────────────
29751
30132
  send(request) {
29752
- return this.track(this.sendWithPolicy(request));
30133
+ return this.track(this.sendOutcome(request));
29753
30134
  }
29754
30135
  async receive() {
29755
30136
  const transfers = await this.track(this.receiveLoop.drainOnce());
@@ -29759,9 +30140,32 @@ var PaymentsFacade = class {
29759
30140
  mint(coinId, amount) {
29760
30141
  return this.track(this.mintInner(coinId, amount));
29761
30142
  }
30143
+ // §4 retry verb: coalesces onto the running pass — NEVER re-issue send() (#631/#676).
30144
+ async resumeNow() {
30145
+ if (!this.started) return;
30146
+ await this.track(this.runConvergencePass());
30147
+ }
30148
+ /** §5.1 restore — awaited by the session latch BEFORE streams resume; never coalesced onto a pre-restore pass. */
30149
+ async handleEpochChange(_newEpoch) {
30150
+ await this.passChain.enqueue(() => reseedAndReset(this.restoreDeps));
30151
+ await this.passChain.enqueue(() => this.convergeBody());
30152
+ }
30153
+ /** One single-flighted pass + its reschedule: concurrent callers coalesce. */
30154
+ runConvergencePass() {
30155
+ return this.resumeFlight.run(() => this.passChain.enqueue(() => this.convergeBody()));
30156
+ }
30157
+ async convergeBody() {
30158
+ const outcome = await this.converger.convergeOnce();
30159
+ const pending = await this.converger.pendingWork().catch(() => true);
30160
+ this.heartbeat.settle(outcome, pending);
30161
+ }
30162
+ nowMs() {
30163
+ return (this.deps.now ?? Date.now)();
30164
+ }
29762
30165
  // ── send policy (§5.5 + old-loop parity; the machine stays policy-free) ────
29763
- async sendWithPolicy(request) {
29764
- const recipient = await this.requireSameNetworkRecipient(request.recipient);
30166
+ /** The ONE place a send() outcome is shaped: success emits in finishSend, a
30167
+ * CLEAN rejection emits `transfer:updated{status:'failed'}` here (§4). */
30168
+ async sendOutcome(request) {
29765
30169
  const run = {
29766
30170
  amount: request.amount,
29767
30171
  delivered: [],
@@ -29769,12 +30173,36 @@ var PaymentsFacade = class {
29769
30173
  tokenTransfers: [],
29770
30174
  settledShortfalls: [],
29771
30175
  firstPartialId: void 0,
29772
- pendingAny: false
30176
+ pendingAny: false,
30177
+ lastTransferId: ""
29773
30178
  };
30179
+ try {
30180
+ return await this.sendWithPolicy(request, run);
30181
+ } catch (err) {
30182
+ this.emitCleanFailure(err, run.lastTransferId);
30183
+ throw err;
30184
+ }
30185
+ }
30186
+ /** Only a CLEAN failure (nothing certified, classifyError 'other') is 'failed'.
30187
+ * Keep-open/partial/conflict outcomes are pending/converging — labelling them
30188
+ * 'failed' invites a dApp re-send, i.e. a double-pay (#631/#676). */
30189
+ emitCleanFailure(err, transferId) {
30190
+ if (isPossiblyCommittedSendOutcome(err) || classifyError(err) !== "other") return;
30191
+ const failed = {
30192
+ id: transferId,
30193
+ status: "failed",
30194
+ tokens: [],
30195
+ tokenTransfers: [],
30196
+ error: messageOf(err)
30197
+ };
30198
+ this.deps.emit("transfer:updated", failed);
30199
+ }
30200
+ async sendWithPolicy(request, run) {
30201
+ const recipient = await this.requireSameNetworkRecipient(request.recipient);
29774
30202
  for (let attempt = 0; ; attempt++) {
29775
30203
  let ctx;
29776
30204
  try {
29777
- ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount });
30205
+ ctx = await this.planAndMaterialize(recipient.chainPubkey, { ...request, amount: run.amount }, run);
29778
30206
  } catch (err) {
29779
30207
  throw this.partialize(err, run);
29780
30208
  }
@@ -29808,21 +30236,24 @@ var PaymentsFacade = class {
29808
30236
  return recipient;
29809
30237
  }
29810
30238
  async runAttempt(ctx) {
30239
+ this.activeMoneyOps.add(ctx.transferId);
29811
30240
  try {
29812
30241
  const { deliveryPending } = await this.machine.run(ctx.plan);
29813
30242
  this.settleSuccess(ctx, ctx.sourceIds);
29814
30243
  return { kind: "success", deliveryPending };
29815
30244
  } catch (err) {
29816
- return this.disposeFailedAttempt(ctx, err);
30245
+ return await this.disposeFailedAttempt(ctx, err);
30246
+ } finally {
30247
+ this.activeMoneyOps.delete(ctx.transferId);
29817
30248
  }
29818
30249
  }
29819
- /**
29820
- * Partial outcome (#677/#690): the shortfall is already durable (written by
29821
- * the machine BEFORE complete). Accumulate the settled set, then re-plan ONLY
29822
- * the remainder under a NEW transferId — never the full amount.
29823
- */
30250
+ /** Partial (#677/#690): shortfall already durable (machine wrote it BEFORE
30251
+ * complete); accumulate settled, re-plan ONLY the remainder, NEW transferId. */
29824
30252
  async consumePartial(ctx, disposition, run, attempt) {
29825
- run.pendingAny = run.pendingAny || disposition.deliveryPending;
30253
+ if (disposition.deliveryPending) {
30254
+ run.pendingAny = true;
30255
+ this.heartbeat.arm();
30256
+ }
29826
30257
  const partial = disposition.entry;
29827
30258
  run.delivered.push(...partial.committedTokenIds);
29828
30259
  run.firstPartialId ??= partial.transferId;
@@ -29843,11 +30274,8 @@ var PaymentsFacade = class {
29843
30274
  run.amount = partial.remainingAmount;
29844
30275
  return null;
29845
30276
  }
29846
- /**
29847
- * One attempt's failure disposition: possibly-committed → rethrow UNWRAPPED;
29848
- * backstop still 'open' (committed>0) → converge via the same machine's
29849
- * resumeIntent; clean conflict with a demoted source → bounded full re-plan.
29850
- */
30277
+ /** Failure disposition: possibly-committed → rethrow UNWRAPPED; backstop
30278
+ * 'open' → converge via resumeIntent; clean demoted conflict → full re-plan. */
29851
30279
  async disposeFailedAttempt(ctx, err) {
29852
30280
  if (isPossiblyCommittedSendOutcome(err)) {
29853
30281
  this.settleKeepOpen(ctx);
@@ -29896,8 +30324,9 @@ var PaymentsFacade = class {
29896
30324
  }
29897
30325
  return { kind: "rethrow", error: err };
29898
30326
  }
29899
- async planAndMaterialize(recipientPubkey, request) {
30327
+ async planAndMaterialize(recipientPubkey, request, run) {
29900
30328
  const transferId = this.newId();
30329
+ run.lastTransferId = transferId;
29901
30330
  const planned = this.queue.plan(transferId, { coinId: request.coinId, amount: request.amount });
29902
30331
  const spend = planned.kind === "planned" ? planned.spend : await planned.settled;
29903
30332
  const sourceIds = this.markPlanned(transferId, request.coinId, spend);
@@ -29980,6 +30409,7 @@ var PaymentsFacade = class {
29980
30409
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
29981
30410
  settleKeepOpen(ctx) {
29982
30411
  this.queue.clearExpectedChange(ctx.transferId);
30412
+ this.heartbeat.arm();
29983
30413
  }
29984
30414
  settlePartial(ctx, partial) {
29985
30415
  this.ledger.cancel(ctx.transferId);
@@ -30016,6 +30446,7 @@ var PaymentsFacade = class {
30016
30446
  for (const id of run.settledShortfalls.splice(0)) {
30017
30447
  await this.machineStores.shortfalls.removeByKey(id).catch(() => void 0);
30018
30448
  }
30449
+ if (run.pendingAny) this.heartbeat.arm();
30019
30450
  const result = {
30020
30451
  id: transferId,
30021
30452
  status: run.pendingAny ? "confirmed" : "delivered",
@@ -30027,11 +30458,7 @@ var PaymentsFacade = class {
30027
30458
  this.deps.emit("transfer:updated", result);
30028
30459
  return result;
30029
30460
  }
30030
- /**
30031
- * With nothing delivered the error passes UNWRAPPED (identity + cause kept);
30032
- * after ≥1 delivered leg EVERY failure surfaces as PartialSendConflictError
30033
- * over the accumulated settled set — never bare, never a full-amount retry.
30034
- */
30461
+ /** Nothing delivered → UNWRAPPED; after ≥1 delivered leg every failure surfaces as PartialSendConflictError over the settled set. */
30035
30462
  partialize(err, run) {
30036
30463
  if (run.delivered.length === 0) return err;
30037
30464
  return new PartialSendConflictError(
@@ -30060,21 +30487,29 @@ var PaymentsFacade = class {
30060
30487
  if (!/^(?:[0-9a-f]{2})+$/.test(coinId) || amount <= 0n) {
30061
30488
  return { success: false, error: "coinId must be even-length lowercase hex and amount positive" };
30062
30489
  }
30063
- const engine = this.engine();
30064
30490
  const mintId = this.newId();
30065
- const params = this.mintParams(coinId, amount);
30491
+ this.activeMoneyOps.add(mintId);
30492
+ try {
30493
+ return await this.mintUnderJournal(mintId, coinId, amount);
30494
+ } finally {
30495
+ this.activeMoneyOps.delete(mintId);
30496
+ }
30497
+ }
30498
+ async mintUnderJournal(mintId, coinId, amount) {
30499
+ const engine = this.engine();
30066
30500
  const entry = {
30067
30501
  mintId,
30068
30502
  coinId,
30069
30503
  amount: amount.toString(),
30070
30504
  tokenId: "",
30071
- createdAt: (this.deps.now ?? Date.now)()
30505
+ createdAt: this.nowMs()
30072
30506
  };
30073
30507
  await this.machineStores.mintJournal.upsert(entry);
30074
30508
  let token;
30075
30509
  try {
30076
- token = await engine.mint(params, { transferId: mintId });
30510
+ token = await engine.mint(this.mintParams(coinId, amount), { transferId: mintId });
30077
30511
  } catch (err) {
30512
+ this.heartbeat.arm();
30078
30513
  return { success: false, error: messageOf(err) };
30079
30514
  }
30080
30515
  if (token.blob.tokenId !== entry.tokenId) {
@@ -30083,6 +30518,7 @@ var PaymentsFacade = class {
30083
30518
  try {
30084
30519
  await this.finalizeMint(engine, mintId, token, coinId, amount.toString());
30085
30520
  } catch (err) {
30521
+ this.heartbeat.arm();
30086
30522
  return { success: false, tokenId: token.blob.tokenId, error: messageOf(err) };
30087
30523
  }
30088
30524
  return { success: true, tokenId: token.blob.tokenId };
@@ -30105,18 +30541,22 @@ var PaymentsFacade = class {
30105
30541
  this.heldStates.set(token.blob.tokenId, (await engine.deliveryKeys(bytes)).stateHash);
30106
30542
  this.trackTail(this.view.delta());
30107
30543
  }
30544
+ /** @returns how many journal entries were RESOLVED (cleared) — heartbeat progress. */
30108
30545
  async replayMints() {
30546
+ let resolved = 0;
30109
30547
  for (const entry of await this.machineStores.mintJournal.list()) {
30548
+ if (this.activeMoneyOps.has(entry.mintId)) continue;
30110
30549
  try {
30111
- await this.replayMint(entry);
30550
+ if (await this.replayMint(entry)) resolved += 1;
30112
30551
  } catch {
30113
30552
  }
30114
30553
  }
30554
+ return resolved;
30115
30555
  }
30116
30556
  async replayMint(entry) {
30117
30557
  if (entry.tokenId !== "" && await this.tokenInServerInventory(entry.tokenId)) {
30118
30558
  await this.machineStores.mintJournal.removeByKey(entry.mintId);
30119
- return;
30559
+ return true;
30120
30560
  }
30121
30561
  const engine = this.engine();
30122
30562
  if (!supportsDeterministicMint(engine)) {
@@ -30124,7 +30564,7 @@ var PaymentsFacade = class {
30124
30564
  transferId: entry.mintId,
30125
30565
  code: ATTENTION_MINT_UNRESOLVED
30126
30566
  });
30127
- return;
30567
+ return false;
30128
30568
  }
30129
30569
  const token = await engine.mint(this.mintParams(entry.coinId, BigInt(entry.amount)), {
30130
30570
  transferId: entry.mintId
@@ -30133,6 +30573,7 @@ var PaymentsFacade = class {
30133
30573
  await this.machineStores.mintJournal.upsert({ ...entry, tokenId: token.blob.tokenId });
30134
30574
  }
30135
30575
  await this.finalizeMint(engine, entry.mintId, token, entry.coinId, entry.amount);
30576
+ return true;
30136
30577
  }
30137
30578
  mintParams(coinId, amount) {
30138
30579
  return {
@@ -30158,17 +30599,6 @@ var PaymentsFacade = class {
30158
30599
  newId() {
30159
30600
  return (this.deps.newId ?? randomUUID)();
30160
30601
  }
30161
- async runResume() {
30162
- const report = await resumeAll(this.machineDeps);
30163
- await this.requests.reconcile({
30164
- resumed: report.resumed,
30165
- conflicted: report.conflicted,
30166
- open: report.failed
30167
- });
30168
- if (report.resumed.length > 0 || report.conflicted.length > 0) {
30169
- await this.view.delta().catch(() => void 0);
30170
- }
30171
- }
30172
30602
  async seedHeldStates() {
30173
30603
  let page = await this.deps.storagePort.listInventory();
30174
30604
  for (let i = 0; i < INVENTORY_SCAN_PAGE_LIMIT; i++) {
@@ -30180,22 +30610,14 @@ var PaymentsFacade = class {
30180
30610
  }
30181
30611
  }
30182
30612
  toUiToken(tokenId, coinId, amount) {
30183
- const registry = this.deps.registry;
30184
- const iconUrl = registry.getIconUrl(coinId);
30185
- const now = (this.deps.now ?? Date.now)();
30186
- return {
30187
- id: tokenId,
30188
- coinId,
30189
- symbol: registry.getSymbol(coinId),
30190
- name: registry.getName(coinId),
30191
- decimals: registry.getDecimals(coinId),
30192
- ...iconUrl !== null ? { iconUrl } : {},
30193
- amount: amount.toString(),
30194
- status: "transferring",
30195
- createdAt: now,
30196
- updatedAt: now,
30197
- lazy: true
30198
- };
30613
+ const now = this.nowMs();
30614
+ return toToken(
30615
+ tokenId,
30616
+ { assets: [], createdAt: now, updatedAt: now },
30617
+ { coinId, amount: amount.toString() },
30618
+ this.deps.registry,
30619
+ { transferring: true, suspectedSpent: false }
30620
+ );
30199
30621
  }
30200
30622
  track(op) {
30201
30623
  const settled = op.then(
@@ -30210,27 +30632,6 @@ var PaymentsFacade = class {
30210
30632
  this.track(op).catch(() => void 0);
30211
30633
  }
30212
30634
  };
30213
- function buildPayload(recipientPubkey, request, spend, spentStates) {
30214
- return {
30215
- v: 2,
30216
- recipient: recipientPubkey,
30217
- coinId: request.coinId,
30218
- amount: request.amount,
30219
- ...request.memo !== void 0 ? { memo: request.memo } : {},
30220
- direct: [...spend.plan.direct],
30221
- ...spend.plan.split !== void 0 ? {
30222
- split: {
30223
- tokenId: spend.plan.split.tokenId,
30224
- splitAmount: spend.plan.split.splitAmount.toString(),
30225
- remainderAmount: spend.plan.split.remainderAmount.toString()
30226
- }
30227
- } : {},
30228
- spentStates
30229
- };
30230
- }
30231
- function messageOf(err) {
30232
- return err instanceof Error ? err.message : String(err);
30233
- }
30234
30635
 
30235
30636
  // impl/wallet-api-v2/http.ts
30236
30637
  var WalletApiHttpError = class extends Error {
@@ -30576,7 +30977,9 @@ var WalletApiSession = class {
30576
30977
  epochLatch = null;
30577
30978
  reseedChain = Promise.resolve();
30578
30979
  reseedsPending = 0;
30980
+ epochHandlers = /* @__PURE__ */ new Set();
30579
30981
  handlers = /* @__PURE__ */ new Map();
30982
+ statusHandlers = /* @__PURE__ */ new Set();
30580
30983
  socket = null;
30581
30984
  started = false;
30582
30985
  stopped = false;
@@ -30677,6 +31080,18 @@ var WalletApiSession = class {
30677
31080
  await this.deps.kv.set(this.refreshKey, tokens.refreshToken);
30678
31081
  }
30679
31082
  // ── epoch latch (F12) ─────────────────────────────────────────────────────
31083
+ /** §5.1: the latched server syncEpoch ('' before first server contact). */
31084
+ currentEpoch() {
31085
+ return this.epochLatch ?? "";
31086
+ }
31087
+ /**
31088
+ * §5.1 restore hook (FacadeSession contract): handlers are AWAITED inside
31089
+ * the reseed chain, BEFORE any stream nudge resumes.
31090
+ */
31091
+ subscribeEpochChange(handler) {
31092
+ this.epochHandlers.add(handler);
31093
+ return () => this.epochHandlers.delete(handler);
31094
+ }
30680
31095
  noteEpoch(epoch) {
30681
31096
  if (this.epochLatch === epoch) return;
30682
31097
  const previous = this.epochLatch;
@@ -30690,7 +31105,7 @@ var WalletApiSession = class {
30690
31105
  }
30691
31106
  async runReseed(epoch, previous) {
30692
31107
  try {
30693
- await this.deps.onEpochChange(epoch);
31108
+ for (const handler of [...this.epochHandlers]) await handler(epoch);
30694
31109
  await this.deps.kv.set(STORE_KEYS.epochLatch, epoch);
30695
31110
  } catch {
30696
31111
  if (this.epochLatch === epoch) this.epochLatch = previous;
@@ -30727,10 +31142,16 @@ var WalletApiSession = class {
30727
31142
  status() {
30728
31143
  return this.lastStatus ?? "offline";
30729
31144
  }
31145
+ /** Transition feed off the existing emitStatus point (FacadeSession.subscribeStatus). */
31146
+ subscribeStatus(handler) {
31147
+ this.statusHandlers.add(handler);
31148
+ return () => this.statusHandlers.delete(handler);
31149
+ }
30730
31150
  emitStatus(status) {
30731
31151
  if (this.stopped || this.lastStatus === status) return;
30732
31152
  this.lastStatus = status;
30733
31153
  this.deps.emitStatus(status);
31154
+ for (const handler of this.statusHandlers) handler(status);
30734
31155
  }
30735
31156
  async connect() {
30736
31157
  if (this.stopped) return;
@@ -30946,6 +31367,7 @@ var WalletApiDeliveryPort = class {
30946
31367
  wake;
30947
31368
  deriveFn;
30948
31369
  seenChain = new SerialChain();
31370
+ lastIncomingEpoch = null;
30949
31371
  constructor(config) {
30950
31372
  this.client = config.client;
30951
31373
  this.kv = config.kv;
@@ -31040,6 +31462,7 @@ var WalletApiDeliveryPort = class {
31040
31462
  const seen = await this.readSeen();
31041
31463
  let page = await this.client.listMailbox(sinceCursor === void 0 ? 0 : Number(sinceCursor));
31042
31464
  for (; ; ) {
31465
+ this.lastIncomingEpoch = String(page.syncEpoch);
31043
31466
  for (const entry of page.entries) {
31044
31467
  if (entry.status !== "unclaimed") continue;
31045
31468
  if (seen.has(entry.entryId)) continue;
@@ -31049,6 +31472,10 @@ var WalletApiDeliveryPort = class {
31049
31472
  page = await this.client.listMailbox(page.cursor);
31050
31473
  }
31051
31474
  }
31475
+ /** §5.7: the page-honest epoch — the restore-detection source Receive keys on. */
31476
+ incomingEpoch() {
31477
+ return this.lastIncomingEpoch;
31478
+ }
31052
31479
  toIncoming(entry) {
31053
31480
  const bundle = this.openEnvelope(entry);
31054
31481
  return {
@@ -31126,7 +31553,7 @@ var WalletApiSplitCheckpointStore = class {
31126
31553
  return utf8(`${transferId}:${String(opIndex)}`);
31127
31554
  }
31128
31555
  async put(transferId, opIndex, bytes) {
31129
- const { client, kv, fieldKey, signProgress } = this.config;
31556
+ const { kv, fieldKey } = this.config;
31130
31557
  const aad = this.aad(transferId, opIndex);
31131
31558
  const cacheKey = this.cacheKey(transferId, opIndex);
31132
31559
  let envelope = await kv.get(cacheKey);
@@ -31134,10 +31561,21 @@ var WalletApiSplitCheckpointStore = class {
31134
31561
  envelope = encryptFieldBytes(fieldKey, bytes, aad);
31135
31562
  await kv.set(cacheKey, envelope);
31136
31563
  }
31137
- const signature = await signProgress(progressSignMessage(transferId, opIndex, envelope));
31138
- const record = await client.postProgress(transferId, opIndex, envelope, signature);
31564
+ const record = await this.post(transferId, opIndex, envelope);
31139
31565
  return this.open(record.payload, aad);
31140
31566
  }
31567
+ /** §5.1 restore (CheckpointReseeder): re-POST the cached ciphertext byte-identical; false = none cached. */
31568
+ async reseedCheckpoint(transferId, opIndex) {
31569
+ const envelope = await this.config.kv.get(this.cacheKey(transferId, opIndex));
31570
+ if (envelope === null) return false;
31571
+ await this.post(transferId, opIndex, envelope);
31572
+ return true;
31573
+ }
31574
+ /** The ONE signed progress-append path put() and reseedCheckpoint() share. */
31575
+ async post(transferId, opIndex, envelope) {
31576
+ const signature = await this.config.signProgress(progressSignMessage(transferId, opIndex, envelope));
31577
+ return this.config.client.postProgress(transferId, opIndex, envelope, signature);
31578
+ }
31141
31579
  async get(transferId, opIndex) {
31142
31580
  const records = await this.config.client.getProgress(transferId);
31143
31581
  const record = records.find((r) => r.opIndex === opIndex);
@@ -31160,7 +31598,7 @@ var WalletApiSplitCheckpointStore = class {
31160
31598
  };
31161
31599
 
31162
31600
  // core/payments-v2-wiring.ts
31163
- function resolvePaymentsV2Composition(walletApi) {
31601
+ function resolvePaymentsV2Composition(walletApi, sphereNetwork) {
31164
31602
  const source = walletApi;
31165
31603
  const network = typeof source?.network === "string" && source.network !== "" ? source.network : null;
31166
31604
  if (!source || network === null) {
@@ -31169,6 +31607,18 @@ function resolvePaymentsV2Composition(walletApi) {
31169
31607
  "INVALID_CONFIG"
31170
31608
  );
31171
31609
  }
31610
+ if (!(network in NETWORKS)) {
31611
+ throw new SphereError(
31612
+ `paymentsV2: walletApi.network "${network}" is not a known network (${Object.keys(NETWORKS).join("/")}) \u2014 a payments composition is single-network (Sphere network "${String(sphereNetwork)}").`,
31613
+ "INVALID_CONFIG"
31614
+ );
31615
+ }
31616
+ if (network !== sphereNetwork) {
31617
+ throw new SphereError(
31618
+ `paymentsV2: walletApi.network "${network}" does not match the Sphere network "${String(sphereNetwork)}" the engine/registry run on \u2014 a payments composition is single-network.`,
31619
+ "INVALID_CONFIG"
31620
+ );
31621
+ }
31172
31622
  if (typeof source.paymentsV2Transport === "function") {
31173
31623
  return { network, factory: source.paymentsV2Transport.bind(source) };
31174
31624
  }
@@ -31234,8 +31684,7 @@ function defaultPaymentsV2Transport(config) {
31234
31684
  deviceId: `${config.deviceId}:pv2`,
31235
31685
  kv: args.kv,
31236
31686
  webSocketFactory,
31237
- emitStatus: args.emitStatus,
31238
- onEpochChange: args.onEpochChange
31687
+ emitStatus: args.emitStatus
31239
31688
  });
31240
31689
  return { session, client: authedWireClient(session, client) };
31241
31690
  };
@@ -31305,8 +31754,7 @@ function composePaymentsV2(spec) {
31305
31754
  identity: identitySlice,
31306
31755
  network,
31307
31756
  kv,
31308
- emitStatus: (status) => host.emit("connection:status", { status }),
31309
- onEpochChange: async () => void 0
31757
+ emitStatus: (status) => host.emit("connection:status", { status })
31310
31758
  });
31311
31759
  const deps = {
31312
31760
  session: transport.session,
@@ -31336,7 +31784,10 @@ function composePaymentsV2(spec) {
31336
31784
  network,
31337
31785
  ownPubkey: identity.chainPubkey,
31338
31786
  ownNametag: host.nametag,
31339
- requestMemo: requestMemoCodec(identity.privateKey)
31787
+ requestMemo: requestMemoCodec(identity.privateKey),
31788
+ // §5.1: the session is the epoch authority — the facade registers its
31789
+ // restore hook on the session itself (FacadeSession.subscribeEpochChange).
31790
+ syncEpoch: () => transport.session.currentEpoch()
31340
31791
  };
31341
31792
  return new PaymentsFacade(deps);
31342
31793
  }
@@ -31666,18 +32117,20 @@ var Sphere = class _Sphere {
31666
32117
  }
31667
32118
  /**
31668
32119
  * P9: validate + resolve the paymentsV2 opt-in BEFORE any storage writes —
31669
- * fail-closed at init, never a silently-degraded composition.
32120
+ * fail-closed at init, never a silently-degraded composition. Returns the
32121
+ * resolved composition (null = flag off); #728: resolving here also asserts
32122
+ * the single-network invariant (walletApi.network known + equal to the
32123
+ * Sphere network) before any session/KV/provider exists.
31670
32124
  */
31671
- static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi) {
31672
- if (flag !== true) return false;
32125
+ static resolvePaymentsV2Flag(flag, accountingConfig, swapConfig, walletApi, network) {
32126
+ if (flag !== true) return null;
31673
32127
  if (accountingConfig || swapConfig) {
31674
32128
  throw new SphereError(
31675
32129
  "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.",
31676
32130
  "INVALID_CONFIG"
31677
32131
  );
31678
32132
  }
31679
- resolvePaymentsV2Composition(walletApi);
31680
- return true;
32133
+ return resolvePaymentsV2Composition(walletApi, network);
31681
32134
  }
31682
32135
  /**
31683
32136
  * Configure TokenRegistry in the main bundle context.
@@ -31732,12 +32185,14 @@ var Sphere = class _Sphere {
31732
32185
  options.walletApi
31733
32186
  );
31734
32187
  sphere._verification = options.verification;
31735
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32188
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31736
32189
  options.paymentsV2,
31737
32190
  accountingConfig,
31738
32191
  swapConfig,
31739
- options.walletApi
32192
+ options.walletApi,
32193
+ options.network
31740
32194
  );
32195
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31741
32196
  sphere._password = options.password ?? null;
31742
32197
  sphere._passwordProtected = sphere._password !== null;
31743
32198
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -31804,12 +32259,14 @@ var Sphere = class _Sphere {
31804
32259
  options.walletApi
31805
32260
  );
31806
32261
  sphere._verification = options.verification;
31807
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32262
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31808
32263
  options.paymentsV2,
31809
32264
  accountingConfig,
31810
32265
  swapConfig,
31811
- options.walletApi
32266
+ options.walletApi,
32267
+ options.network
31812
32268
  );
32269
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31813
32270
  sphere._password = options.password ?? null;
31814
32271
  sphere._passwordProtected = sphere._password !== null;
31815
32272
  if (!options.storage.isConnected()) {
@@ -31883,12 +32340,14 @@ var Sphere = class _Sphere {
31883
32340
  options.walletApi
31884
32341
  );
31885
32342
  sphere._verification = options.verification;
31886
- sphere._paymentsV2Enabled = _Sphere.resolvePaymentsV2Flag(
32343
+ sphere._paymentsV2Composition = _Sphere.resolvePaymentsV2Flag(
31887
32344
  options.paymentsV2,
31888
32345
  accountingConfig,
31889
32346
  swapConfig,
31890
- options.walletApi
32347
+ options.walletApi,
32348
+ options.network
31891
32349
  );
32350
+ sphere._paymentsV2Enabled = sphere._paymentsV2Composition !== null;
31892
32351
  sphere._password = options.password ?? null;
31893
32352
  sphere._passwordProtected = sphere._password !== null;
31894
32353
  progress?.({ step: "storing_keys", message: "Storing wallet keys..." });
@@ -34546,7 +35005,9 @@ var Sphere = class _Sphere {
34546
35005
  * setOracleApiKey rebuild is what future operations snapshot.
34547
35006
  */
34548
35007
  async startPaymentsV2Inner(index, identity) {
34549
- this._paymentsV2Composition ??= resolvePaymentsV2Composition(this._walletApiTemplate);
35008
+ if (this._paymentsV2Composition === null) {
35009
+ throw new SphereError("paymentsV2: composition unresolved \u2014 init never ran the flag gate", "INVALID_CONFIG");
35010
+ }
34550
35011
  if (!this._addressModules.get(index)?.tokenEngine) {
34551
35012
  throw new SphereError(
34552
35013
  "paymentsV2 requires the v2 token engine \u2014 the oracle must supply a trust base + gateway URL (and API key where required).",