@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.
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,86 @@ 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
+ function lateDeliveringSignal(signal) {
9528
+ return new Proxy(signal, {
9529
+ get(target, prop) {
9530
+ if (prop === "addEventListener") {
9531
+ return (type, listener, options) => {
9532
+ if (type === "abort" && target.aborted) {
9533
+ queueMicrotask(() => {
9534
+ listener.call(target, new Event("abort"));
9535
+ });
9536
+ return;
9537
+ }
9538
+ target.addEventListener(type, listener, options);
9539
+ };
9540
+ }
9541
+ const value = Reflect.get(target, prop, target);
9542
+ return typeof value === "function" ? value.bind(target) : value;
9543
+ }
9544
+ });
9545
+ }
9546
+ function rejectOnAbort(signal) {
9547
+ return new Promise((_resolve, reject) => {
9548
+ if (signal.aborted) {
9549
+ reject(signal.reason);
9550
+ return;
9551
+ }
9552
+ signal.addEventListener(
9553
+ "abort",
9554
+ () => {
9555
+ reject(signal.reason);
9556
+ },
9557
+ { once: true }
9558
+ );
9559
+ });
9560
+ }
9561
+ async function awaitProofBounded(deps, transaction, external) {
9562
+ const controller = new AbortController();
9563
+ const relayAbort = () => {
9564
+ controller.abort(external?.reason);
9565
+ };
9566
+ if (external !== void 0) {
9567
+ if (external.aborted) relayAbort();
9568
+ else external.addEventListener("abort", relayAbort, { once: true });
9569
+ }
9570
+ const timer = setTimeout(() => {
9571
+ controller.abort(
9572
+ new SphereError(
9573
+ `inclusion proof not confirmed within ${String(deps.timeoutMs)} ms`,
9574
+ "AGGREGATOR_ERROR"
9575
+ )
9576
+ );
9577
+ }, deps.timeoutMs);
9578
+ const wait = (0, import_InclusionProofUtils.waitInclusionProof)(
9579
+ deps.client,
9580
+ deps.trustBase,
9581
+ deps.predicateVerifier,
9582
+ transaction,
9583
+ lateDeliveringSignal(controller.signal),
9584
+ deps.intervalMs
9585
+ );
9586
+ wait.catch(() => void 0);
9587
+ try {
9588
+ return await Promise.race([wait, rejectOnAbort(controller.signal)]);
9589
+ } finally {
9590
+ clearTimeout(timer);
9591
+ external?.removeEventListener("abort", relayAbort);
9592
+ }
9593
+ }
9594
+
9514
9595
  // token-engine/errors.ts
9515
9596
  var TransferConflictError = class extends SphereError {
9516
9597
  /**
@@ -9735,11 +9816,13 @@ var SphereTokenEngine = class {
9735
9816
  constructor(deps) {
9736
9817
  this.deps = deps;
9737
9818
  this.privateKeyHex = import_HexConverter.HexConverter.encode(deps.privateKey);
9819
+ this.proofTimeoutMs = resolveProofTimeoutMs(deps.proofTimeoutMs);
9738
9820
  }
9739
9821
  /** F13 advertisement: mint under a (transferId, opIndex) seed is idempotent-recoverable — a same-seed re-call converges, never double-mints. */
9740
9822
  deterministicMint = true;
9741
9823
  /** Hex form of the wallet key — deriveRealization's ikm input (Part E.1). */
9742
9824
  privateKeyHex;
9825
+ proofTimeoutMs;
9743
9826
  // ── identity ────────────────────────────────────────────────────────────────
9744
9827
  getIdentity() {
9745
9828
  return { chainPubkey: new Uint8Array(this.deps.signingService.publicKey) };
@@ -10082,14 +10165,17 @@ var SphereTokenEngine = class {
10082
10165
  submitError = err ?? new SphereError(`${failLabel}: submit failed`, failCode);
10083
10166
  }
10084
10167
  try {
10085
- return await (0, import_InclusionProofUtils.waitInclusionProof)(
10086
- this.deps.client,
10087
- this.deps.trustBase,
10088
- this.deps.predicateVerifier,
10168
+ return await awaitProofBounded(
10169
+ {
10170
+ client: this.deps.client,
10171
+ trustBase: this.deps.trustBase,
10172
+ predicateVerifier: this.deps.predicateVerifier,
10173
+ timeoutMs: this.proofTimeoutMs,
10174
+ intervalMs: this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10175
+ // #683
10176
+ },
10089
10177
  transaction,
10090
- options?.signal,
10091
- this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10092
- // #683 finer poll cadence
10178
+ options?.signal
10093
10179
  );
10094
10180
  } catch (err) {
10095
10181
  if (err instanceof Error && err.message === `Invalid inclusion proof status: ${import_InclusionProofVerificationRule.InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH}`) {
@@ -10105,6 +10191,13 @@ var SphereTokenEngine = class {
10105
10191
  );
10106
10192
  }
10107
10193
  }
10194
+ /**
10195
+ * One inclusion-proof wait, with the deadline OWNED here (#739). The SDK's own
10196
+ * default deadline is unreliable — see lateDeliveringSignal — and a lost one
10197
+ * means an op that never settles, which also wedges PaymentsFacade.stop().
10198
+ * A deadline hit throws, and the caller maps that to ProofUnconfirmedError
10199
+ * (keep-open), which is the correct posture: the spend may be on-chain.
10200
+ */
10108
10201
  /** Fail fast if this engine's key does not own the token's current state. */
10109
10202
  assertOwned(token) {
10110
10203
  if (!this.isOwnedBy(token, this.deps.signingService.publicKey)) {
@@ -10204,6 +10297,8 @@ async function createSphereTokenEngine(config) {
10204
10297
  networkId: trustBase.networkId,
10205
10298
  // #683: forward the (optional) proof-poll cadence; undefined → the engine default.
10206
10299
  proofPollIntervalMs: config.proofPollIntervalMs,
10300
+ proofTimeoutMs: config.proofTimeoutMs,
10301
+ // #739: was declared but never wired
10207
10302
  // Opt-in parallel verification (2.0.2). Absent → the sequential verifier.
10208
10303
  ...config.verification ? { tokenVerifier: createWorkerTokenVerifier(config.verification) } : {}
10209
10304
  };
@@ -12113,6 +12208,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
12113
12208
  }
12114
12209
  return out;
12115
12210
  }
12211
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
12212
+ const holds = /* @__PURE__ */ new Map();
12213
+ let complete = true;
12214
+ for (const entry of await stores.backstop.list()) {
12215
+ if (entry.disposition !== "open") continue;
12216
+ let payload = null;
12217
+ try {
12218
+ const raw = await decryptPayload(entry.payloadEnvelope);
12219
+ if (raw !== null && typeof raw === "object") payload = raw;
12220
+ } catch {
12221
+ complete = false;
12222
+ continue;
12223
+ }
12224
+ if (payload === null) {
12225
+ complete = false;
12226
+ continue;
12227
+ }
12228
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
12229
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
12230
+ holds.set(entry.transferId, [...direct, ...split]);
12231
+ }
12232
+ return { open: holds, complete };
12233
+ }
12116
12234
  function groupJournal(journal) {
12117
12235
  const byTransfer = /* @__PURE__ */ new Map();
12118
12236
  for (const entry of journal) {
@@ -12523,10 +12641,21 @@ var InventoryView = class {
12523
12641
  release(tokenId) {
12524
12642
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12525
12643
  }
12644
+ pinned(tokenId) {
12645
+ return this.deps.isPinned?.(tokenId) ?? false;
12646
+ }
12647
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
12648
+ held(tokenId) {
12649
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
12650
+ }
12651
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
12652
+ // and needs the entry to say how much is pinned when it refuses (#737). In
12653
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
12526
12654
  pool(coinId) {
12527
12655
  const out = [];
12528
12656
  for (const [tokenId, entry] of this.mirror) {
12529
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
12657
+ if (entry.status !== "active") continue;
12658
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12530
12659
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12531
12660
  const asset = entry.assets.find((a) => a.coinId === coinId);
12532
12661
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12542,7 +12671,7 @@ var InventoryView = class {
12542
12671
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12543
12672
  out.push(
12544
12673
  toToken(tokenId, entry, asset, registry, {
12545
- transferring: this.inFlight.has(tokenId),
12674
+ transferring: this.held(tokenId),
12546
12675
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12547
12676
  })
12548
12677
  );
@@ -12550,7 +12679,7 @@ var InventoryView = class {
12550
12679
  return out;
12551
12680
  }
12552
12681
  async assets(registry, price) {
12553
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12682
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12554
12683
  if (!price || raw.length === 0) return raw;
12555
12684
  return withPrices(raw, registry, price);
12556
12685
  }
@@ -13236,6 +13365,20 @@ var Requests = class {
13236
13365
  var ReservationLedger = class {
13237
13366
  reservations = /* @__PURE__ */ new Map();
13238
13367
  tokenHolder = /* @__PURE__ */ new Map();
13368
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
13369
+ // IntentPins proves every one of them "no holder" means unknown, not free.
13370
+ authoritative = false;
13371
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
13372
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
13373
+ setAuthoritative(value) {
13374
+ const moved = this.authoritative !== value;
13375
+ this.authoritative = value;
13376
+ return moved;
13377
+ }
13378
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
13379
+ unprovenReason() {
13380
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
13381
+ }
13239
13382
  // All-or-nothing: every entry is validated before any state mutates.
13240
13383
  reserve(reservationId, entries) {
13241
13384
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13262,6 +13405,13 @@ var ReservationLedger = class {
13262
13405
  getFreeAmount(tokenId, tokenAmount) {
13263
13406
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13264
13407
  }
13408
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
13409
+ holderOf(tokenId) {
13410
+ return this.tokenHolder.get(tokenId);
13411
+ }
13412
+ tokensOf(reservationId) {
13413
+ return [...this.reservations.get(reservationId) ?? []];
13414
+ }
13265
13415
  clear() {
13266
13416
  this.reservations.clear();
13267
13417
  this.tokenHolder.clear();
@@ -13274,6 +13424,44 @@ var ReservationLedger = class {
13274
13424
  }
13275
13425
  };
13276
13426
 
13427
+ // modules/payments-v2/select/pins.ts
13428
+ var IntentPins = class {
13429
+ constructor(deps) {
13430
+ this.deps = deps;
13431
+ }
13432
+ pinned = /* @__PURE__ */ new Set();
13433
+ /** The keep-open reservation IS this intent's pin from now on. */
13434
+ adopt(transferId) {
13435
+ this.pinned.add(transferId);
13436
+ }
13437
+ async sync() {
13438
+ const { open, complete } = await this.deps.openIntents();
13439
+ let changed = false;
13440
+ for (const transferId of [...this.pinned]) {
13441
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
13442
+ this.pinned.delete(transferId);
13443
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
13444
+ this.deps.ledger.cancel(transferId);
13445
+ changed = true;
13446
+ }
13447
+ for (const [transferId, tokenIds] of open) {
13448
+ if (this.deps.isActive(transferId)) continue;
13449
+ this.pinned.add(transferId);
13450
+ changed = this.pin(transferId, tokenIds) || changed;
13451
+ }
13452
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
13453
+ if (changed || gateMoved) this.deps.changed();
13454
+ }
13455
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
13456
+ pin(transferId, tokenIds) {
13457
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
13458
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
13459
+ if (free.length === 0) return false;
13460
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
13461
+ return true;
13462
+ }
13463
+ };
13464
+
13277
13465
  // modules/payments-v2/select/selector.ts
13278
13466
  var DEFAULT_WORK_BUDGET = 2e4;
13279
13467
  var MAX_COMBINATION_SIZE = 5;
@@ -13364,6 +13552,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13364
13552
  // modules/payments-v2/select/queue.ts
13365
13553
  var QUEUE_TIMEOUT_MS = 3e4;
13366
13554
  var QUEUE_MAX_SIZE = 100;
13555
+ function insufficientBalance(view, amount) {
13556
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
13557
+ 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";
13558
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
13559
+ }
13367
13560
  var SpendQueue = class {
13368
13561
  constructor(deps) {
13369
13562
  this.deps = deps;
@@ -13376,12 +13569,10 @@ var SpendQueue = class {
13376
13569
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13377
13570
  }
13378
13571
  const amount = parseAmount(request.amount);
13379
- const { freeView, freeTotal } = this.freeView(request.coinId);
13572
+ const view = this.freeView(request.coinId);
13573
+ const { freeView, freeTotal } = view;
13380
13574
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13381
- throw new SphereError(
13382
- "Insufficient balance for this transaction",
13383
- "SEND_INSUFFICIENT_BALANCE"
13384
- );
13575
+ throw insufficientBalance(view, amount);
13385
13576
  }
13386
13577
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13387
13578
  if (plan !== null) {
@@ -13431,11 +13622,10 @@ var SpendQueue = class {
13431
13622
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13432
13623
  return "rejected";
13433
13624
  }
13434
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13625
+ const view = this.freeView(entry.coinId);
13626
+ const { freeView, freeTotal } = view;
13435
13627
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13436
- entry.reject(
13437
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13438
- );
13628
+ entry.reject(insufficientBalance(view, entry.amount));
13439
13629
  return "rejected";
13440
13630
  }
13441
13631
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13480,18 +13670,26 @@ var SpendQueue = class {
13480
13670
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13481
13671
  if (entries.length === 0) this.queues.delete(coinId);
13482
13672
  }
13483
- // Whole-token semantics: only fully-free tokens enter the selector pool.
13673
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
13674
+ // the reservations hold back is counted too — the refusal has to name it (#737).
13484
13675
  freeView(coinId) {
13485
13676
  const freeView = [];
13486
13677
  let freeTotal = 0n;
13678
+ let pinnedTotal = 0n;
13679
+ const holders = /* @__PURE__ */ new Set();
13680
+ const unproven = this.deps.ledger.unprovenReason();
13487
13681
  for (const entry of this.deps.getPool(coinId)) {
13488
13682
  if (entry.amount <= 0n) continue;
13489
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
13683
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
13684
+ if (holder === void 0) {
13490
13685
  freeView.push(entry);
13491
13686
  freeTotal += entry.amount;
13687
+ } else {
13688
+ pinnedTotal += entry.amount;
13689
+ holders.add(holder);
13492
13690
  }
13493
13691
  }
13494
- return { freeView, freeTotal };
13692
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13495
13693
  }
13496
13694
  expectedChangeTotal(coinId) {
13497
13695
  let total = 0n;
@@ -13533,13 +13731,17 @@ function supportsDeterministicMint(engine) {
13533
13731
  }
13534
13732
  function composeFacadeParts(deps, hooks) {
13535
13733
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13734
+ const ledger = new ReservationLedger();
13536
13735
  const view = new InventoryView({
13537
13736
  port: deps.storagePort,
13538
13737
  kv: deps.kv,
13539
13738
  emit: (event) => deps.emit(event, {}),
13739
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
13740
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
13741
+ // must not call a token spendable that the queue is about to refuse to spend.
13742
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
13540
13743
  ...deps.now !== void 0 ? { now: deps.now } : {}
13541
13744
  });
13542
- const ledger = new ReservationLedger();
13543
13745
  const queue = new SpendQueue({
13544
13746
  ledger,
13545
13747
  getPool: (coinId) => view.pool(coinId),
@@ -13560,6 +13762,7 @@ function composeFacadeParts(deps, hooks) {
13560
13762
  ownPubkeyBytes,
13561
13763
  view,
13562
13764
  ledger,
13765
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13563
13766
  queue,
13564
13767
  historyStore,
13565
13768
  machineStores,
@@ -13571,6 +13774,17 @@ function composeFacadeParts(deps, hooks) {
13571
13774
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13572
13775
  };
13573
13776
  }
13777
+ function buildPins(deps, hooks, parts) {
13778
+ return new IntentPins({
13779
+ ledger: parts.ledger,
13780
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
13781
+ isActive: (transferId) => hooks.isActiveOp(transferId),
13782
+ release: (tokenId) => {
13783
+ parts.view.release(tokenId);
13784
+ },
13785
+ changed: () => deps.emit("inventory:updated", {})
13786
+ });
13787
+ }
13574
13788
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13575
13789
  return {
13576
13790
  stores: machineStores,
@@ -13680,7 +13894,8 @@ var PaymentsFacade = class {
13680
13894
  this.deps = deps;
13681
13895
  const parts = composeFacadeParts(deps, {
13682
13896
  engine: () => this.engine(),
13683
- send: (request) => this.send(request)
13897
+ send: (request) => this.send(request),
13898
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13684
13899
  });
13685
13900
  this.view = parts.view;
13686
13901
  this.ledger = parts.ledger;
@@ -13694,6 +13909,7 @@ var PaymentsFacade = class {
13694
13909
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13695
13910
  this.restoreDeps = parts.restoreDeps;
13696
13911
  this.requests = parts.requests;
13912
+ this.pins = parts.pins;
13697
13913
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13698
13914
  this.heartbeat = new ConvergenceHeartbeat({
13699
13915
  now: () => this.nowMs(),
@@ -13736,6 +13952,8 @@ var PaymentsFacade = class {
13736
13952
  passChain = new SerialChain();
13737
13953
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13738
13954
  activeMoneyOps = /* @__PURE__ */ new Set();
13955
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13956
+ pins;
13739
13957
  // ── lifecycle ──────────────────────────────────────────────────────────────
13740
13958
  async start() {
13741
13959
  if (this.started) return;
@@ -13758,6 +13976,7 @@ var PaymentsFacade = class {
13758
13976
  });
13759
13977
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13760
13978
  await this.deps.session.start();
13979
+ await this.pins.sync().catch(() => void 0);
13761
13980
  this.trackTail(this.runConvergencePass());
13762
13981
  this.trackTail(this.seedHeldStates());
13763
13982
  await this.view.fullPull().catch(() => void 0);
@@ -13828,6 +14047,7 @@ var PaymentsFacade = class {
13828
14047
  }
13829
14048
  async convergeBody() {
13830
14049
  const outcome = await this.converger.convergeOnce();
14050
+ await this.pins.sync().catch(() => void 0);
13831
14051
  const pending = await this.converger.pendingWork().catch(() => true);
13832
14052
  this.heartbeat.settle(outcome, pending);
13833
14053
  }
@@ -14067,6 +14287,7 @@ var PaymentsFacade = class {
14067
14287
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
14068
14288
  settleKeepOpen(ctx) {
14069
14289
  this.queue.clearExpectedChange(ctx.transferId);
14290
+ this.pins.adopt(ctx.transferId);
14070
14291
  this.heartbeat.arm();
14071
14292
  }
14072
14293
  settlePartial(ctx, partial) {