@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.
package/dist/index.js CHANGED
@@ -9218,6 +9218,7 @@ import { CertificationResponse, CertificationStatus } from "@unicitylabs/state-t
9218
9218
  import { StateId } from "@unicitylabs/state-transition-sdk/lib/api/StateId.js";
9219
9219
  import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProof.js";
9220
9220
  import { InclusionProofResponse } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProofResponse.js";
9221
+ import { JsonRpcNetworkError } from "@unicitylabs/state-transition-sdk/lib/api/json-rpc/JsonRpcNetworkError.js";
9221
9222
  import { RootTrustBase } from "@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js";
9222
9223
  import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js";
9223
9224
  import { InclusionProofVerificationStatus } from "@unicitylabs/state-transition-sdk/lib/transaction/verification/rule/InclusionProofVerificationRule.js";
@@ -9383,6 +9384,86 @@ function decodeSpherePaymentData(bytes) {
9383
9384
  return Promise.resolve(SpherePaymentData.fromCBOR(bytes));
9384
9385
  }
9385
9386
 
9387
+ // token-engine/proof-wait.ts
9388
+ var DEFAULT_PROOF_TIMEOUT_MS = 1e4;
9389
+ function resolveProofTimeoutMs(configured) {
9390
+ if (configured === void 0) return DEFAULT_PROOF_TIMEOUT_MS;
9391
+ if (!Number.isFinite(configured) || configured <= 0) {
9392
+ throw new SphereError(
9393
+ `proofTimeoutMs must be a positive number of milliseconds (got ${String(configured)}); an unbounded inclusion-proof wait is not supported`,
9394
+ "INVALID_CONFIG"
9395
+ );
9396
+ }
9397
+ return configured;
9398
+ }
9399
+ function lateDeliveringSignal(signal) {
9400
+ return new Proxy(signal, {
9401
+ get(target, prop) {
9402
+ if (prop === "addEventListener") {
9403
+ return (type, listener, options) => {
9404
+ if (type === "abort" && target.aborted) {
9405
+ queueMicrotask(() => {
9406
+ listener.call(target, new Event("abort"));
9407
+ });
9408
+ return;
9409
+ }
9410
+ target.addEventListener(type, listener, options);
9411
+ };
9412
+ }
9413
+ const value = Reflect.get(target, prop, target);
9414
+ return typeof value === "function" ? value.bind(target) : value;
9415
+ }
9416
+ });
9417
+ }
9418
+ function rejectOnAbort(signal) {
9419
+ return new Promise((_resolve, reject) => {
9420
+ if (signal.aborted) {
9421
+ reject(signal.reason);
9422
+ return;
9423
+ }
9424
+ signal.addEventListener(
9425
+ "abort",
9426
+ () => {
9427
+ reject(signal.reason);
9428
+ },
9429
+ { once: true }
9430
+ );
9431
+ });
9432
+ }
9433
+ async function awaitProofBounded(deps, transaction, external) {
9434
+ const controller = new AbortController();
9435
+ const relayAbort = () => {
9436
+ controller.abort(external?.reason);
9437
+ };
9438
+ if (external !== void 0) {
9439
+ if (external.aborted) relayAbort();
9440
+ else external.addEventListener("abort", relayAbort, { once: true });
9441
+ }
9442
+ const timer = setTimeout(() => {
9443
+ controller.abort(
9444
+ new SphereError(
9445
+ `inclusion proof not confirmed within ${String(deps.timeoutMs)} ms`,
9446
+ "AGGREGATOR_ERROR"
9447
+ )
9448
+ );
9449
+ }, deps.timeoutMs);
9450
+ const wait = waitInclusionProof(
9451
+ deps.client,
9452
+ deps.trustBase,
9453
+ deps.predicateVerifier,
9454
+ transaction,
9455
+ lateDeliveringSignal(controller.signal),
9456
+ deps.intervalMs
9457
+ );
9458
+ wait.catch(() => void 0);
9459
+ try {
9460
+ return await Promise.race([wait, rejectOnAbort(controller.signal)]);
9461
+ } finally {
9462
+ clearTimeout(timer);
9463
+ external?.removeEventListener("abort", relayAbort);
9464
+ }
9465
+ }
9466
+
9386
9467
  // token-engine/errors.ts
9387
9468
  var TransferConflictError = class extends SphereError {
9388
9469
  /**
@@ -9607,11 +9688,13 @@ var SphereTokenEngine = class {
9607
9688
  constructor(deps) {
9608
9689
  this.deps = deps;
9609
9690
  this.privateKeyHex = HexConverter.encode(deps.privateKey);
9691
+ this.proofTimeoutMs = resolveProofTimeoutMs(deps.proofTimeoutMs);
9610
9692
  }
9611
9693
  /** F13 advertisement: mint under a (transferId, opIndex) seed is idempotent-recoverable — a same-seed re-call converges, never double-mints. */
9612
9694
  deterministicMint = true;
9613
9695
  /** Hex form of the wallet key — deriveRealization's ikm input (Part E.1). */
9614
9696
  privateKeyHex;
9697
+ proofTimeoutMs;
9615
9698
  // ── identity ────────────────────────────────────────────────────────────────
9616
9699
  getIdentity() {
9617
9700
  return { chainPubkey: new Uint8Array(this.deps.signingService.publicKey) };
@@ -9954,14 +10037,17 @@ var SphereTokenEngine = class {
9954
10037
  submitError = err ?? new SphereError(`${failLabel}: submit failed`, failCode);
9955
10038
  }
9956
10039
  try {
9957
- return await waitInclusionProof(
9958
- this.deps.client,
9959
- this.deps.trustBase,
9960
- this.deps.predicateVerifier,
10040
+ return await awaitProofBounded(
10041
+ {
10042
+ client: this.deps.client,
10043
+ trustBase: this.deps.trustBase,
10044
+ predicateVerifier: this.deps.predicateVerifier,
10045
+ timeoutMs: this.proofTimeoutMs,
10046
+ intervalMs: this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
10047
+ // #683
10048
+ },
9961
10049
  transaction,
9962
- options?.signal,
9963
- this.deps.proofPollIntervalMs ?? DEFAULT_PROOF_POLL_INTERVAL_MS
9964
- // #683 finer poll cadence
10050
+ options?.signal
9965
10051
  );
9966
10052
  } catch (err) {
9967
10053
  if (err instanceof Error && err.message === `Invalid inclusion proof status: ${InclusionProofVerificationStatus.TRANSACTION_HASH_MISMATCH}`) {
@@ -9977,6 +10063,13 @@ var SphereTokenEngine = class {
9977
10063
  );
9978
10064
  }
9979
10065
  }
10066
+ /**
10067
+ * One inclusion-proof wait, with the deadline OWNED here (#739). The SDK's own
10068
+ * default deadline is unreliable — see lateDeliveringSignal — and a lost one
10069
+ * means an op that never settles, which also wedges PaymentsFacade.stop().
10070
+ * A deadline hit throws, and the caller maps that to ProofUnconfirmedError
10071
+ * (keep-open), which is the correct posture: the spend may be on-chain.
10072
+ */
9980
10073
  /** Fail fast if this engine's key does not own the token's current state. */
9981
10074
  assertOwned(token) {
9982
10075
  if (!this.isOwnedBy(token, this.deps.signingService.publicKey)) {
@@ -10076,6 +10169,8 @@ async function createSphereTokenEngine(config) {
10076
10169
  networkId: trustBase.networkId,
10077
10170
  // #683: forward the (optional) proof-poll cadence; undefined → the engine default.
10078
10171
  proofPollIntervalMs: config.proofPollIntervalMs,
10172
+ proofTimeoutMs: config.proofTimeoutMs,
10173
+ // #739: was declared but never wired
10079
10174
  // Opt-in parallel verification (2.0.2). Absent → the sequential verifier.
10080
10175
  ...config.verification ? { tokenVerifier: createWorkerTokenVerifier(config.verification) } : {}
10081
10176
  };
@@ -11985,6 +12080,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
11985
12080
  }
11986
12081
  return out;
11987
12082
  }
12083
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
12084
+ const holds = /* @__PURE__ */ new Map();
12085
+ let complete = true;
12086
+ for (const entry of await stores.backstop.list()) {
12087
+ if (entry.disposition !== "open") continue;
12088
+ let payload = null;
12089
+ try {
12090
+ const raw = await decryptPayload(entry.payloadEnvelope);
12091
+ if (raw !== null && typeof raw === "object") payload = raw;
12092
+ } catch {
12093
+ complete = false;
12094
+ continue;
12095
+ }
12096
+ if (payload === null) {
12097
+ complete = false;
12098
+ continue;
12099
+ }
12100
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
12101
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
12102
+ holds.set(entry.transferId, [...direct, ...split]);
12103
+ }
12104
+ return { open: holds, complete };
12105
+ }
11988
12106
  function groupJournal(journal) {
11989
12107
  const byTransfer = /* @__PURE__ */ new Map();
11990
12108
  for (const entry of journal) {
@@ -12154,11 +12272,14 @@ function toAsset(coinId, totals, registry) {
12154
12272
  fiatValueEur: null
12155
12273
  };
12156
12274
  }
12275
+ function priceIdOf(registry, coinId) {
12276
+ return registry.getName(coinId).toLowerCase();
12277
+ }
12157
12278
  async function withPrices(raw, registry, price) {
12158
12279
  try {
12159
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12160
- const quotes = await price.getPrices(names);
12161
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12280
+ const ids = [...new Set(raw.map((a) => priceIdOf(registry, a.coinId)))];
12281
+ const quotes = await price.getPrices(ids);
12282
+ return raw.map((a) => priceOne(a, quotes.get(priceIdOf(registry, a.coinId))));
12162
12283
  } catch {
12163
12284
  return raw;
12164
12285
  }
@@ -12392,10 +12513,21 @@ var InventoryView = class {
12392
12513
  release(tokenId) {
12393
12514
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12394
12515
  }
12516
+ pinned(tokenId) {
12517
+ return this.deps.isPinned?.(tokenId) ?? false;
12518
+ }
12519
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
12520
+ held(tokenId) {
12521
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
12522
+ }
12523
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
12524
+ // and needs the entry to say how much is pinned when it refuses (#737). In
12525
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
12395
12526
  pool(coinId) {
12396
12527
  const out = [];
12397
12528
  for (const [tokenId, entry] of this.mirror) {
12398
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
12529
+ if (entry.status !== "active") continue;
12530
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
12399
12531
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12400
12532
  const asset = entry.assets.find((a) => a.coinId === coinId);
12401
12533
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12411,7 +12543,7 @@ var InventoryView = class {
12411
12543
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12412
12544
  out.push(
12413
12545
  toToken(tokenId, entry, asset, registry, {
12414
- transferring: this.inFlight.has(tokenId),
12546
+ transferring: this.held(tokenId),
12415
12547
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12416
12548
  })
12417
12549
  );
@@ -12419,7 +12551,7 @@ var InventoryView = class {
12419
12551
  return out;
12420
12552
  }
12421
12553
  async assets(registry, price) {
12422
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12554
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12423
12555
  if (!price || raw.length === 0) return raw;
12424
12556
  return withPrices(raw, registry, price);
12425
12557
  }
@@ -13105,6 +13237,20 @@ var Requests = class {
13105
13237
  var ReservationLedger = class {
13106
13238
  reservations = /* @__PURE__ */ new Map();
13107
13239
  tokenHolder = /* @__PURE__ */ new Map();
13240
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
13241
+ // IntentPins proves every one of them "no holder" means unknown, not free.
13242
+ authoritative = false;
13243
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
13244
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
13245
+ setAuthoritative(value) {
13246
+ const moved = this.authoritative !== value;
13247
+ this.authoritative = value;
13248
+ return moved;
13249
+ }
13250
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
13251
+ unprovenReason() {
13252
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
13253
+ }
13108
13254
  // All-or-nothing: every entry is validated before any state mutates.
13109
13255
  reserve(reservationId, entries) {
13110
13256
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13131,6 +13277,13 @@ var ReservationLedger = class {
13131
13277
  getFreeAmount(tokenId, tokenAmount) {
13132
13278
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13133
13279
  }
13280
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
13281
+ holderOf(tokenId) {
13282
+ return this.tokenHolder.get(tokenId);
13283
+ }
13284
+ tokensOf(reservationId) {
13285
+ return [...this.reservations.get(reservationId) ?? []];
13286
+ }
13134
13287
  clear() {
13135
13288
  this.reservations.clear();
13136
13289
  this.tokenHolder.clear();
@@ -13143,6 +13296,44 @@ var ReservationLedger = class {
13143
13296
  }
13144
13297
  };
13145
13298
 
13299
+ // modules/payments-v2/select/pins.ts
13300
+ var IntentPins = class {
13301
+ constructor(deps) {
13302
+ this.deps = deps;
13303
+ }
13304
+ pinned = /* @__PURE__ */ new Set();
13305
+ /** The keep-open reservation IS this intent's pin from now on. */
13306
+ adopt(transferId) {
13307
+ this.pinned.add(transferId);
13308
+ }
13309
+ async sync() {
13310
+ const { open, complete } = await this.deps.openIntents();
13311
+ let changed = false;
13312
+ for (const transferId of [...this.pinned]) {
13313
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
13314
+ this.pinned.delete(transferId);
13315
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
13316
+ this.deps.ledger.cancel(transferId);
13317
+ changed = true;
13318
+ }
13319
+ for (const [transferId, tokenIds] of open) {
13320
+ if (this.deps.isActive(transferId)) continue;
13321
+ this.pinned.add(transferId);
13322
+ changed = this.pin(transferId, tokenIds) || changed;
13323
+ }
13324
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
13325
+ if (changed || gateMoved) this.deps.changed();
13326
+ }
13327
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
13328
+ pin(transferId, tokenIds) {
13329
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
13330
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
13331
+ if (free.length === 0) return false;
13332
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
13333
+ return true;
13334
+ }
13335
+ };
13336
+
13146
13337
  // modules/payments-v2/select/selector.ts
13147
13338
  var DEFAULT_WORK_BUDGET = 2e4;
13148
13339
  var MAX_COMBINATION_SIZE = 5;
@@ -13233,6 +13424,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13233
13424
  // modules/payments-v2/select/queue.ts
13234
13425
  var QUEUE_TIMEOUT_MS = 3e4;
13235
13426
  var QUEUE_MAX_SIZE = 100;
13427
+ function insufficientBalance(view, amount) {
13428
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
13429
+ 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";
13430
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
13431
+ }
13236
13432
  var SpendQueue = class {
13237
13433
  constructor(deps) {
13238
13434
  this.deps = deps;
@@ -13245,12 +13441,10 @@ var SpendQueue = class {
13245
13441
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13246
13442
  }
13247
13443
  const amount = parseAmount(request.amount);
13248
- const { freeView, freeTotal } = this.freeView(request.coinId);
13444
+ const view = this.freeView(request.coinId);
13445
+ const { freeView, freeTotal } = view;
13249
13446
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13250
- throw new SphereError(
13251
- "Insufficient balance for this transaction",
13252
- "SEND_INSUFFICIENT_BALANCE"
13253
- );
13447
+ throw insufficientBalance(view, amount);
13254
13448
  }
13255
13449
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13256
13450
  if (plan !== null) {
@@ -13300,11 +13494,10 @@ var SpendQueue = class {
13300
13494
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13301
13495
  return "rejected";
13302
13496
  }
13303
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13497
+ const view = this.freeView(entry.coinId);
13498
+ const { freeView, freeTotal } = view;
13304
13499
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13305
- entry.reject(
13306
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13307
- );
13500
+ entry.reject(insufficientBalance(view, entry.amount));
13308
13501
  return "rejected";
13309
13502
  }
13310
13503
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13349,18 +13542,26 @@ var SpendQueue = class {
13349
13542
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13350
13543
  if (entries.length === 0) this.queues.delete(coinId);
13351
13544
  }
13352
- // Whole-token semantics: only fully-free tokens enter the selector pool.
13545
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
13546
+ // the reservations hold back is counted too — the refusal has to name it (#737).
13353
13547
  freeView(coinId) {
13354
13548
  const freeView = [];
13355
13549
  let freeTotal = 0n;
13550
+ let pinnedTotal = 0n;
13551
+ const holders = /* @__PURE__ */ new Set();
13552
+ const unproven = this.deps.ledger.unprovenReason();
13356
13553
  for (const entry of this.deps.getPool(coinId)) {
13357
13554
  if (entry.amount <= 0n) continue;
13358
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
13555
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
13556
+ if (holder === void 0) {
13359
13557
  freeView.push(entry);
13360
13558
  freeTotal += entry.amount;
13559
+ } else {
13560
+ pinnedTotal += entry.amount;
13561
+ holders.add(holder);
13361
13562
  }
13362
13563
  }
13363
- return { freeView, freeTotal };
13564
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13364
13565
  }
13365
13566
  expectedChangeTotal(coinId) {
13366
13567
  let total = 0n;
@@ -13402,13 +13603,17 @@ function supportsDeterministicMint(engine) {
13402
13603
  }
13403
13604
  function composeFacadeParts(deps, hooks) {
13404
13605
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13606
+ const ledger = new ReservationLedger();
13405
13607
  const view = new InventoryView({
13406
13608
  port: deps.storagePort,
13407
13609
  kv: deps.kv,
13408
13610
  emit: (event) => deps.emit(event, {}),
13611
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
13612
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
13613
+ // must not call a token spendable that the queue is about to refuse to spend.
13614
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
13409
13615
  ...deps.now !== void 0 ? { now: deps.now } : {}
13410
13616
  });
13411
- const ledger = new ReservationLedger();
13412
13617
  const queue = new SpendQueue({
13413
13618
  ledger,
13414
13619
  getPool: (coinId) => view.pool(coinId),
@@ -13429,6 +13634,7 @@ function composeFacadeParts(deps, hooks) {
13429
13634
  ownPubkeyBytes,
13430
13635
  view,
13431
13636
  ledger,
13637
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13432
13638
  queue,
13433
13639
  historyStore,
13434
13640
  machineStores,
@@ -13440,6 +13646,17 @@ function composeFacadeParts(deps, hooks) {
13440
13646
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13441
13647
  };
13442
13648
  }
13649
+ function buildPins(deps, hooks, parts) {
13650
+ return new IntentPins({
13651
+ ledger: parts.ledger,
13652
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
13653
+ isActive: (transferId) => hooks.isActiveOp(transferId),
13654
+ release: (tokenId) => {
13655
+ parts.view.release(tokenId);
13656
+ },
13657
+ changed: () => deps.emit("inventory:updated", {})
13658
+ });
13659
+ }
13443
13660
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13444
13661
  return {
13445
13662
  stores: machineStores,
@@ -13549,7 +13766,8 @@ var PaymentsFacade = class {
13549
13766
  this.deps = deps;
13550
13767
  const parts = composeFacadeParts(deps, {
13551
13768
  engine: () => this.engine(),
13552
- send: (request) => this.send(request)
13769
+ send: (request) => this.send(request),
13770
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13553
13771
  });
13554
13772
  this.view = parts.view;
13555
13773
  this.ledger = parts.ledger;
@@ -13563,6 +13781,7 @@ var PaymentsFacade = class {
13563
13781
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13564
13782
  this.restoreDeps = parts.restoreDeps;
13565
13783
  this.requests = parts.requests;
13784
+ this.pins = parts.pins;
13566
13785
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13567
13786
  this.heartbeat = new ConvergenceHeartbeat({
13568
13787
  now: () => this.nowMs(),
@@ -13605,6 +13824,8 @@ var PaymentsFacade = class {
13605
13824
  passChain = new SerialChain();
13606
13825
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13607
13826
  activeMoneyOps = /* @__PURE__ */ new Set();
13827
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13828
+ pins;
13608
13829
  // ── lifecycle ──────────────────────────────────────────────────────────────
13609
13830
  async start() {
13610
13831
  if (this.started) return;
@@ -13627,6 +13848,7 @@ var PaymentsFacade = class {
13627
13848
  });
13628
13849
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13629
13850
  await this.deps.session.start();
13851
+ await this.pins.sync().catch(() => void 0);
13630
13852
  this.trackTail(this.runConvergencePass());
13631
13853
  this.trackTail(this.seedHeldStates());
13632
13854
  await this.view.fullPull().catch(() => void 0);
@@ -13697,6 +13919,7 @@ var PaymentsFacade = class {
13697
13919
  }
13698
13920
  async convergeBody() {
13699
13921
  const outcome = await this.converger.convergeOnce();
13922
+ await this.pins.sync().catch(() => void 0);
13700
13923
  const pending = await this.converger.pendingWork().catch(() => true);
13701
13924
  this.heartbeat.settle(outcome, pending);
13702
13925
  }
@@ -13936,6 +14159,7 @@ var PaymentsFacade = class {
13936
14159
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
13937
14160
  settleKeepOpen(ctx) {
13938
14161
  this.queue.clearExpectedChange(ctx.transferId);
14162
+ this.pins.adopt(ctx.transferId);
13939
14163
  this.heartbeat.arm();
13940
14164
  }
13941
14165
  settlePartial(ctx, partial) {