@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.
@@ -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) {
@@ -1631,11 +1655,14 @@ function toAsset(coinId, totals, registry) {
1631
1655
  fiatValueEur: null
1632
1656
  };
1633
1657
  }
1658
+ function priceIdOf(registry, coinId) {
1659
+ return registry.getName(coinId).toLowerCase();
1660
+ }
1634
1661
  async function withPrices(raw, registry, price) {
1635
1662
  try {
1636
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
1637
- const quotes = await price.getPrices(names);
1638
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
1663
+ const ids = [...new Set(raw.map((a) => priceIdOf(registry, a.coinId)))];
1664
+ const quotes = await price.getPrices(ids);
1665
+ return raw.map((a) => priceOne(a, quotes.get(priceIdOf(registry, a.coinId))));
1639
1666
  } catch {
1640
1667
  return raw;
1641
1668
  }
@@ -2719,10 +2746,21 @@ var InventoryView = class {
2719
2746
  release(tokenId) {
2720
2747
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
2721
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.
2722
2759
  pool(coinId) {
2723
2760
  const out = [];
2724
2761
  for (const [tokenId, entry] of this.mirror) {
2725
- 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;
2726
2764
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
2727
2765
  const asset = entry.assets.find((a) => a.coinId === coinId);
2728
2766
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -2738,7 +2776,7 @@ var InventoryView = class {
2738
2776
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
2739
2777
  out.push(
2740
2778
  toToken(tokenId, entry, asset, registry, {
2741
- transferring: this.inFlight.has(tokenId),
2779
+ transferring: this.held(tokenId),
2742
2780
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
2743
2781
  })
2744
2782
  );
@@ -2746,7 +2784,7 @@ var InventoryView = class {
2746
2784
  return out;
2747
2785
  }
2748
2786
  async assets(registry, price) {
2749
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
2787
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
2750
2788
  if (!price || raw.length === 0) return raw;
2751
2789
  return withPrices(raw, registry, price);
2752
2790
  }
@@ -3524,6 +3562,20 @@ var Requests = class {
3524
3562
  var ReservationLedger = class {
3525
3563
  reservations = /* @__PURE__ */ new Map();
3526
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
+ }
3527
3579
  // All-or-nothing: every entry is validated before any state mutates.
3528
3580
  reserve(reservationId, entries) {
3529
3581
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -3550,6 +3602,13 @@ var ReservationLedger = class {
3550
3602
  getFreeAmount(tokenId, tokenAmount) {
3551
3603
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
3552
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
+ }
3553
3612
  clear() {
3554
3613
  this.reservations.clear();
3555
3614
  this.tokenHolder.clear();
@@ -3562,6 +3621,44 @@ var ReservationLedger = class {
3562
3621
  }
3563
3622
  };
3564
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
+
3565
3662
  // modules/payments-v2/select/selector.ts
3566
3663
  var DEFAULT_WORK_BUDGET = 2e4;
3567
3664
  var MAX_COMBINATION_SIZE = 5;
@@ -3652,6 +3749,11 @@ function searchCombinationsOfSize(window, size, target, search) {
3652
3749
  // modules/payments-v2/select/queue.ts
3653
3750
  var QUEUE_TIMEOUT_MS = 3e4;
3654
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
+ }
3655
3757
  var SpendQueue = class {
3656
3758
  constructor(deps) {
3657
3759
  this.deps = deps;
@@ -3664,12 +3766,10 @@ var SpendQueue = class {
3664
3766
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
3665
3767
  }
3666
3768
  const amount = parseAmount(request.amount);
3667
- const { freeView, freeTotal } = this.freeView(request.coinId);
3769
+ const view = this.freeView(request.coinId);
3770
+ const { freeView, freeTotal } = view;
3668
3771
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
3669
- throw new SphereError(
3670
- "Insufficient balance for this transaction",
3671
- "SEND_INSUFFICIENT_BALANCE"
3672
- );
3772
+ throw insufficientBalance(view, amount);
3673
3773
  }
3674
3774
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
3675
3775
  if (plan !== null) {
@@ -3719,11 +3819,10 @@ var SpendQueue = class {
3719
3819
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3720
3820
  return "rejected";
3721
3821
  }
3722
- const { freeView, freeTotal } = this.freeView(entry.coinId);
3822
+ const view = this.freeView(entry.coinId);
3823
+ const { freeView, freeTotal } = view;
3723
3824
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
3724
- entry.reject(
3725
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
3726
- );
3825
+ entry.reject(insufficientBalance(view, entry.amount));
3727
3826
  return "rejected";
3728
3827
  }
3729
3828
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -3768,18 +3867,26 @@ var SpendQueue = class {
3768
3867
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3769
3868
  if (entries.length === 0) this.queues.delete(coinId);
3770
3869
  }
3771
- // 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).
3772
3872
  freeView(coinId) {
3773
3873
  const freeView = [];
3774
3874
  let freeTotal = 0n;
3875
+ let pinnedTotal = 0n;
3876
+ const holders = /* @__PURE__ */ new Set();
3877
+ const unproven = this.deps.ledger.unprovenReason();
3775
3878
  for (const entry of this.deps.getPool(coinId)) {
3776
3879
  if (entry.amount <= 0n) continue;
3777
- 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) {
3778
3882
  freeView.push(entry);
3779
3883
  freeTotal += entry.amount;
3884
+ } else {
3885
+ pinnedTotal += entry.amount;
3886
+ holders.add(holder);
3780
3887
  }
3781
3888
  }
3782
- return { freeView, freeTotal };
3889
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
3783
3890
  }
3784
3891
  expectedChangeTotal(coinId) {
3785
3892
  let total = 0n;
@@ -3821,13 +3928,17 @@ function supportsDeterministicMint(engine) {
3821
3928
  }
3822
3929
  function composeFacadeParts(deps, hooks) {
3823
3930
  const ownPubkeyBytes = hexToBytes(deps.ownPubkey);
3931
+ const ledger = new ReservationLedger();
3824
3932
  const view = new InventoryView({
3825
3933
  port: deps.storagePort,
3826
3934
  kv: deps.kv,
3827
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,
3828
3940
  ...deps.now !== void 0 ? { now: deps.now } : {}
3829
3941
  });
3830
- const ledger = new ReservationLedger();
3831
3942
  const queue = new SpendQueue({
3832
3943
  ledger,
3833
3944
  getPool: (coinId) => view.pool(coinId),
@@ -3848,6 +3959,7 @@ function composeFacadeParts(deps, hooks) {
3848
3959
  ownPubkeyBytes,
3849
3960
  view,
3850
3961
  ledger,
3962
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
3851
3963
  queue,
3852
3964
  historyStore,
3853
3965
  machineStores,
@@ -3859,6 +3971,17 @@ function composeFacadeParts(deps, hooks) {
3859
3971
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3860
3972
  };
3861
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
+ }
3862
3985
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3863
3986
  return {
3864
3987
  stores: machineStores,
@@ -3968,7 +4091,8 @@ var PaymentsFacade = class {
3968
4091
  this.deps = deps;
3969
4092
  const parts = composeFacadeParts(deps, {
3970
4093
  engine: () => this.engine(),
3971
- send: (request) => this.send(request)
4094
+ send: (request) => this.send(request),
4095
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
3972
4096
  });
3973
4097
  this.view = parts.view;
3974
4098
  this.ledger = parts.ledger;
@@ -3982,6 +4106,7 @@ var PaymentsFacade = class {
3982
4106
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3983
4107
  this.restoreDeps = parts.restoreDeps;
3984
4108
  this.requests = parts.requests;
4109
+ this.pins = parts.pins;
3985
4110
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3986
4111
  this.heartbeat = new ConvergenceHeartbeat({
3987
4112
  now: () => this.nowMs(),
@@ -4024,6 +4149,8 @@ var PaymentsFacade = class {
4024
4149
  passChain = new SerialChain();
4025
4150
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
4026
4151
  activeMoneyOps = /* @__PURE__ */ new Set();
4152
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
4153
+ pins;
4027
4154
  // ── lifecycle ──────────────────────────────────────────────────────────────
4028
4155
  async start() {
4029
4156
  if (this.started) return;
@@ -4046,6 +4173,7 @@ var PaymentsFacade = class {
4046
4173
  });
4047
4174
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4048
4175
  await this.deps.session.start();
4176
+ await this.pins.sync().catch(() => void 0);
4049
4177
  this.trackTail(this.runConvergencePass());
4050
4178
  this.trackTail(this.seedHeldStates());
4051
4179
  await this.view.fullPull().catch(() => void 0);
@@ -4116,6 +4244,7 @@ var PaymentsFacade = class {
4116
4244
  }
4117
4245
  async convergeBody() {
4118
4246
  const outcome = await this.converger.convergeOnce();
4247
+ await this.pins.sync().catch(() => void 0);
4119
4248
  const pending = await this.converger.pendingWork().catch(() => true);
4120
4249
  this.heartbeat.settle(outcome, pending);
4121
4250
  }
@@ -4355,6 +4484,7 @@ var PaymentsFacade = class {
4355
4484
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
4356
4485
  settleKeepOpen(ctx) {
4357
4486
  this.queue.clearExpectedChange(ctx.transferId);
4487
+ this.pins.adopt(ctx.transferId);
4358
4488
  this.heartbeat.arm();
4359
4489
  }
4360
4490
  settlePartial(ctx, partial) {