@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.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) {
@@ -12282,11 +12400,14 @@ function toAsset(coinId, totals, registry) {
12282
12400
  fiatValueEur: null
12283
12401
  };
12284
12402
  }
12403
+ function priceIdOf(registry, coinId) {
12404
+ return registry.getName(coinId).toLowerCase();
12405
+ }
12285
12406
  async function withPrices(raw, registry, price) {
12286
12407
  try {
12287
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
12288
- const quotes = await price.getPrices(names);
12289
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
12408
+ const ids = [...new Set(raw.map((a) => priceIdOf(registry, a.coinId)))];
12409
+ const quotes = await price.getPrices(ids);
12410
+ return raw.map((a) => priceOne(a, quotes.get(priceIdOf(registry, a.coinId))));
12290
12411
  } catch {
12291
12412
  return raw;
12292
12413
  }
@@ -12520,10 +12641,21 @@ var InventoryView = class {
12520
12641
  release(tokenId) {
12521
12642
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12522
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.
12523
12654
  pool(coinId) {
12524
12655
  const out = [];
12525
12656
  for (const [tokenId, entry] of this.mirror) {
12526
- 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;
12527
12659
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
12528
12660
  const asset = entry.assets.find((a) => a.coinId === coinId);
12529
12661
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -12539,7 +12671,7 @@ var InventoryView = class {
12539
12671
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
12540
12672
  out.push(
12541
12673
  toToken(tokenId, entry, asset, registry, {
12542
- transferring: this.inFlight.has(tokenId),
12674
+ transferring: this.held(tokenId),
12543
12675
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
12544
12676
  })
12545
12677
  );
@@ -12547,7 +12679,7 @@ var InventoryView = class {
12547
12679
  return out;
12548
12680
  }
12549
12681
  async assets(registry, price) {
12550
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
12682
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
12551
12683
  if (!price || raw.length === 0) return raw;
12552
12684
  return withPrices(raw, registry, price);
12553
12685
  }
@@ -13233,6 +13365,20 @@ var Requests = class {
13233
13365
  var ReservationLedger = class {
13234
13366
  reservations = /* @__PURE__ */ new Map();
13235
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
+ }
13236
13382
  // All-or-nothing: every entry is validated before any state mutates.
13237
13383
  reserve(reservationId, entries) {
13238
13384
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -13259,6 +13405,13 @@ var ReservationLedger = class {
13259
13405
  getFreeAmount(tokenId, tokenAmount) {
13260
13406
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
13261
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
+ }
13262
13415
  clear() {
13263
13416
  this.reservations.clear();
13264
13417
  this.tokenHolder.clear();
@@ -13271,6 +13424,44 @@ var ReservationLedger = class {
13271
13424
  }
13272
13425
  };
13273
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
+
13274
13465
  // modules/payments-v2/select/selector.ts
13275
13466
  var DEFAULT_WORK_BUDGET = 2e4;
13276
13467
  var MAX_COMBINATION_SIZE = 5;
@@ -13361,6 +13552,11 @@ function searchCombinationsOfSize(window, size, target, search) {
13361
13552
  // modules/payments-v2/select/queue.ts
13362
13553
  var QUEUE_TIMEOUT_MS = 3e4;
13363
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
+ }
13364
13560
  var SpendQueue = class {
13365
13561
  constructor(deps) {
13366
13562
  this.deps = deps;
@@ -13373,12 +13569,10 @@ var SpendQueue = class {
13373
13569
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
13374
13570
  }
13375
13571
  const amount = parseAmount(request.amount);
13376
- const { freeView, freeTotal } = this.freeView(request.coinId);
13572
+ const view = this.freeView(request.coinId);
13573
+ const { freeView, freeTotal } = view;
13377
13574
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
13378
- throw new SphereError(
13379
- "Insufficient balance for this transaction",
13380
- "SEND_INSUFFICIENT_BALANCE"
13381
- );
13575
+ throw insufficientBalance(view, amount);
13382
13576
  }
13383
13577
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
13384
13578
  if (plan !== null) {
@@ -13428,11 +13622,10 @@ var SpendQueue = class {
13428
13622
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13429
13623
  return "rejected";
13430
13624
  }
13431
- const { freeView, freeTotal } = this.freeView(entry.coinId);
13625
+ const view = this.freeView(entry.coinId);
13626
+ const { freeView, freeTotal } = view;
13432
13627
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
13433
- entry.reject(
13434
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
13435
- );
13628
+ entry.reject(insufficientBalance(view, entry.amount));
13436
13629
  return "rejected";
13437
13630
  }
13438
13631
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -13477,18 +13670,26 @@ var SpendQueue = class {
13477
13670
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
13478
13671
  if (entries.length === 0) this.queues.delete(coinId);
13479
13672
  }
13480
- // 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).
13481
13675
  freeView(coinId) {
13482
13676
  const freeView = [];
13483
13677
  let freeTotal = 0n;
13678
+ let pinnedTotal = 0n;
13679
+ const holders = /* @__PURE__ */ new Set();
13680
+ const unproven = this.deps.ledger.unprovenReason();
13484
13681
  for (const entry of this.deps.getPool(coinId)) {
13485
13682
  if (entry.amount <= 0n) continue;
13486
- 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) {
13487
13685
  freeView.push(entry);
13488
13686
  freeTotal += entry.amount;
13687
+ } else {
13688
+ pinnedTotal += entry.amount;
13689
+ holders.add(holder);
13489
13690
  }
13490
13691
  }
13491
- return { freeView, freeTotal };
13692
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
13492
13693
  }
13493
13694
  expectedChangeTotal(coinId) {
13494
13695
  let total = 0n;
@@ -13530,13 +13731,17 @@ function supportsDeterministicMint(engine) {
13530
13731
  }
13531
13732
  function composeFacadeParts(deps, hooks) {
13532
13733
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13734
+ const ledger = new ReservationLedger();
13533
13735
  const view = new InventoryView({
13534
13736
  port: deps.storagePort,
13535
13737
  kv: deps.kv,
13536
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,
13537
13743
  ...deps.now !== void 0 ? { now: deps.now } : {}
13538
13744
  });
13539
- const ledger = new ReservationLedger();
13540
13745
  const queue = new SpendQueue({
13541
13746
  ledger,
13542
13747
  getPool: (coinId) => view.pool(coinId),
@@ -13557,6 +13762,7 @@ function composeFacadeParts(deps, hooks) {
13557
13762
  ownPubkeyBytes,
13558
13763
  view,
13559
13764
  ledger,
13765
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13560
13766
  queue,
13561
13767
  historyStore,
13562
13768
  machineStores,
@@ -13568,6 +13774,17 @@ function composeFacadeParts(deps, hooks) {
13568
13774
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13569
13775
  };
13570
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
+ }
13571
13788
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
13572
13789
  return {
13573
13790
  stores: machineStores,
@@ -13677,7 +13894,8 @@ var PaymentsFacade = class {
13677
13894
  this.deps = deps;
13678
13895
  const parts = composeFacadeParts(deps, {
13679
13896
  engine: () => this.engine(),
13680
- send: (request) => this.send(request)
13897
+ send: (request) => this.send(request),
13898
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
13681
13899
  });
13682
13900
  this.view = parts.view;
13683
13901
  this.ledger = parts.ledger;
@@ -13691,6 +13909,7 @@ var PaymentsFacade = class {
13691
13909
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
13692
13910
  this.restoreDeps = parts.restoreDeps;
13693
13911
  this.requests = parts.requests;
13912
+ this.pins = parts.pins;
13694
13913
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
13695
13914
  this.heartbeat = new ConvergenceHeartbeat({
13696
13915
  now: () => this.nowMs(),
@@ -13733,6 +13952,8 @@ var PaymentsFacade = class {
13733
13952
  passChain = new SerialChain();
13734
13953
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
13735
13954
  activeMoneyOps = /* @__PURE__ */ new Set();
13955
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
13956
+ pins;
13736
13957
  // ── lifecycle ──────────────────────────────────────────────────────────────
13737
13958
  async start() {
13738
13959
  if (this.started) return;
@@ -13755,6 +13976,7 @@ var PaymentsFacade = class {
13755
13976
  });
13756
13977
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
13757
13978
  await this.deps.session.start();
13979
+ await this.pins.sync().catch(() => void 0);
13758
13980
  this.trackTail(this.runConvergencePass());
13759
13981
  this.trackTail(this.seedHeldStates());
13760
13982
  await this.view.fullPull().catch(() => void 0);
@@ -13825,6 +14047,7 @@ var PaymentsFacade = class {
13825
14047
  }
13826
14048
  async convergeBody() {
13827
14049
  const outcome = await this.converger.convergeOnce();
14050
+ await this.pins.sync().catch(() => void 0);
13828
14051
  const pending = await this.converger.pendingWork().catch(() => true);
13829
14052
  this.heartbeat.settle(outcome, pending);
13830
14053
  }
@@ -14064,6 +14287,7 @@ var PaymentsFacade = class {
14064
14287
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
14065
14288
  settleKeepOpen(ctx) {
14066
14289
  this.queue.clearExpectedChange(ctx.transferId);
14290
+ this.pins.adopt(ctx.transferId);
14067
14291
  this.heartbeat.arm();
14068
14292
  }
14069
14293
  settlePartial(ctx, partial) {