@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.
@@ -9202,6 +9202,7 @@ import { CertificationResponse, CertificationStatus } from "@unicitylabs/state-t
9202
9202
  import { StateId } from "@unicitylabs/state-transition-sdk/lib/api/StateId.js";
9203
9203
  import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProof.js";
9204
9204
  import { InclusionProofResponse } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProofResponse.js";
9205
+ import { JsonRpcNetworkError } from "@unicitylabs/state-transition-sdk/lib/api/json-rpc/JsonRpcNetworkError.js";
9205
9206
  import { RootTrustBase } from "@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js";
9206
9207
  import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js";
9207
9208
  import { InclusionProofVerificationStatus } from "@unicitylabs/state-transition-sdk/lib/transaction/verification/rule/InclusionProofVerificationRule.js";
@@ -9367,6 +9368,75 @@ function decodeSpherePaymentData(bytes) {
9367
9368
  return Promise.resolve(SpherePaymentData.fromCBOR(bytes));
9368
9369
  }
9369
9370
 
9371
+ // token-engine/proof-wait.ts
9372
+ var DEFAULT_PROOF_TIMEOUT_MS = 1e4;
9373
+ function resolveProofTimeoutMs(configured) {
9374
+ if (configured === void 0) return DEFAULT_PROOF_TIMEOUT_MS;
9375
+ if (!Number.isFinite(configured) || configured <= 0) {
9376
+ throw new SphereError(
9377
+ `proofTimeoutMs must be a positive number of milliseconds (got ${String(configured)}); an unbounded inclusion-proof wait is not supported`,
9378
+ "INVALID_CONFIG"
9379
+ );
9380
+ }
9381
+ return configured;
9382
+ }
9383
+ var TRANSIENT_HTTP_STATUSES = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
9384
+ function isTransientGatewayError(err) {
9385
+ return err instanceof JsonRpcNetworkError && TRANSIENT_HTTP_STATUSES.has(err.status);
9386
+ }
9387
+ function pause(ms, signal) {
9388
+ return new Promise((resolve) => {
9389
+ const timer = setTimeout(resolve, ms);
9390
+ signal.addEventListener(
9391
+ "abort",
9392
+ () => {
9393
+ clearTimeout(timer);
9394
+ resolve();
9395
+ },
9396
+ { once: true }
9397
+ );
9398
+ });
9399
+ }
9400
+ async function awaitProofBounded(deps, transaction, external) {
9401
+ const controller = new AbortController();
9402
+ const relayAbort = () => {
9403
+ controller.abort(external?.reason);
9404
+ };
9405
+ if (external !== void 0) {
9406
+ if (external.aborted) relayAbort();
9407
+ else external.addEventListener("abort", relayAbort, { once: true });
9408
+ }
9409
+ const timer = setTimeout(() => {
9410
+ controller.abort(
9411
+ new SphereError(
9412
+ `inclusion proof not confirmed within ${String(deps.timeoutMs)} ms`,
9413
+ "AGGREGATOR_ERROR"
9414
+ )
9415
+ );
9416
+ }, deps.timeoutMs);
9417
+ try {
9418
+ for (; ; ) {
9419
+ try {
9420
+ return await waitInclusionProof(
9421
+ deps.client,
9422
+ deps.trustBase,
9423
+ deps.predicateVerifier,
9424
+ transaction,
9425
+ controller.signal,
9426
+ deps.intervalMs
9427
+ );
9428
+ } catch (err) {
9429
+ if (controller.signal.aborted) throw err;
9430
+ if (!isTransientGatewayError(err)) throw err;
9431
+ await pause(deps.intervalMs, controller.signal);
9432
+ }
9433
+ }
9434
+ } finally {
9435
+ clearTimeout(timer);
9436
+ external?.removeEventListener("abort", relayAbort);
9437
+ }
9438
+ }
9439
+
9370
9440
  // token-engine/errors.ts
9371
9441
  var TransferConflictError = class extends SphereError {
9372
9442
  /**
@@ -9591,11 +9661,13 @@ var SphereTokenEngine = class {
9591
9661
  constructor(deps) {
9592
9662
  this.deps = deps;
9593
9663
  this.privateKeyHex = HexConverter.encode(deps.privateKey);
9664
+ this.proofTimeoutMs = resolveProofTimeoutMs(deps.proofTimeoutMs);
9594
9665
  }
9595
9666
  /** F13 advertisement: mint under a (transferId, opIndex) seed is idempotent-recoverable — a same-seed re-call converges, never double-mints. */
9596
9667
  deterministicMint = true;
9597
9668
  /** Hex form of the wallet key — deriveRealization's ikm input (Part E.1). */
9598
9669
  privateKeyHex;
9670
+ proofTimeoutMs;
9599
9671
  // ── identity ────────────────────────────────────────────────────────────────
9600
9672
  getIdentity() {
9601
9673
  return { chainPubkey: new Uint8Array(this.deps.signingService.publicKey) };
@@ -9938,14 +10010,17 @@ var SphereTokenEngine = class {
9938
10010
  submitError = err ?? new SphereError(`${failLabel}: submit failed`, failCode);
9939
10011
  }
9940
10012
  try {
9941
- return await waitInclusionProof(
9942
- this.deps.client,
9943
- this.deps.trustBase,
9944
- this.deps.predicateVerifier,
10013
+ return await awaitProofBounded(
10014
+ {
10015
+ client: this.deps.client,
10016
+ trustBase: this.deps.trustBase,
10017
+ predicateVerifier: this.deps.predicateVerifier,
10018
+ timeoutMs: this.proofTimeoutMs,
10019
+ intervalMs: this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10020
+ // #683
10021
+ },
9945
10022
  transaction,
9946
- options?.signal,
9947
- this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
9948
- // #683 finer poll cadence
10023
+ options?.signal
9949
10024
  );
9950
10025
  } catch (err) {
9951
10026
  if (err instanceof Error && err.message === `Invalid inclusion proof status: ${InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH}`) {
@@ -9961,6 +10036,13 @@ var SphereTokenEngine = class {
9961
10036
  );
9962
10037
  }
9963
10038
  }
10039
+ /**
10040
+ * One inclusion-proof wait, with the deadline OWNED here (#739). The SDK's own
10041
+ * default deadline is unreliable — see lateDeliveringSignal — and a lost one
10042
+ * means an op that never settles, which also wedges PaymentsFacade.stop().
10043
+ * A deadline hit throws, and the caller maps that to ProofUnconfirmedError
10044
+ * (keep-open), which is the correct posture: the spend may be on-chain.
10045
+ */
9964
10046
  /** Fail fast if this engine's key does not own the token's current state. */
9965
10047
  assertOwned(token) {
9966
10048
  if (!this.isOwnedBy(token, this.deps.signingService.publicKey)) {
@@ -10060,6 +10142,8 @@ async function createSphereTokenEngine(config) {
10060
10142
  networkId: trustBase.networkId,
10061
10143
  // #683: forward the (optional) proof-poll cadence; undefined → the engine default.
10062
10144
  proofPollIntervalMs: config.proofPollIntervalMs,
10145
+ proofTimeoutMs: config.proofTimeoutMs,
10146
+ // #739: was declared but never wired
10063
10147
  // Opt-in parallel verification (2.0.2). Absent → the sequential verifier.
10064
10148
  ...config.verification ? { tokenVerifier: createWorkerTokenVerifier(config.verification) } : {}
10065
10149
  };
@@ -11969,6 +12053,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
11969
12053
  }
11970
12054
  return out;
11971
12055
  }
12056
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
12057
+ const holds = /* @__PURE__ */ new Map();
12058
+ let complete = true;
12059
+ for (const entry of await stores.backstop.list()) {
12060
+ if (entry.disposition !== "open") continue;
12061
+ let payload = null;
12062
+ try {
12063
+ const raw = await decryptPayload(entry.payloadEnvelope);
12064
+ if (raw !== null && typeof raw === "object") payload = raw;
12065
+ } catch {
12066
+ complete = false;
12067
+ continue;
12068
+ }
12069
+ if (payload === null) {
12070
+ complete = false;
12071
+ continue;
12072
+ }
12073
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
12074
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
12075
+ holds.set(entry.transferId, [...direct, ...split]);
12076
+ }
12077
+ return { open: holds, complete };
12078
+ }
11972
12079
  function groupJournal(journal) {
11973
12080
  const byTransfer = /* @__PURE__ */ new Map();
11974
12081
  for (const entry of journal) {
@@ -12379,10 +12486,21 @@ var InventoryView = class {
12379
12486
  release(tokenId) {
12380
12487
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12381
12488
  }
12489
+ pinned(tokenId) {
12490
+ return this.deps.isPinned?.(tokenId) ?? false;
12491
+ }
12492
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
12493
+ held(tokenId) {
12494
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
12495
+ }
12496
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
12497
+ // and needs the entry to say how much is pinned when it refuses (#737). In
12498
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
12382
12499
  pool(coinId) {
12383
12500
  const out = [];
12384
12501
  for (const [tokenId, entry] of this.mirror) {
12385
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
12502
+ if (entry.status !== "active") continue;
12503
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12386
12504
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12387
12505
  const asset = entry.assets.find((a) => a.coinId === coinId);
12388
12506
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12398,7 +12516,7 @@ var InventoryView = class {
12398
12516
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12399
12517
  out.push(
12400
12518
  toToken(tokenId, entry, asset, registry, {
12401
- transferring: this.inFlight.has(tokenId),
12519
+ transferring: this.held(tokenId),
12402
12520
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12403
12521
  })
12404
12522
  );
@@ -12406,7 +12524,7 @@ var InventoryView = class {
12406
12524
  return out;
12407
12525
  }
12408
12526
  async assets(registry, price) {
12409
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12527
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12410
12528
  if (!price || raw.length === 0) return raw;
12411
12529
  return withPrices(raw, registry, price);
12412
12530
  }
@@ -13092,6 +13210,20 @@ var Requests = class {
13092
13210
  var ReservationLedger = class {
13093
13211
  reservations = /* @__PURE__ */ new Map();
13094
13212
  tokenHolder = /* @__PURE__ */ new Map();
13213
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
13214
+ // IntentPins proves every one of them "no holder" means unknown, not free.
13215
+ authoritative = false;
13216
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
13217
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
13218
+ setAuthoritative(value) {
13219
+ const moved = this.authoritative !== value;
13220
+ this.authoritative = value;
13221
+ return moved;
13222
+ }
13223
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
13224
+ unprovenReason() {
13225
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
13226
+ }
13095
13227
  // All-or-nothing: every entry is validated before any state mutates.
13096
13228
  reserve(reservationId, entries) {
13097
13229
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13118,6 +13250,13 @@ var ReservationLedger = class {
13118
13250
  getFreeAmount(tokenId, tokenAmount) {
13119
13251
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13120
13252
  }
13253
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
13254
+ holderOf(tokenId) {
13255
+ return this.tokenHolder.get(tokenId);
13256
+ }
13257
+ tokensOf(reservationId) {
13258
+ return [...this.reservations.get(reservationId) ?? []];
13259
+ }
13121
13260
  clear() {
13122
13261
  this.reservations.clear();
13123
13262
  this.tokenHolder.clear();
@@ -13130,6 +13269,44 @@ var ReservationLedger = class {
13130
13269
  }
13131
13270
  };
13132
13271
 
13272
+ // modules/payments-v2/select/pins.ts
13273
+ var IntentPins = class {
13274
+ constructor(deps) {
13275
+ this.deps = deps;
13276
+ }
13277
+ pinned = /* @__PURE__ */ new Set();
13278
+ /** The keep-open reservation IS this intent's pin from now on. */
13279
+ adopt(transferId) {
13280
+ this.pinned.add(transferId);
13281
+ }
13282
+ async sync() {
13283
+ const { open, complete } = await this.deps.openIntents();
13284
+ let changed = false;
13285
+ for (const transferId of [...this.pinned]) {
13286
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
13287
+ this.pinned.delete(transferId);
13288
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
13289
+ this.deps.ledger.cancel(transferId);
13290
+ changed = true;
13291
+ }
13292
+ for (const [transferId, tokenIds] of open) {
13293
+ if (this.deps.isActive(transferId)) continue;
13294
+ this.pinned.add(transferId);
13295
+ changed = this.pin(transferId, tokenIds) || changed;
13296
+ }
13297
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
13298
+ if (changed || gateMoved) this.deps.changed();
13299
+ }
13300
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
13301
+ pin(transferId, tokenIds) {
13302
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
13303
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
13304
+ if (free.length === 0) return false;
13305
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
13306
+ return true;
13307
+ }
13308
+ };
13309
+
13133
13310
  // modules/payments-v2/select/selector.ts
13134
13311
  var DEFAULT_WORK_BUDGET = 2e4;
13135
13312
  var MAX_COMBINATION_SIZE = 5;
@@ -13220,6 +13397,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13220
13397
  // modules/payments-v2/select/queue.ts
13221
13398
  var QUEUE_TIMEOUT_MS = 3e4;
13222
13399
  var QUEUE_MAX_SIZE = 100;
13400
+ function insufficientBalance(view, amount) {
13401
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
13402
+ 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";
13403
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
13404
+ }
13223
13405
  var SpendQueue = class {
13224
13406
  constructor(deps) {
13225
13407
  this.deps = deps;
@@ -13232,12 +13414,10 @@ var SpendQueue = class {
13232
13414
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13233
13415
  }
13234
13416
  const amount = parseAmount(request.amount);
13235
- const { freeView, freeTotal } = this.freeView(request.coinId);
13417
+ const view = this.freeView(request.coinId);
13418
+ const { freeView, freeTotal } = view;
13236
13419
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13237
- throw new SphereError(
13238
- "Insufficient balance for this transaction",
13239
- "SEND_INSUFFICIENT_BALANCE"
13240
- );
13420
+ throw insufficientBalance(view, amount);
13241
13421
  }
13242
13422
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13243
13423
  if (plan !== null) {
@@ -13287,11 +13467,10 @@ var SpendQueue = class {
13287
13467
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13288
13468
  return "rejected";
13289
13469
  }
13290
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13470
+ const view = this.freeView(entry.coinId);
13471
+ const { freeView, freeTotal } = view;
13291
13472
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13292
- entry.reject(
13293
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13294
- );
13473
+ entry.reject(insufficientBalance(view, entry.amount));
13295
13474
  return "rejected";
13296
13475
  }
13297
13476
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13336,18 +13515,26 @@ var SpendQueue = class {
13336
13515
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13337
13516
  if (entries.length === 0) this.queues.delete(coinId);
13338
13517
  }
13339
- // Whole-token semantics: only fully-free tokens enter the selector pool.
13518
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
13519
+ // the reservations hold back is counted too — the refusal has to name it (#737).
13340
13520
  freeView(coinId) {
13341
13521
  const freeView = [];
13342
13522
  let freeTotal = 0n;
13523
+ let pinnedTotal = 0n;
13524
+ const holders = /* @__PURE__ */ new Set();
13525
+ const unproven = this.deps.ledger.unprovenReason();
13343
13526
  for (const entry of this.deps.getPool(coinId)) {
13344
13527
  if (entry.amount <= 0n) continue;
13345
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
13528
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
13529
+ if (holder === void 0) {
13346
13530
  freeView.push(entry);
13347
13531
  freeTotal += entry.amount;
13532
+ } else {
13533
+ pinnedTotal += entry.amount;
13534
+ holders.add(holder);
13348
13535
  }
13349
13536
  }
13350
- return { freeView, freeTotal };
13537
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13351
13538
  }
13352
13539
  expectedChangeTotal(coinId) {
13353
13540
  let total = 0n;
@@ -13389,13 +13576,17 @@ function supportsDeterministicMint(engine) {
13389
13576
  }
13390
13577
  function composeFacadeParts(deps, hooks) {
13391
13578
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13579
+ const ledger = new ReservationLedger();
13392
13580
  const view = new InventoryView({
13393
13581
  port: deps.storagePort,
13394
13582
  kv: deps.kv,
13395
13583
  emit: (event) => deps.emit(event, {}),
13584
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
13585
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
13586
+ // must not call a token spendable that the queue is about to refuse to spend.
13587
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
13396
13588
  ...deps.now !== void 0 ? { now: deps.now } : {}
13397
13589
  });
13398
- const ledger = new ReservationLedger();
13399
13590
  const queue = new SpendQueue({
13400
13591
  ledger,
13401
13592
  getPool: (coinId) => view.pool(coinId),
@@ -13416,6 +13607,7 @@ function composeFacadeParts(deps, hooks) {
13416
13607
  ownPubkeyBytes,
13417
13608
  view,
13418
13609
  ledger,
13610
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13419
13611
  queue,
13420
13612
  historyStore,
13421
13613
  machineStores,
@@ -13427,6 +13619,17 @@ function composeFacadeParts(deps, hooks) {
13427
13619
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13428
13620
  };
13429
13621
  }
13622
+ function buildPins(deps, hooks, parts) {
13623
+ return new IntentPins({
13624
+ ledger: parts.ledger,
13625
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
13626
+ isActive: (transferId) => hooks.isActiveOp(transferId),
13627
+ release: (tokenId) => {
13628
+ parts.view.release(tokenId);
13629
+ },
13630
+ changed: () => deps.emit("inventory:updated", {})
13631
+ });
13632
+ }
13430
13633
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13431
13634
  return {
13432
13635
  stores: machineStores,
@@ -13536,7 +13739,8 @@ var PaymentsFacade = class {
13536
13739
  this.deps = deps;
13537
13740
  const parts = composeFacadeParts(deps, {
13538
13741
  engine: () => this.engine(),
13539
- send: (request) => this.send(request)
13742
+ send: (request) => this.send(request),
13743
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13540
13744
  });
13541
13745
  this.view = parts.view;
13542
13746
  this.ledger = parts.ledger;
@@ -13550,6 +13754,7 @@ var PaymentsFacade = class {
13550
13754
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13551
13755
  this.restoreDeps = parts.restoreDeps;
13552
13756
  this.requests = parts.requests;
13757
+ this.pins = parts.pins;
13553
13758
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13554
13759
  this.heartbeat = new ConvergenceHeartbeat({
13555
13760
  now: () => this.nowMs(),
@@ -13592,6 +13797,8 @@ var PaymentsFacade = class {
13592
13797
  passChain = new SerialChain();
13593
13798
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13594
13799
  activeMoneyOps = /* @__PURE__ */ new Set();
13800
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13801
+ pins;
13595
13802
  // ── lifecycle ──────────────────────────────────────────────────────────────
13596
13803
  async start() {
13597
13804
  if (this.started) return;
@@ -13614,6 +13821,7 @@ var PaymentsFacade = class {
13614
13821
  });
13615
13822
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13616
13823
  await this.deps.session.start();
13824
+ await this.pins.sync().catch(() => void 0);
13617
13825
  this.trackTail(this.runConvergencePass());
13618
13826
  this.trackTail(this.seedHeldStates());
13619
13827
  await this.view.fullPull().catch(() => void 0);
@@ -13684,6 +13892,7 @@ var PaymentsFacade = class {
13684
13892
  }
13685
13893
  async convergeBody() {
13686
13894
  const outcome = await this.converger.convergeOnce();
13895
+ await this.pins.sync().catch(() => void 0);
13687
13896
  const pending = await this.converger.pendingWork().catch(() => true);
13688
13897
  this.heartbeat.settle(outcome, pending);
13689
13898
  }
@@ -13923,6 +14132,7 @@ var PaymentsFacade = class {
13923
14132
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
13924
14133
  settleKeepOpen(ctx) {
13925
14134
  this.queue.clearExpectedChange(ctx.transferId);
14135
+ this.pins.adopt(ctx.transferId);
13926
14136
  this.heartbeat.arm();
13927
14137
  }
13928
14138
  settlePartial(ctx, partial) {