@unicitylabs/sphere-sdk 0.14.2 → 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) {
@@ -12138,11 +12256,14 @@ function toAsset(coinId, totals, registry) {
12138
12256
  fiatValueEur: null
12139
12257
  };
12140
12258
  }
12259
+ function priceIdOf(registry, coinId) {
12260
+ return registry.getName(coinId).toLowerCase();
12261
+ }
12141
12262
  async function withPrices(raw, registry, price) {
12142
12263
  try {
12143
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12144
- const quotes = await price.getPrices(names);
12145
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12264
+ const ids = [...new Set(raw.map((a) => priceIdOf(registry, a.coinId)))];
12265
+ const quotes = await price.getPrices(ids);
12266
+ return raw.map((a) => priceOne(a, quotes.get(priceIdOf(registry, a.coinId))));
12146
12267
  } catch {
12147
12268
  return raw;
12148
12269
  }
@@ -12376,10 +12497,21 @@ var InventoryView = class {
12376
12497
  release(tokenId) {
12377
12498
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12378
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.
12379
12510
  pool(coinId) {
12380
12511
  const out = [];
12381
12512
  for (const [tokenId, entry] of this.mirror) {
12382
- 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;
12383
12515
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12384
12516
  const asset = entry.assets.find((a) => a.coinId === coinId);
12385
12517
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12395,7 +12527,7 @@ var InventoryView = class {
12395
12527
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12396
12528
  out.push(
12397
12529
  toToken(tokenId, entry, asset, registry, {
12398
- transferring: this.inFlight.has(tokenId),
12530
+ transferring: this.held(tokenId),
12399
12531
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12400
12532
  })
12401
12533
  );
@@ -12403,7 +12535,7 @@ var InventoryView = class {
12403
12535
  return out;
12404
12536
  }
12405
12537
  async assets(registry, price) {
12406
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12538
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12407
12539
  if (!price || raw.length === 0) return raw;
12408
12540
  return withPrices(raw, registry, price);
12409
12541
  }
@@ -13089,6 +13221,20 @@ var Requests = class {
13089
13221
  var ReservationLedger = class {
13090
13222
  reservations = /* @__PURE__ */ new Map();
13091
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
+ }
13092
13238
  // All-or-nothing: every entry is validated before any state mutates.
13093
13239
  reserve(reservationId, entries) {
13094
13240
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13115,6 +13261,13 @@ var ReservationLedger = class {
13115
13261
  getFreeAmount(tokenId, tokenAmount) {
13116
13262
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13117
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
+ }
13118
13271
  clear() {
13119
13272
  this.reservations.clear();
13120
13273
  this.tokenHolder.clear();
@@ -13127,6 +13280,44 @@ var ReservationLedger = class {
13127
13280
  }
13128
13281
  };
13129
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
+
13130
13321
  // modules/payments-v2/select/selector.ts
13131
13322
  var DEFAULT_WORK_BUDGET = 2e4;
13132
13323
  var MAX_COMBINATION_SIZE = 5;
@@ -13217,6 +13408,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13217
13408
  // modules/payments-v2/select/queue.ts
13218
13409
  var QUEUE_TIMEOUT_MS = 3e4;
13219
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
+ }
13220
13416
  var SpendQueue = class {
13221
13417
  constructor(deps) {
13222
13418
  this.deps = deps;
@@ -13229,12 +13425,10 @@ var SpendQueue = class {
13229
13425
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13230
13426
  }
13231
13427
  const amount = parseAmount(request.amount);
13232
- const { freeView, freeTotal } = this.freeView(request.coinId);
13428
+ const view = this.freeView(request.coinId);
13429
+ const { freeView, freeTotal } = view;
13233
13430
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13234
- throw new SphereError(
13235
- "Insufficient balance for this transaction",
13236
- "SEND_INSUFFICIENT_BALANCE"
13237
- );
13431
+ throw insufficientBalance(view, amount);
13238
13432
  }
13239
13433
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13240
13434
  if (plan !== null) {
@@ -13284,11 +13478,10 @@ var SpendQueue = class {
13284
13478
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13285
13479
  return "rejected";
13286
13480
  }
13287
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13481
+ const view = this.freeView(entry.coinId);
13482
+ const { freeView, freeTotal } = view;
13288
13483
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13289
- entry.reject(
13290
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13291
- );
13484
+ entry.reject(insufficientBalance(view, entry.amount));
13292
13485
  return "rejected";
13293
13486
  }
13294
13487
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13333,18 +13526,26 @@ var SpendQueue = class {
13333
13526
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13334
13527
  if (entries.length === 0) this.queues.delete(coinId);
13335
13528
  }
13336
- // 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).
13337
13531
  freeView(coinId) {
13338
13532
  const freeView = [];
13339
13533
  let freeTotal = 0n;
13534
+ let pinnedTotal = 0n;
13535
+ const holders = /* @__PURE__ */ new Set();
13536
+ const unproven = this.deps.ledger.unprovenReason();
13340
13537
  for (const entry of this.deps.getPool(coinId)) {
13341
13538
  if (entry.amount <= 0n) continue;
13342
- 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) {
13343
13541
  freeView.push(entry);
13344
13542
  freeTotal += entry.amount;
13543
+ } else {
13544
+ pinnedTotal += entry.amount;
13545
+ holders.add(holder);
13345
13546
  }
13346
13547
  }
13347
- return { freeView, freeTotal };
13548
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13348
13549
  }
13349
13550
  expectedChangeTotal(coinId) {
13350
13551
  let total = 0n;
@@ -13386,13 +13587,17 @@ function supportsDeterministicMint(engine) {
13386
13587
  }
13387
13588
  function composeFacadeParts(deps, hooks) {
13388
13589
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13590
+ const ledger = new ReservationLedger();
13389
13591
  const view = new InventoryView({
13390
13592
  port: deps.storagePort,
13391
13593
  kv: deps.kv,
13392
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,
13393
13599
  ...deps.now !== void 0 ? { now: deps.now } : {}
13394
13600
  });
13395
- const ledger = new ReservationLedger();
13396
13601
  const queue = new SpendQueue({
13397
13602
  ledger,
13398
13603
  getPool: (coinId) => view.pool(coinId),
@@ -13413,6 +13618,7 @@ function composeFacadeParts(deps, hooks) {
13413
13618
  ownPubkeyBytes,
13414
13619
  view,
13415
13620
  ledger,
13621
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13416
13622
  queue,
13417
13623
  historyStore,
13418
13624
  machineStores,
@@ -13424,6 +13630,17 @@ function composeFacadeParts(deps, hooks) {
13424
13630
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13425
13631
  };
13426
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
+ }
13427
13644
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13428
13645
  return {
13429
13646
  stores: machineStores,
@@ -13533,7 +13750,8 @@ var PaymentsFacade = class {
13533
13750
  this.deps = deps;
13534
13751
  const parts = composeFacadeParts(deps, {
13535
13752
  engine: () => this.engine(),
13536
- send: (request) => this.send(request)
13753
+ send: (request) => this.send(request),
13754
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13537
13755
  });
13538
13756
  this.view = parts.view;
13539
13757
  this.ledger = parts.ledger;
@@ -13547,6 +13765,7 @@ var PaymentsFacade = class {
13547
13765
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13548
13766
  this.restoreDeps = parts.restoreDeps;
13549
13767
  this.requests = parts.requests;
13768
+ this.pins = parts.pins;
13550
13769
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13551
13770
  this.heartbeat = new ConvergenceHeartbeat({
13552
13771
  now: () => this.nowMs(),
@@ -13589,6 +13808,8 @@ var PaymentsFacade = class {
13589
13808
  passChain = new SerialChain();
13590
13809
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13591
13810
  activeMoneyOps = /* @__PURE__ */ new Set();
13811
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13812
+ pins;
13592
13813
  // ── lifecycle ──────────────────────────────────────────────────────────────
13593
13814
  async start() {
13594
13815
  if (this.started) return;
@@ -13611,6 +13832,7 @@ var PaymentsFacade = class {
13611
13832
  });
13612
13833
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13613
13834
  await this.deps.session.start();
13835
+ await this.pins.sync().catch(() => void 0);
13614
13836
  this.trackTail(this.runConvergencePass());
13615
13837
  this.trackTail(this.seedHeldStates());
13616
13838
  await this.view.fullPull().catch(() => void 0);
@@ -13681,6 +13903,7 @@ var PaymentsFacade = class {
13681
13903
  }
13682
13904
  async convergeBody() {
13683
13905
  const outcome = await this.converger.convergeOnce();
13906
+ await this.pins.sync().catch(() => void 0);
13684
13907
  const pending = await this.converger.pendingWork().catch(() => true);
13685
13908
  this.heartbeat.settle(outcome, pending);
13686
13909
  }
@@ -13920,6 +14143,7 @@ var PaymentsFacade = class {
13920
14143
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
13921
14144
  settleKeepOpen(ctx) {
13922
14145
  this.queue.clearExpectedChange(ctx.transferId);
14146
+ this.pins.adopt(ctx.transferId);
13923
14147
  this.heartbeat.arm();
13924
14148
  }
13925
14149
  settlePartial(ctx, partial) {