@unicitylabs/sphere-sdk 0.14.3 → 0.14.4

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,86 @@ 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
+ function lateDeliveringSignal(signal) {
9384
+ return new Proxy(signal, {
9385
+ get(target, prop) {
9386
+ if (prop === "addEventListener") {
9387
+ return (type, listener, options) => {
9388
+ if (type === "abort" && target.aborted) {
9389
+ queueMicrotask(() => {
9390
+ listener.call(target, new Event("abort"));
9391
+ });
9392
+ return;
9393
+ }
9394
+ target.addEventListener(type, listener, options);
9395
+ };
9396
+ }
9397
+ const value = Reflect.get(target, prop, target);
9398
+ return typeof value === "function" ? value.bind(target) : value;
9399
+ }
9400
+ });
9401
+ }
9402
+ function rejectOnAbort(signal) {
9403
+ return new Promise((_resolve, reject) => {
9404
+ if (signal.aborted) {
9405
+ reject(signal.reason);
9406
+ return;
9407
+ }
9408
+ signal.addEventListener(
9409
+ "abort",
9410
+ () => {
9411
+ reject(signal.reason);
9412
+ },
9413
+ { once: true }
9414
+ );
9415
+ });
9416
+ }
9417
+ async function awaitProofBounded(deps, transaction, external) {
9418
+ const controller = new AbortController();
9419
+ const relayAbort = () => {
9420
+ controller.abort(external?.reason);
9421
+ };
9422
+ if (external !== void 0) {
9423
+ if (external.aborted) relayAbort();
9424
+ else external.addEventListener("abort", relayAbort, { once: true });
9425
+ }
9426
+ const timer = setTimeout(() => {
9427
+ controller.abort(
9428
+ new SphereError(
9429
+ `inclusion proof not confirmed within ${String(deps.timeoutMs)} ms`,
9430
+ "AGGREGATOR_ERROR"
9431
+ )
9432
+ );
9433
+ }, deps.timeoutMs);
9434
+ const wait = waitInclusionProof(
9435
+ deps.client,
9436
+ deps.trustBase,
9437
+ deps.predicateVerifier,
9438
+ transaction,
9439
+ lateDeliveringSignal(controller.signal),
9440
+ deps.intervalMs
9441
+ );
9442
+ wait.catch(() => void 0);
9443
+ try {
9444
+ return await Promise.race([wait, rejectOnAbort(controller.signal)]);
9445
+ } finally {
9446
+ clearTimeout(timer);
9447
+ external?.removeEventListener("abort", relayAbort);
9448
+ }
9449
+ }
9450
+
9370
9451
  // token-engine/errors.ts
9371
9452
  var TransferConflictError = class extends SphereError {
9372
9453
  /**
@@ -9591,11 +9672,13 @@ var SphereTokenEngine = class {
9591
9672
  constructor(deps) {
9592
9673
  this.deps = deps;
9593
9674
  this.privateKeyHex = HexConverter.encode(deps.privateKey);
9675
+ this.proofTimeoutMs = resolveProofTimeoutMs(deps.proofTimeoutMs);
9594
9676
  }
9595
9677
  /** F13 advertisement: mint under a (transferId, opIndex) seed is idempotent-recoverable — a same-seed re-call converges, never double-mints. */
9596
9678
  deterministicMint = true;
9597
9679
  /** Hex form of the wallet key — deriveRealization's ikm input (Part E.1). */
9598
9680
  privateKeyHex;
9681
+ proofTimeoutMs;
9599
9682
  // ── identity ────────────────────────────────────────────────────────────────
9600
9683
  getIdentity() {
9601
9684
  return { chainPubkey: new Uint8Array(this.deps.signingService.publicKey) };
@@ -9938,14 +10021,17 @@ var SphereTokenEngine = class {
9938
10021
  submitError = err ?? new SphereError(`${failLabel}: submit failed`, failCode);
9939
10022
  }
9940
10023
  try {
9941
- return await waitInclusionProof(
9942
- this.deps.client,
9943
- this.deps.trustBase,
9944
- this.deps.predicateVerifier,
10024
+ return await awaitProofBounded(
10025
+ {
10026
+ client: this.deps.client,
10027
+ trustBase: this.deps.trustBase,
10028
+ predicateVerifier: this.deps.predicateVerifier,
10029
+ timeoutMs: this.proofTimeoutMs,
10030
+ intervalMs: this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10031
+ // #683
10032
+ },
9945
10033
  transaction,
9946
- options?.signal,
9947
- this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
9948
- // #683 finer poll cadence
10034
+ options?.signal
9949
10035
  );
9950
10036
  } catch (err) {
9951
10037
  if (err instanceof Error && err.message === `Invalid inclusion proof status: ${InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH}`) {
@@ -9961,6 +10047,13 @@ var SphereTokenEngine = class {
9961
10047
  );
9962
10048
  }
9963
10049
  }
10050
+ /**
10051
+ * One inclusion-proof wait, with the deadline OWNED here (#739). The SDK's own
10052
+ * default deadline is unreliable — see lateDeliveringSignal — and a lost one
10053
+ * means an op that never settles, which also wedges PaymentsFacade.stop().
10054
+ * A deadline hit throws, and the caller maps that to ProofUnconfirmedError
10055
+ * (keep-open), which is the correct posture: the spend may be on-chain.
10056
+ */
9964
10057
  /** Fail fast if this engine's key does not own the token's current state. */
9965
10058
  assertOwned(token) {
9966
10059
  if (!this.isOwnedBy(token, this.deps.signingService.publicKey)) {
@@ -10060,6 +10153,8 @@ async function createSphereTokenEngine(config) {
10060
10153
  networkId: trustBase.networkId,
10061
10154
  // #683: forward the (optional) proof-poll cadence; undefined → the engine default.
10062
10155
  proofPollIntervalMs: config.proofPollIntervalMs,
10156
+ proofTimeoutMs: config.proofTimeoutMs,
10157
+ // #739: was declared but never wired
10063
10158
  // Opt-in parallel verification (2.0.2). Absent → the sequential verifier.
10064
10159
  ...config.verification ? { tokenVerifier: createWorkerTokenVerifier(config.verification) } : {}
10065
10160
  };
@@ -11969,6 +12064,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
11969
12064
  }
11970
12065
  return out;
11971
12066
  }
12067
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
12068
+ const holds = /* @__PURE__ */ new Map();
12069
+ let complete = true;
12070
+ for (const entry of await stores.backstop.list()) {
12071
+ if (entry.disposition !== "open") continue;
12072
+ let payload = null;
12073
+ try {
12074
+ const raw = await decryptPayload(entry.payloadEnvelope);
12075
+ if (raw !== null && typeof raw === "object") payload = raw;
12076
+ } catch {
12077
+ complete = false;
12078
+ continue;
12079
+ }
12080
+ if (payload === null) {
12081
+ complete = false;
12082
+ continue;
12083
+ }
12084
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
12085
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
12086
+ holds.set(entry.transferId, [...direct, ...split]);
12087
+ }
12088
+ return { open: holds, complete };
12089
+ }
11972
12090
  function groupJournal(journal) {
11973
12091
  const byTransfer = /* @__PURE__ */ new Map();
11974
12092
  for (const entry of journal) {
@@ -12379,10 +12497,21 @@ var InventoryView = class {
12379
12497
  release(tokenId) {
12380
12498
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12381
12499
  }
12500
+ pinned(tokenId) {
12501
+ return this.deps.isPinned?.(tokenId) ?? false;
12502
+ }
12503
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
12504
+ held(tokenId) {
12505
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
12506
+ }
12507
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
12508
+ // and needs the entry to say how much is pinned when it refuses (#737). In
12509
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
12382
12510
  pool(coinId) {
12383
12511
  const out = [];
12384
12512
  for (const [tokenId, entry] of this.mirror) {
12385
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
12513
+ if (entry.status !== "active") continue;
12514
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12386
12515
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12387
12516
  const asset = entry.assets.find((a) => a.coinId === coinId);
12388
12517
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12398,7 +12527,7 @@ var InventoryView = class {
12398
12527
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12399
12528
  out.push(
12400
12529
  toToken(tokenId, entry, asset, registry, {
12401
- transferring: this.inFlight.has(tokenId),
12530
+ transferring: this.held(tokenId),
12402
12531
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12403
12532
  })
12404
12533
  );
@@ -12406,7 +12535,7 @@ var InventoryView = class {
12406
12535
  return out;
12407
12536
  }
12408
12537
  async assets(registry, price) {
12409
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12538
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12410
12539
  if (!price || raw.length === 0) return raw;
12411
12540
  return withPrices(raw, registry, price);
12412
12541
  }
@@ -13092,6 +13221,20 @@ var Requests = class {
13092
13221
  var ReservationLedger = class {
13093
13222
  reservations = /* @__PURE__ */ new Map();
13094
13223
  tokenHolder = /* @__PURE__ */ new Map();
13224
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
13225
+ // IntentPins proves every one of them "no holder" means unknown, not free.
13226
+ authoritative = false;
13227
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
13228
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
13229
+ setAuthoritative(value) {
13230
+ const moved = this.authoritative !== value;
13231
+ this.authoritative = value;
13232
+ return moved;
13233
+ }
13234
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
13235
+ unprovenReason() {
13236
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
13237
+ }
13095
13238
  // All-or-nothing: every entry is validated before any state mutates.
13096
13239
  reserve(reservationId, entries) {
13097
13240
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13118,6 +13261,13 @@ var ReservationLedger = class {
13118
13261
  getFreeAmount(tokenId, tokenAmount) {
13119
13262
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13120
13263
  }
13264
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
13265
+ holderOf(tokenId) {
13266
+ return this.tokenHolder.get(tokenId);
13267
+ }
13268
+ tokensOf(reservationId) {
13269
+ return [...this.reservations.get(reservationId) ?? []];
13270
+ }
13121
13271
  clear() {
13122
13272
  this.reservations.clear();
13123
13273
  this.tokenHolder.clear();
@@ -13130,6 +13280,44 @@ var ReservationLedger = class {
13130
13280
  }
13131
13281
  };
13132
13282
 
13283
+ // modules/payments-v2/select/pins.ts
13284
+ var IntentPins = class {
13285
+ constructor(deps) {
13286
+ this.deps = deps;
13287
+ }
13288
+ pinned = /* @__PURE__ */ new Set();
13289
+ /** The keep-open reservation IS this intent's pin from now on. */
13290
+ adopt(transferId) {
13291
+ this.pinned.add(transferId);
13292
+ }
13293
+ async sync() {
13294
+ const { open, complete } = await this.deps.openIntents();
13295
+ let changed = false;
13296
+ for (const transferId of [...this.pinned]) {
13297
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
13298
+ this.pinned.delete(transferId);
13299
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
13300
+ this.deps.ledger.cancel(transferId);
13301
+ changed = true;
13302
+ }
13303
+ for (const [transferId, tokenIds] of open) {
13304
+ if (this.deps.isActive(transferId)) continue;
13305
+ this.pinned.add(transferId);
13306
+ changed = this.pin(transferId, tokenIds) || changed;
13307
+ }
13308
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
13309
+ if (changed || gateMoved) this.deps.changed();
13310
+ }
13311
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
13312
+ pin(transferId, tokenIds) {
13313
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
13314
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
13315
+ if (free.length === 0) return false;
13316
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
13317
+ return true;
13318
+ }
13319
+ };
13320
+
13133
13321
  // modules/payments-v2/select/selector.ts
13134
13322
  var DEFAULT_WORK_BUDGET = 2e4;
13135
13323
  var MAX_COMBINATION_SIZE = 5;
@@ -13220,6 +13408,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13220
13408
  // modules/payments-v2/select/queue.ts
13221
13409
  var QUEUE_TIMEOUT_MS = 3e4;
13222
13410
  var QUEUE_MAX_SIZE = 100;
13411
+ function insufficientBalance(view, amount) {
13412
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
13413
+ 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";
13414
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
13415
+ }
13223
13416
  var SpendQueue = class {
13224
13417
  constructor(deps) {
13225
13418
  this.deps = deps;
@@ -13232,12 +13425,10 @@ var SpendQueue = class {
13232
13425
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13233
13426
  }
13234
13427
  const amount = parseAmount(request.amount);
13235
- const { freeView, freeTotal } = this.freeView(request.coinId);
13428
+ const view = this.freeView(request.coinId);
13429
+ const { freeView, freeTotal } = view;
13236
13430
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13237
- throw new SphereError(
13238
- "Insufficient balance for this transaction",
13239
- "SEND_INSUFFICIENT_BALANCE"
13240
- );
13431
+ throw insufficientBalance(view, amount);
13241
13432
  }
13242
13433
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13243
13434
  if (plan !== null) {
@@ -13287,11 +13478,10 @@ var SpendQueue = class {
13287
13478
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13288
13479
  return "rejected";
13289
13480
  }
13290
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13481
+ const view = this.freeView(entry.coinId);
13482
+ const { freeView, freeTotal } = view;
13291
13483
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13292
- entry.reject(
13293
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13294
- );
13484
+ entry.reject(insufficientBalance(view, entry.amount));
13295
13485
  return "rejected";
13296
13486
  }
13297
13487
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13336,18 +13526,26 @@ var SpendQueue = class {
13336
13526
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13337
13527
  if (entries.length === 0) this.queues.delete(coinId);
13338
13528
  }
13339
- // Whole-token semantics: only fully-free tokens enter the selector pool.
13529
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
13530
+ // the reservations hold back is counted too — the refusal has to name it (#737).
13340
13531
  freeView(coinId) {
13341
13532
  const freeView = [];
13342
13533
  let freeTotal = 0n;
13534
+ let pinnedTotal = 0n;
13535
+ const holders = /* @__PURE__ */ new Set();
13536
+ const unproven = this.deps.ledger.unprovenReason();
13343
13537
  for (const entry of this.deps.getPool(coinId)) {
13344
13538
  if (entry.amount <= 0n) continue;
13345
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
13539
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
13540
+ if (holder === void 0) {
13346
13541
  freeView.push(entry);
13347
13542
  freeTotal += entry.amount;
13543
+ } else {
13544
+ pinnedTotal += entry.amount;
13545
+ holders.add(holder);
13348
13546
  }
13349
13547
  }
13350
- return { freeView, freeTotal };
13548
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13351
13549
  }
13352
13550
  expectedChangeTotal(coinId) {
13353
13551
  let total = 0n;
@@ -13389,13 +13587,17 @@ function supportsDeterministicMint(engine) {
13389
13587
  }
13390
13588
  function composeFacadeParts(deps, hooks) {
13391
13589
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13590
+ const ledger = new ReservationLedger();
13392
13591
  const view = new InventoryView({
13393
13592
  port: deps.storagePort,
13394
13593
  kv: deps.kv,
13395
13594
  emit: (event) => deps.emit(event, {}),
13595
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
13596
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
13597
+ // must not call a token spendable that the queue is about to refuse to spend.
13598
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
13396
13599
  ...deps.now !== void 0 ? { now: deps.now } : {}
13397
13600
  });
13398
- const ledger = new ReservationLedger();
13399
13601
  const queue = new SpendQueue({
13400
13602
  ledger,
13401
13603
  getPool: (coinId) => view.pool(coinId),
@@ -13416,6 +13618,7 @@ function composeFacadeParts(deps, hooks) {
13416
13618
  ownPubkeyBytes,
13417
13619
  view,
13418
13620
  ledger,
13621
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13419
13622
  queue,
13420
13623
  historyStore,
13421
13624
  machineStores,
@@ -13427,6 +13630,17 @@ function composeFacadeParts(deps, hooks) {
13427
13630
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13428
13631
  };
13429
13632
  }
13633
+ function buildPins(deps, hooks, parts) {
13634
+ return new IntentPins({
13635
+ ledger: parts.ledger,
13636
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
13637
+ isActive: (transferId) => hooks.isActiveOp(transferId),
13638
+ release: (tokenId) => {
13639
+ parts.view.release(tokenId);
13640
+ },
13641
+ changed: () => deps.emit("inventory:updated", {})
13642
+ });
13643
+ }
13430
13644
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13431
13645
  return {
13432
13646
  stores: machineStores,
@@ -13536,7 +13750,8 @@ var PaymentsFacade = class {
13536
13750
  this.deps = deps;
13537
13751
  const parts = composeFacadeParts(deps, {
13538
13752
  engine: () => this.engine(),
13539
- send: (request) => this.send(request)
13753
+ send: (request) => this.send(request),
13754
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13540
13755
  });
13541
13756
  this.view = parts.view;
13542
13757
  this.ledger = parts.ledger;
@@ -13550,6 +13765,7 @@ var PaymentsFacade = class {
13550
13765
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13551
13766
  this.restoreDeps = parts.restoreDeps;
13552
13767
  this.requests = parts.requests;
13768
+ this.pins = parts.pins;
13553
13769
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13554
13770
  this.heartbeat = new ConvergenceHeartbeat({
13555
13771
  now: () => this.nowMs(),
@@ -13592,6 +13808,8 @@ var PaymentsFacade = class {
13592
13808
  passChain = new SerialChain();
13593
13809
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13594
13810
  activeMoneyOps = /* @__PURE__ */ new Set();
13811
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13812
+ pins;
13595
13813
  // ── lifecycle ──────────────────────────────────────────────────────────────
13596
13814
  async start() {
13597
13815
  if (this.started) return;
@@ -13614,6 +13832,7 @@ var PaymentsFacade = class {
13614
13832
  });
13615
13833
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13616
13834
  await this.deps.session.start();
13835
+ await this.pins.sync().catch(() => void 0);
13617
13836
  this.trackTail(this.runConvergencePass());
13618
13837
  this.trackTail(this.seedHeldStates());
13619
13838
  await this.view.fullPull().catch(() => void 0);
@@ -13684,6 +13903,7 @@ var PaymentsFacade = class {
13684
13903
  }
13685
13904
  async convergeBody() {
13686
13905
  const outcome = await this.converger.convergeOnce();
13906
+ await this.pins.sync().catch(() => void 0);
13687
13907
  const pending = await this.converger.pendingWork().catch(() => true);
13688
13908
  this.heartbeat.settle(outcome, pending);
13689
13909
  }
@@ -13923,6 +14143,7 @@ var PaymentsFacade = class {
13923
14143
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
13924
14144
  settleKeepOpen(ctx) {
13925
14145
  this.queue.clearExpectedChange(ctx.transferId);
14146
+ this.pins.adopt(ctx.transferId);
13926
14147
  this.heartbeat.arm();
13927
14148
  }
13928
14149
  settlePartial(ctx, partial) {