@unicitylabs/sphere-sdk 0.14.3 → 0.14.5

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
@@ -9346,6 +9346,7 @@ var import_CertificationResponse = require("@unicitylabs/state-transition-sdk/li
9346
9346
  var import_StateId = require("@unicitylabs/state-transition-sdk/lib/api/StateId.js");
9347
9347
  var import_InclusionProof = require("@unicitylabs/state-transition-sdk/lib/api/InclusionProof.js");
9348
9348
  var import_InclusionProofResponse = require("@unicitylabs/state-transition-sdk/lib/api/InclusionProofResponse.js");
9349
+ var import_JsonRpcNetworkError = require("@unicitylabs/state-transition-sdk/lib/api/json-rpc/JsonRpcNetworkError.js");
9349
9350
  var import_RootTrustBase = require("@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js");
9350
9351
  var import_InclusionProofUtils = require("@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js");
9351
9352
  var import_InclusionProofVerificationRule = require("@unicitylabs/state-transition-sdk/lib/transaction/verification/rule/InclusionProofVerificationRule.js");
@@ -9511,6 +9512,75 @@ function decodeSpherePaymentData(bytes) {
9511
9512
  return Promise.resolve(SpherePaymentData.fromCBOR(bytes));
9512
9513
  }
9513
9514
 
9515
+ // token-engine/proof-wait.ts
9516
+ var DEFAULT_PROOF_TIMEOUT_MS = 1e4;
9517
+ function resolveProofTimeoutMs(configured) {
9518
+ if (configured === void 0) return DEFAULT_PROOF_TIMEOUT_MS;
9519
+ if (!Number.isFinite(configured) || configured <= 0) {
9520
+ throw new SphereError(
9521
+ `proofTimeoutMs must be a positive number of milliseconds (got ${String(configured)}); an unbounded inclusion-proof wait is not supported`,
9522
+ "INVALID_CONFIG"
9523
+ );
9524
+ }
9525
+ return configured;
9526
+ }
9527
+ var TRANSIENT_HTTP_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
9528
+ function isTransientGatewayError(err) {
9529
+ return err instanceof import_JsonRpcNetworkError.JsonRpcNetworkError && TRANSIENT_HTTP_STATUSES.has(err.status);
9530
+ }
9531
+ function pause(ms, signal) {
9532
+ return new Promise((resolve) => {
9533
+ const timer = setTimeout(resolve, ms);
9534
+ signal.addEventListener(
9535
+ "abort",
9536
+ () => {
9537
+ clearTimeout(timer);
9538
+ resolve();
9539
+ },
9540
+ { once: true }
9541
+ );
9542
+ });
9543
+ }
9544
+ async function awaitProofBounded(deps, transaction, external) {
9545
+ const controller = new AbortController();
9546
+ const relayAbort = () => {
9547
+ controller.abort(external?.reason);
9548
+ };
9549
+ if (external !== void 0) {
9550
+ if (external.aborted) relayAbort();
9551
+ else external.addEventListener("abort", relayAbort, { once: true });
9552
+ }
9553
+ const timer = setTimeout(() => {
9554
+ controller.abort(
9555
+ new SphereError(
9556
+ `inclusion proof not confirmed within ${String(deps.timeoutMs)} ms`,
9557
+ "AGGREGATOR_ERROR"
9558
+ )
9559
+ );
9560
+ }, deps.timeoutMs);
9561
+ try {
9562
+ for (; ; ) {
9563
+ try {
9564
+ return await (0, import_InclusionProofUtils.waitInclusionProof)(
9565
+ deps.client,
9566
+ deps.trustBase,
9567
+ deps.predicateVerifier,
9568
+ transaction,
9569
+ controller.signal,
9570
+ deps.intervalMs
9571
+ );
9572
+ } catch (err) {
9573
+ if (controller.signal.aborted) throw err;
9574
+ if (!isTransientGatewayError(err)) throw err;
9575
+ await pause(deps.intervalMs, controller.signal);
9576
+ }
9577
+ }
9578
+ } finally {
9579
+ clearTimeout(timer);
9580
+ external?.removeEventListener("abort", relayAbort);
9581
+ }
9582
+ }
9583
+
9514
9584
  // token-engine/errors.ts
9515
9585
  var TransferConflictError = class extends SphereError {
9516
9586
  /**
@@ -9735,11 +9805,13 @@ var SphereTokenEngine = class {
9735
9805
  constructor(deps) {
9736
9806
  this.deps = deps;
9737
9807
  this.privateKeyHex = import_HexConverter.HexConverter.encode(deps.privateKey);
9808
+ this.proofTimeoutMs = resolveProofTimeoutMs(deps.proofTimeoutMs);
9738
9809
  }
9739
9810
  /** F13 advertisement: mint under a (transferId, opIndex) seed is idempotent-recoverable — a same-seed re-call converges, never double-mints. */
9740
9811
  deterministicMint = true;
9741
9812
  /** Hex form of the wallet key — deriveRealization's ikm input (Part E.1). */
9742
9813
  privateKeyHex;
9814
+ proofTimeoutMs;
9743
9815
  // ── identity ────────────────────────────────────────────────────────────────
9744
9816
  getIdentity() {
9745
9817
  return { chainPubkey: new Uint8Array(this.deps.signingService.publicKey) };
@@ -10082,14 +10154,17 @@ var SphereTokenEngine = class {
10082
10154
  submitError = err ?? new SphereError(`${failLabel}: submit failed`, failCode);
10083
10155
  }
10084
10156
  try {
10085
- return await (0, import_InclusionProofUtils.waitInclusionProof)(
10086
- this.deps.client,
10087
- this.deps.trustBase,
10088
- this.deps.predicateVerifier,
10157
+ return await awaitProofBounded(
10158
+ {
10159
+ client: this.deps.client,
10160
+ trustBase: this.deps.trustBase,
10161
+ predicateVerifier: this.deps.predicateVerifier,
10162
+ timeoutMs: this.proofTimeoutMs,
10163
+ intervalMs: this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10164
+ // #683
10165
+ },
10089
10166
  transaction,
10090
- options?.signal,
10091
- this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10092
- // #683 finer poll cadence
10167
+ options?.signal
10093
10168
  );
10094
10169
  } catch (err) {
10095
10170
  if (err instanceof Error && err.message === `Invalid inclusion proof status: ${import_InclusionProofVerificationRule.InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH}`) {
@@ -10105,6 +10180,13 @@ var SphereTokenEngine = class {
10105
10180
  );
10106
10181
  }
10107
10182
  }
10183
+ /**
10184
+ * One inclusion-proof wait, with the deadline OWNED here (#739). The SDK's own
10185
+ * default deadline is unreliable — see lateDeliveringSignal — and a lost one
10186
+ * means an op that never settles, which also wedges PaymentsFacade.stop().
10187
+ * A deadline hit throws, and the caller maps that to ProofUnconfirmedError
10188
+ * (keep-open), which is the correct posture: the spend may be on-chain.
10189
+ */
10108
10190
  /** Fail fast if this engine's key does not own the token's current state. */
10109
10191
  assertOwned(token) {
10110
10192
  if (!this.isOwnedBy(token, this.deps.signingService.publicKey)) {
@@ -10204,6 +10286,8 @@ async function createSphereTokenEngine(config) {
10204
10286
  networkId: trustBase.networkId,
10205
10287
  // #683: forward the (optional) proof-poll cadence; undefined → the engine default.
10206
10288
  proofPollIntervalMs: config.proofPollIntervalMs,
10289
+ proofTimeoutMs: config.proofTimeoutMs,
10290
+ // #739: was declared but never wired
10207
10291
  // Opt-in parallel verification (2.0.2). Absent → the sequential verifier.
10208
10292
  ...config.verification ? { tokenVerifier: createWorkerTokenVerifier(config.verification) } : {}
10209
10293
  };
@@ -12113,6 +12197,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
12113
12197
  }
12114
12198
  return out;
12115
12199
  }
12200
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
12201
+ const holds = /* @__PURE__ */ new Map();
12202
+ let complete = true;
12203
+ for (const entry of await stores.backstop.list()) {
12204
+ if (entry.disposition !== "open") continue;
12205
+ let payload = null;
12206
+ try {
12207
+ const raw = await decryptPayload(entry.payloadEnvelope);
12208
+ if (raw !== null && typeof raw === "object") payload = raw;
12209
+ } catch {
12210
+ complete = false;
12211
+ continue;
12212
+ }
12213
+ if (payload === null) {
12214
+ complete = false;
12215
+ continue;
12216
+ }
12217
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
12218
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
12219
+ holds.set(entry.transferId, [...direct, ...split]);
12220
+ }
12221
+ return { open: holds, complete };
12222
+ }
12116
12223
  function groupJournal(journal) {
12117
12224
  const byTransfer = /* @__PURE__ */ new Map();
12118
12225
  for (const entry of journal) {
@@ -12523,10 +12630,21 @@ var InventoryView = class {
12523
12630
  release(tokenId) {
12524
12631
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12525
12632
  }
12633
+ pinned(tokenId) {
12634
+ return this.deps.isPinned?.(tokenId) ?? false;
12635
+ }
12636
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
12637
+ held(tokenId) {
12638
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
12639
+ }
12640
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
12641
+ // and needs the entry to say how much is pinned when it refuses (#737). In
12642
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
12526
12643
  pool(coinId) {
12527
12644
  const out = [];
12528
12645
  for (const [tokenId, entry] of this.mirror) {
12529
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
12646
+ if (entry.status !== "active") continue;
12647
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12530
12648
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12531
12649
  const asset = entry.assets.find((a) => a.coinId === coinId);
12532
12650
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12542,7 +12660,7 @@ var InventoryView = class {
12542
12660
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12543
12661
  out.push(
12544
12662
  toToken(tokenId, entry, asset, registry, {
12545
- transferring: this.inFlight.has(tokenId),
12663
+ transferring: this.held(tokenId),
12546
12664
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12547
12665
  })
12548
12666
  );
@@ -12550,7 +12668,7 @@ var InventoryView = class {
12550
12668
  return out;
12551
12669
  }
12552
12670
  async assets(registry, price) {
12553
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12671
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12554
12672
  if (!price || raw.length === 0) return raw;
12555
12673
  return withPrices(raw, registry, price);
12556
12674
  }
@@ -13236,6 +13354,20 @@ var Requests = class {
13236
13354
  var ReservationLedger = class {
13237
13355
  reservations = /* @__PURE__ */ new Map();
13238
13356
  tokenHolder = /* @__PURE__ */ new Map();
13357
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
13358
+ // IntentPins proves every one of them "no holder" means unknown, not free.
13359
+ authoritative = false;
13360
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
13361
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
13362
+ setAuthoritative(value) {
13363
+ const moved = this.authoritative !== value;
13364
+ this.authoritative = value;
13365
+ return moved;
13366
+ }
13367
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
13368
+ unprovenReason() {
13369
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
13370
+ }
13239
13371
  // All-or-nothing: every entry is validated before any state mutates.
13240
13372
  reserve(reservationId, entries) {
13241
13373
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13262,6 +13394,13 @@ var ReservationLedger = class {
13262
13394
  getFreeAmount(tokenId, tokenAmount) {
13263
13395
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13264
13396
  }
13397
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
13398
+ holderOf(tokenId) {
13399
+ return this.tokenHolder.get(tokenId);
13400
+ }
13401
+ tokensOf(reservationId) {
13402
+ return [...this.reservations.get(reservationId) ?? []];
13403
+ }
13265
13404
  clear() {
13266
13405
  this.reservations.clear();
13267
13406
  this.tokenHolder.clear();
@@ -13274,6 +13413,44 @@ var ReservationLedger = class {
13274
13413
  }
13275
13414
  };
13276
13415
 
13416
+ // modules/payments-v2/select/pins.ts
13417
+ var IntentPins = class {
13418
+ constructor(deps) {
13419
+ this.deps = deps;
13420
+ }
13421
+ pinned = /* @__PURE__ */ new Set();
13422
+ /** The keep-open reservation IS this intent's pin from now on. */
13423
+ adopt(transferId) {
13424
+ this.pinned.add(transferId);
13425
+ }
13426
+ async sync() {
13427
+ const { open, complete } = await this.deps.openIntents();
13428
+ let changed = false;
13429
+ for (const transferId of [...this.pinned]) {
13430
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
13431
+ this.pinned.delete(transferId);
13432
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
13433
+ this.deps.ledger.cancel(transferId);
13434
+ changed = true;
13435
+ }
13436
+ for (const [transferId, tokenIds] of open) {
13437
+ if (this.deps.isActive(transferId)) continue;
13438
+ this.pinned.add(transferId);
13439
+ changed = this.pin(transferId, tokenIds) || changed;
13440
+ }
13441
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
13442
+ if (changed || gateMoved) this.deps.changed();
13443
+ }
13444
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
13445
+ pin(transferId, tokenIds) {
13446
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
13447
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
13448
+ if (free.length === 0) return false;
13449
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
13450
+ return true;
13451
+ }
13452
+ };
13453
+
13277
13454
  // modules/payments-v2/select/selector.ts
13278
13455
  var DEFAULT_WORK_BUDGET = 2e4;
13279
13456
  var MAX_COMBINATION_SIZE = 5;
@@ -13364,6 +13541,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13364
13541
  // modules/payments-v2/select/queue.ts
13365
13542
  var QUEUE_TIMEOUT_MS = 3e4;
13366
13543
  var QUEUE_MAX_SIZE = 100;
13544
+ function insufficientBalance(view, amount) {
13545
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
13546
+ const message = view.pinnedTotal > 0n ? `Insufficient spendable balance: need ${amount.toString()}, ${view.freeTotal.toString()} free, ${view.pinnedTotal.toString()} pinned by ${String(view.pinnedBy)} transfer(s) still converging \u2014 see payments.pendingTransfers(); do not re-send.` : "Insufficient balance for this transaction";
13547
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
13548
+ }
13367
13549
  var SpendQueue = class {
13368
13550
  constructor(deps) {
13369
13551
  this.deps = deps;
@@ -13376,12 +13558,10 @@ var SpendQueue = class {
13376
13558
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13377
13559
  }
13378
13560
  const amount = parseAmount(request.amount);
13379
- const { freeView, freeTotal } = this.freeView(request.coinId);
13561
+ const view = this.freeView(request.coinId);
13562
+ const { freeView, freeTotal } = view;
13380
13563
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13381
- throw new SphereError(
13382
- "Insufficient balance for this transaction",
13383
- "SEND_INSUFFICIENT_BALANCE"
13384
- );
13564
+ throw insufficientBalance(view, amount);
13385
13565
  }
13386
13566
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13387
13567
  if (plan !== null) {
@@ -13431,11 +13611,10 @@ var SpendQueue = class {
13431
13611
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13432
13612
  return "rejected";
13433
13613
  }
13434
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13614
+ const view = this.freeView(entry.coinId);
13615
+ const { freeView, freeTotal } = view;
13435
13616
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13436
- entry.reject(
13437
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13438
- );
13617
+ entry.reject(insufficientBalance(view, entry.amount));
13439
13618
  return "rejected";
13440
13619
  }
13441
13620
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13480,18 +13659,26 @@ var SpendQueue = class {
13480
13659
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13481
13660
  if (entries.length === 0) this.queues.delete(coinId);
13482
13661
  }
13483
- // Whole-token semantics: only fully-free tokens enter the selector pool.
13662
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
13663
+ // the reservations hold back is counted too — the refusal has to name it (#737).
13484
13664
  freeView(coinId) {
13485
13665
  const freeView = [];
13486
13666
  let freeTotal = 0n;
13667
+ let pinnedTotal = 0n;
13668
+ const holders = /* @__PURE__ */ new Set();
13669
+ const unproven = this.deps.ledger.unprovenReason();
13487
13670
  for (const entry of this.deps.getPool(coinId)) {
13488
13671
  if (entry.amount <= 0n) continue;
13489
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
13672
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
13673
+ if (holder === void 0) {
13490
13674
  freeView.push(entry);
13491
13675
  freeTotal += entry.amount;
13676
+ } else {
13677
+ pinnedTotal += entry.amount;
13678
+ holders.add(holder);
13492
13679
  }
13493
13680
  }
13494
- return { freeView, freeTotal };
13681
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13495
13682
  }
13496
13683
  expectedChangeTotal(coinId) {
13497
13684
  let total = 0n;
@@ -13533,13 +13720,17 @@ function supportsDeterministicMint(engine) {
13533
13720
  }
13534
13721
  function composeFacadeParts(deps, hooks) {
13535
13722
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13723
+ const ledger = new ReservationLedger();
13536
13724
  const view = new InventoryView({
13537
13725
  port: deps.storagePort,
13538
13726
  kv: deps.kv,
13539
13727
  emit: (event) => deps.emit(event, {}),
13728
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
13729
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
13730
+ // must not call a token spendable that the queue is about to refuse to spend.
13731
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
13540
13732
  ...deps.now !== void 0 ? { now: deps.now } : {}
13541
13733
  });
13542
- const ledger = new ReservationLedger();
13543
13734
  const queue = new SpendQueue({
13544
13735
  ledger,
13545
13736
  getPool: (coinId) => view.pool(coinId),
@@ -13560,6 +13751,7 @@ function composeFacadeParts(deps, hooks) {
13560
13751
  ownPubkeyBytes,
13561
13752
  view,
13562
13753
  ledger,
13754
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13563
13755
  queue,
13564
13756
  historyStore,
13565
13757
  machineStores,
@@ -13571,6 +13763,17 @@ function composeFacadeParts(deps, hooks) {
13571
13763
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13572
13764
  };
13573
13765
  }
13766
+ function buildPins(deps, hooks, parts) {
13767
+ return new IntentPins({
13768
+ ledger: parts.ledger,
13769
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
13770
+ isActive: (transferId) => hooks.isActiveOp(transferId),
13771
+ release: (tokenId) => {
13772
+ parts.view.release(tokenId);
13773
+ },
13774
+ changed: () => deps.emit("inventory:updated", {})
13775
+ });
13776
+ }
13574
13777
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13575
13778
  return {
13576
13779
  stores: machineStores,
@@ -13680,7 +13883,8 @@ var PaymentsFacade = class {
13680
13883
  this.deps = deps;
13681
13884
  const parts = composeFacadeParts(deps, {
13682
13885
  engine: () => this.engine(),
13683
- send: (request) => this.send(request)
13886
+ send: (request) => this.send(request),
13887
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13684
13888
  });
13685
13889
  this.view = parts.view;
13686
13890
  this.ledger = parts.ledger;
@@ -13694,6 +13898,7 @@ var PaymentsFacade = class {
13694
13898
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13695
13899
  this.restoreDeps = parts.restoreDeps;
13696
13900
  this.requests = parts.requests;
13901
+ this.pins = parts.pins;
13697
13902
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13698
13903
  this.heartbeat = new ConvergenceHeartbeat({
13699
13904
  now: () => this.nowMs(),
@@ -13736,6 +13941,8 @@ var PaymentsFacade = class {
13736
13941
  passChain = new SerialChain();
13737
13942
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13738
13943
  activeMoneyOps = /* @__PURE__ */ new Set();
13944
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13945
+ pins;
13739
13946
  // ── lifecycle ──────────────────────────────────────────────────────────────
13740
13947
  async start() {
13741
13948
  if (this.started) return;
@@ -13758,6 +13965,7 @@ var PaymentsFacade = class {
13758
13965
  });
13759
13966
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13760
13967
  await this.deps.session.start();
13968
+ await this.pins.sync().catch(() => void 0);
13761
13969
  this.trackTail(this.runConvergencePass());
13762
13970
  this.trackTail(this.seedHeldStates());
13763
13971
  await this.view.fullPull().catch(() => void 0);
@@ -13828,6 +14036,7 @@ var PaymentsFacade = class {
13828
14036
  }
13829
14037
  async convergeBody() {
13830
14038
  const outcome = await this.converger.convergeOnce();
14039
+ await this.pins.sync().catch(() => void 0);
13831
14040
  const pending = await this.converger.pendingWork().catch(() => true);
13832
14041
  this.heartbeat.settle(outcome, pending);
13833
14042
  }
@@ -14067,6 +14276,7 @@ var PaymentsFacade = class {
14067
14276
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
14068
14277
  settleKeepOpen(ctx) {
14069
14278
  this.queue.clearExpectedChange(ctx.transferId);
14279
+ this.pins.adopt(ctx.transferId);
14070
14280
  this.heartbeat.arm();
14071
14281
  }
14072
14282
  settlePartial(ctx, partial) {