@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.
@@ -503,6 +503,7 @@ var import_CertificationResponse = require("@unicitylabs/state-transition-sdk/li
503
503
  var import_StateId = require("@unicitylabs/state-transition-sdk/lib/api/StateId.js");
504
504
  var import_InclusionProof = require("@unicitylabs/state-transition-sdk/lib/api/InclusionProof.js");
505
505
  var import_InclusionProofResponse = require("@unicitylabs/state-transition-sdk/lib/api/InclusionProofResponse.js");
506
+ var import_JsonRpcNetworkError = require("@unicitylabs/state-transition-sdk/lib/api/json-rpc/JsonRpcNetworkError.js");
506
507
  var import_RootTrustBase = require("@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js");
507
508
  var import_InclusionProofUtils = require("@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js");
508
509
  var import_InclusionProofVerificationRule = require("@unicitylabs/state-transition-sdk/lib/transaction/verification/rule/InclusionProofVerificationRule.js");
@@ -1462,6 +1463,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
1462
1463
  }
1463
1464
  return out;
1464
1465
  }
1466
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
1467
+ const holds = /* @__PURE__ */ new Map();
1468
+ let complete = true;
1469
+ for (const entry of await stores.backstop.list()) {
1470
+ if (entry.disposition !== "open") continue;
1471
+ let payload = null;
1472
+ try {
1473
+ const raw = await decryptPayload(entry.payloadEnvelope);
1474
+ if (raw !== null && typeof raw === "object") payload = raw;
1475
+ } catch {
1476
+ complete = false;
1477
+ continue;
1478
+ }
1479
+ if (payload === null) {
1480
+ complete = false;
1481
+ continue;
1482
+ }
1483
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
1484
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
1485
+ holds.set(entry.transferId, [...direct, ...split]);
1486
+ }
1487
+ return { open: holds, complete };
1488
+ }
1465
1489
  function groupJournal(journal) {
1466
1490
  const byTransfer = /* @__PURE__ */ new Map();
1467
1491
  for (const entry of journal) {
@@ -2722,10 +2746,21 @@ var InventoryView = class {
2722
2746
  release(tokenId) {
2723
2747
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
2724
2748
  }
2749
+ pinned(tokenId) {
2750
+ return this.deps.isPinned?.(tokenId) ?? false;
2751
+ }
2752
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
2753
+ held(tokenId) {
2754
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
2755
+ }
2756
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
2757
+ // and needs the entry to say how much is pinned when it refuses (#737). In
2758
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
2725
2759
  pool(coinId) {
2726
2760
  const out = [];
2727
2761
  for (const [tokenId, entry] of this.mirror) {
2728
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
2762
+ if (entry.status !== "active") continue;
2763
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
2729
2764
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
2730
2765
  const asset = entry.assets.find((a) => a.coinId === coinId);
2731
2766
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -2741,7 +2776,7 @@ var InventoryView = class {
2741
2776
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
2742
2777
  out.push(
2743
2778
  toToken(tokenId, entry, asset, registry, {
2744
- transferring: this.inFlight.has(tokenId),
2779
+ transferring: this.held(tokenId),
2745
2780
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
2746
2781
  })
2747
2782
  );
@@ -2749,7 +2784,7 @@ var InventoryView = class {
2749
2784
  return out;
2750
2785
  }
2751
2786
  async assets(registry, price) {
2752
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
2787
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
2753
2788
  if (!price || raw.length === 0) return raw;
2754
2789
  return withPrices(raw, registry, price);
2755
2790
  }
@@ -3527,6 +3562,20 @@ var Requests = class {
3527
3562
  var ReservationLedger = class {
3528
3563
  reservations = /* @__PURE__ */ new Map();
3529
3564
  tokenHolder = /* @__PURE__ */ new Map();
3565
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
3566
+ // IntentPins proves every one of them "no holder" means unknown, not free.
3567
+ authoritative = false;
3568
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
3569
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
3570
+ setAuthoritative(value) {
3571
+ const moved = this.authoritative !== value;
3572
+ this.authoritative = value;
3573
+ return moved;
3574
+ }
3575
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
3576
+ unprovenReason() {
3577
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
3578
+ }
3530
3579
  // All-or-nothing: every entry is validated before any state mutates.
3531
3580
  reserve(reservationId, entries) {
3532
3581
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -3553,6 +3602,13 @@ var ReservationLedger = class {
3553
3602
  getFreeAmount(tokenId, tokenAmount) {
3554
3603
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
3555
3604
  }
3605
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
3606
+ holderOf(tokenId) {
3607
+ return this.tokenHolder.get(tokenId);
3608
+ }
3609
+ tokensOf(reservationId) {
3610
+ return [...this.reservations.get(reservationId) ?? []];
3611
+ }
3556
3612
  clear() {
3557
3613
  this.reservations.clear();
3558
3614
  this.tokenHolder.clear();
@@ -3565,6 +3621,44 @@ var ReservationLedger = class {
3565
3621
  }
3566
3622
  };
3567
3623
 
3624
+ // modules/payments-v2/select/pins.ts
3625
+ var IntentPins = class {
3626
+ constructor(deps) {
3627
+ this.deps = deps;
3628
+ }
3629
+ pinned = /* @__PURE__ */ new Set();
3630
+ /** The keep-open reservation IS this intent's pin from now on. */
3631
+ adopt(transferId) {
3632
+ this.pinned.add(transferId);
3633
+ }
3634
+ async sync() {
3635
+ const { open, complete } = await this.deps.openIntents();
3636
+ let changed = false;
3637
+ for (const transferId of [...this.pinned]) {
3638
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
3639
+ this.pinned.delete(transferId);
3640
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
3641
+ this.deps.ledger.cancel(transferId);
3642
+ changed = true;
3643
+ }
3644
+ for (const [transferId, tokenIds] of open) {
3645
+ if (this.deps.isActive(transferId)) continue;
3646
+ this.pinned.add(transferId);
3647
+ changed = this.pin(transferId, tokenIds) || changed;
3648
+ }
3649
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
3650
+ if (changed || gateMoved) this.deps.changed();
3651
+ }
3652
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
3653
+ pin(transferId, tokenIds) {
3654
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
3655
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
3656
+ if (free.length === 0) return false;
3657
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
3658
+ return true;
3659
+ }
3660
+ };
3661
+
3568
3662
  // modules/payments-v2/select/selector.ts
3569
3663
  var DEFAULT_WORK_BUDGET = 2e4;
3570
3664
  var MAX_COMBINATION_SIZE = 5;
@@ -3655,6 +3749,11 @@ function searchCombinationsOfSize(window, size, target, search) {
3655
3749
  // modules/payments-v2/select/queue.ts
3656
3750
  var QUEUE_TIMEOUT_MS = 3e4;
3657
3751
  var QUEUE_MAX_SIZE = 100;
3752
+ function insufficientBalance(view, amount) {
3753
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
3754
+ 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";
3755
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
3756
+ }
3658
3757
  var SpendQueue = class {
3659
3758
  constructor(deps) {
3660
3759
  this.deps = deps;
@@ -3667,12 +3766,10 @@ var SpendQueue = class {
3667
3766
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
3668
3767
  }
3669
3768
  const amount = parseAmount(request.amount);
3670
- const { freeView, freeTotal } = this.freeView(request.coinId);
3769
+ const view = this.freeView(request.coinId);
3770
+ const { freeView, freeTotal } = view;
3671
3771
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
3672
- throw new SphereError(
3673
- "Insufficient balance for this transaction",
3674
- "SEND_INSUFFICIENT_BALANCE"
3675
- );
3772
+ throw insufficientBalance(view, amount);
3676
3773
  }
3677
3774
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
3678
3775
  if (plan !== null) {
@@ -3722,11 +3819,10 @@ var SpendQueue = class {
3722
3819
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3723
3820
  return "rejected";
3724
3821
  }
3725
- const { freeView, freeTotal } = this.freeView(entry.coinId);
3822
+ const view = this.freeView(entry.coinId);
3823
+ const { freeView, freeTotal } = view;
3726
3824
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
3727
- entry.reject(
3728
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
3729
- );
3825
+ entry.reject(insufficientBalance(view, entry.amount));
3730
3826
  return "rejected";
3731
3827
  }
3732
3828
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -3771,18 +3867,26 @@ var SpendQueue = class {
3771
3867
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3772
3868
  if (entries.length === 0) this.queues.delete(coinId);
3773
3869
  }
3774
- // Whole-token semantics: only fully-free tokens enter the selector pool.
3870
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
3871
+ // the reservations hold back is counted too — the refusal has to name it (#737).
3775
3872
  freeView(coinId) {
3776
3873
  const freeView = [];
3777
3874
  let freeTotal = 0n;
3875
+ let pinnedTotal = 0n;
3876
+ const holders = /* @__PURE__ */ new Set();
3877
+ const unproven = this.deps.ledger.unprovenReason();
3778
3878
  for (const entry of this.deps.getPool(coinId)) {
3779
3879
  if (entry.amount <= 0n) continue;
3780
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
3880
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
3881
+ if (holder === void 0) {
3781
3882
  freeView.push(entry);
3782
3883
  freeTotal += entry.amount;
3884
+ } else {
3885
+ pinnedTotal += entry.amount;
3886
+ holders.add(holder);
3783
3887
  }
3784
3888
  }
3785
- return { freeView, freeTotal };
3889
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
3786
3890
  }
3787
3891
  expectedChangeTotal(coinId) {
3788
3892
  let total = 0n;
@@ -3824,13 +3928,17 @@ function supportsDeterministicMint(engine) {
3824
3928
  }
3825
3929
  function composeFacadeParts(deps, hooks) {
3826
3930
  const ownPubkeyBytes = hexToBytes(deps.ownPubkey);
3931
+ const ledger = new ReservationLedger();
3827
3932
  const view = new InventoryView({
3828
3933
  port: deps.storagePort,
3829
3934
  kv: deps.kv,
3830
3935
  emit: (event) => deps.emit(event, {}),
3936
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
3937
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
3938
+ // must not call a token spendable that the queue is about to refuse to spend.
3939
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
3831
3940
  ...deps.now !== void 0 ? { now: deps.now } : {}
3832
3941
  });
3833
- const ledger = new ReservationLedger();
3834
3942
  const queue = new SpendQueue({
3835
3943
  ledger,
3836
3944
  getPool: (coinId) => view.pool(coinId),
@@ -3851,6 +3959,7 @@ function composeFacadeParts(deps, hooks) {
3851
3959
  ownPubkeyBytes,
3852
3960
  view,
3853
3961
  ledger,
3962
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
3854
3963
  queue,
3855
3964
  historyStore,
3856
3965
  machineStores,
@@ -3862,6 +3971,17 @@ function composeFacadeParts(deps, hooks) {
3862
3971
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3863
3972
  };
3864
3973
  }
3974
+ function buildPins(deps, hooks, parts) {
3975
+ return new IntentPins({
3976
+ ledger: parts.ledger,
3977
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
3978
+ isActive: (transferId) => hooks.isActiveOp(transferId),
3979
+ release: (tokenId) => {
3980
+ parts.view.release(tokenId);
3981
+ },
3982
+ changed: () => deps.emit("inventory:updated", {})
3983
+ });
3984
+ }
3865
3985
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3866
3986
  return {
3867
3987
  stores: machineStores,
@@ -3971,7 +4091,8 @@ var PaymentsFacade = class {
3971
4091
  this.deps = deps;
3972
4092
  const parts = composeFacadeParts(deps, {
3973
4093
  engine: () => this.engine(),
3974
- send: (request) => this.send(request)
4094
+ send: (request) => this.send(request),
4095
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
3975
4096
  });
3976
4097
  this.view = parts.view;
3977
4098
  this.ledger = parts.ledger;
@@ -3985,6 +4106,7 @@ var PaymentsFacade = class {
3985
4106
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3986
4107
  this.restoreDeps = parts.restoreDeps;
3987
4108
  this.requests = parts.requests;
4109
+ this.pins = parts.pins;
3988
4110
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3989
4111
  this.heartbeat = new ConvergenceHeartbeat({
3990
4112
  now: () => this.nowMs(),
@@ -4027,6 +4149,8 @@ var PaymentsFacade = class {
4027
4149
  passChain = new SerialChain();
4028
4150
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
4029
4151
  activeMoneyOps = /* @__PURE__ */ new Set();
4152
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
4153
+ pins;
4030
4154
  // ── lifecycle ──────────────────────────────────────────────────────────────
4031
4155
  async start() {
4032
4156
  if (this.started) return;
@@ -4049,6 +4173,7 @@ var PaymentsFacade = class {
4049
4173
  });
4050
4174
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4051
4175
  await this.deps.session.start();
4176
+ await this.pins.sync().catch(() => void 0);
4052
4177
  this.trackTail(this.runConvergencePass());
4053
4178
  this.trackTail(this.seedHeldStates());
4054
4179
  await this.view.fullPull().catch(() => void 0);
@@ -4119,6 +4244,7 @@ var PaymentsFacade = class {
4119
4244
  }
4120
4245
  async convergeBody() {
4121
4246
  const outcome = await this.converger.convergeOnce();
4247
+ await this.pins.sync().catch(() => void 0);
4122
4248
  const pending = await this.converger.pendingWork().catch(() => true);
4123
4249
  this.heartbeat.settle(outcome, pending);
4124
4250
  }
@@ -4358,6 +4484,7 @@ var PaymentsFacade = class {
4358
4484
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
4359
4485
  settleKeepOpen(ctx) {
4360
4486
  this.queue.clearExpectedChange(ctx.transferId);
4487
+ this.pins.adopt(ctx.transferId);
4361
4488
  this.heartbeat.arm();
4362
4489
  }
4363
4490
  settlePartial(ctx, partial) {