@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.
@@ -1066,6 +1066,8 @@ declare class PaymentsFacade implements PaymentsV2 {
1066
1066
  private readonly passChain;
1067
1067
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
1068
1068
  private readonly activeMoneyOps;
1069
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
1070
+ private readonly pins;
1069
1071
  constructor(deps: PaymentsFacadeDeps);
1070
1072
  start(): Promise<void>;
1071
1073
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
@@ -1066,6 +1066,8 @@ declare class PaymentsFacade implements PaymentsV2 {
1066
1066
  private readonly passChain;
1067
1067
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
1068
1068
  private readonly activeMoneyOps;
1069
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
1070
+ private readonly pins;
1069
1071
  constructor(deps: PaymentsFacadeDeps);
1070
1072
  start(): Promise<void>;
1071
1073
  /** §7 same-address restart gate: resolves only after in-flight ops settle. */
@@ -458,6 +458,7 @@ import { CertificationResponse, CertificationStatus } from "@unicitylabs/state-t
458
458
  import { StateId } from "@unicitylabs/state-transition-sdk/lib/api/StateId.js";
459
459
  import { InclusionProof } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProof.js";
460
460
  import { InclusionProofResponse } from "@unicitylabs/state-transition-sdk/lib/api/InclusionProofResponse.js";
461
+ import { JsonRpcNetworkError } from "@unicitylabs/state-transition-sdk/lib/api/json-rpc/JsonRpcNetworkError.js";
461
462
  import { RootTrustBase } from "@unicitylabs/state-transition-sdk/lib/api/bft/RootTrustBase.js";
462
463
  import { waitInclusionProof } from "@unicitylabs/state-transition-sdk/lib/util/InclusionProofUtils.js";
463
464
  import { InclusionProofVerificationStatus } from "@unicitylabs/state-transition-sdk/lib/transaction/verification/rule/InclusionProofVerificationRule.js";
@@ -1417,6 +1418,29 @@ async function derivePendingTransfers(stores, decryptPayload) {
1417
1418
  }
1418
1419
  return out;
1419
1420
  }
1421
+ async function deriveOpenIntentHolds(stores, decryptPayload) {
1422
+ const holds = /* @__PURE__ */ new Map();
1423
+ let complete = true;
1424
+ for (const entry of await stores.backstop.list()) {
1425
+ if (entry.disposition !== "open") continue;
1426
+ let payload = null;
1427
+ try {
1428
+ const raw = await decryptPayload(entry.payloadEnvelope);
1429
+ if (raw !== null && typeof raw === "object") payload = raw;
1430
+ } catch {
1431
+ complete = false;
1432
+ continue;
1433
+ }
1434
+ if (payload === null) {
1435
+ complete = false;
1436
+ continue;
1437
+ }
1438
+ const direct = Array.isArray(payload?.direct) ? payload.direct.filter((id) => typeof id === "string") : [];
1439
+ const split = typeof payload?.split?.tokenId === "string" ? [payload.split.tokenId] : [];
1440
+ holds.set(entry.transferId, [...direct, ...split]);
1441
+ }
1442
+ return { open: holds, complete };
1443
+ }
1420
1444
  function groupJournal(journal) {
1421
1445
  const byTransfer = /* @__PURE__ */ new Map();
1422
1446
  for (const entry of journal) {
@@ -1586,11 +1610,14 @@ function toAsset(coinId, totals, registry) {
1586
1610
  fiatValueEur: null
1587
1611
  };
1588
1612
  }
1613
+ function priceIdOf(registry, coinId) {
1614
+ return registry.getName(coinId).toLowerCase();
1615
+ }
1589
1616
  async function withPrices(raw, registry, price) {
1590
1617
  try {
1591
- const names = [...new Set(raw.map((a) => registry.getName(a.coinId)))];
1592
- const quotes = await price.getPrices(names);
1593
- return raw.map((a) => priceOne(a, quotes.get(registry.getName(a.coinId))));
1618
+ const ids = [...new Set(raw.map((a) => priceIdOf(registry, a.coinId)))];
1619
+ const quotes = await price.getPrices(ids);
1620
+ return raw.map((a) => priceOne(a, quotes.get(priceIdOf(registry, a.coinId))));
1594
1621
  } catch {
1595
1622
  return raw;
1596
1623
  }
@@ -2674,10 +2701,21 @@ var InventoryView = class {
2674
2701
  release(tokenId) {
2675
2702
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
2676
2703
  }
2704
+ pinned(tokenId) {
2705
+ return this.deps.isPinned?.(tokenId) ?? false;
2706
+ }
2707
+ /** In flight here OR pinned by a reservation: either way not spendable (#737). */
2708
+ held(tokenId) {
2709
+ return this.inFlight.has(tokenId) || this.pinned(tokenId);
2710
+ }
2711
+ // A PINNED token stays in the pool: the queue applies the ledger filter itself
2712
+ // and needs the entry to say how much is pinned when it refuses (#737). In
2713
+ // flight without a pin (spent, awaiting the mirror refresh) stays out.
2677
2714
  pool(coinId) {
2678
2715
  const out = [];
2679
2716
  for (const [tokenId, entry] of this.mirror) {
2680
- if (entry.status !== "active" || this.inFlight.has(tokenId)) continue;
2717
+ if (entry.status !== "active") continue;
2718
+ if (this.inFlight.has(tokenId) && !this.pinned(tokenId)) continue;
2681
2719
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
2682
2720
  const asset = entry.assets.find((a) => a.coinId === coinId);
2683
2721
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -2693,7 +2731,7 @@ var InventoryView = class {
2693
2731
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
2694
2732
  out.push(
2695
2733
  toToken(tokenId, entry, asset, registry, {
2696
- transferring: this.inFlight.has(tokenId),
2734
+ transferring: this.held(tokenId),
2697
2735
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
2698
2736
  })
2699
2737
  );
@@ -2701,7 +2739,7 @@ var InventoryView = class {
2701
2739
  return out;
2702
2740
  }
2703
2741
  async assets(registry, price) {
2704
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
2742
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
2705
2743
  if (!price || raw.length === 0) return raw;
2706
2744
  return withPrices(raw, registry, price);
2707
2745
  }
@@ -3479,6 +3517,20 @@ var Requests = class {
3479
3517
  var ReservationLedger = class {
3480
3518
  reservations = /* @__PURE__ */ new Map();
3481
3519
  tokenHolder = /* @__PURE__ */ new Map();
3520
+ // #738: the held-set is RECONSTRUCTED from the open intents, so until
3521
+ // IntentPins proves every one of them "no holder" means unknown, not free.
3522
+ authoritative = false;
3523
+ /** Sole writer: IntentPins.sync(), true only if it reconstructed every open intent.
3524
+ * Returns whether the gate actually moved, so callers signal transitions, not passes. */
3525
+ setAuthoritative(value) {
3526
+ const moved = this.authoritative !== value;
3527
+ this.authoritative = value;
3528
+ return moved;
3529
+ }
3530
+ /** Non-null while the held-set is unproven: nothing plans, nothing reads free. */
3531
+ unprovenReason() {
3532
+ return this.authoritative ? null : "Cannot yet verify which tokens are held by transfers still converging \u2014 spending is paused until that check succeeds";
3533
+ }
3482
3534
  // All-or-nothing: every entry is validated before any state mutates.
3483
3535
  reserve(reservationId, entries) {
3484
3536
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -3505,6 +3557,13 @@ var ReservationLedger = class {
3505
3557
  getFreeAmount(tokenId, tokenAmount) {
3506
3558
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
3507
3559
  }
3560
+ /** #737: who pins this token — the reporting side reads it so a held token is never called spendable. */
3561
+ holderOf(tokenId) {
3562
+ return this.tokenHolder.get(tokenId);
3563
+ }
3564
+ tokensOf(reservationId) {
3565
+ return [...this.reservations.get(reservationId) ?? []];
3566
+ }
3508
3567
  clear() {
3509
3568
  this.reservations.clear();
3510
3569
  this.tokenHolder.clear();
@@ -3517,6 +3576,44 @@ var ReservationLedger = class {
3517
3576
  }
3518
3577
  };
3519
3578
 
3579
+ // modules/payments-v2/select/pins.ts
3580
+ var IntentPins = class {
3581
+ constructor(deps) {
3582
+ this.deps = deps;
3583
+ }
3584
+ pinned = /* @__PURE__ */ new Set();
3585
+ /** The keep-open reservation IS this intent's pin from now on. */
3586
+ adopt(transferId) {
3587
+ this.pinned.add(transferId);
3588
+ }
3589
+ async sync() {
3590
+ const { open, complete } = await this.deps.openIntents();
3591
+ let changed = false;
3592
+ for (const transferId of [...this.pinned]) {
3593
+ if (open.has(transferId) || this.deps.isActive(transferId)) continue;
3594
+ this.pinned.delete(transferId);
3595
+ for (const tokenId of this.deps.ledger.tokensOf(transferId)) this.deps.release(tokenId);
3596
+ this.deps.ledger.cancel(transferId);
3597
+ changed = true;
3598
+ }
3599
+ for (const [transferId, tokenIds] of open) {
3600
+ if (this.deps.isActive(transferId)) continue;
3601
+ this.pinned.add(transferId);
3602
+ changed = this.pin(transferId, tokenIds) || changed;
3603
+ }
3604
+ const gateMoved = this.deps.ledger.setAuthoritative(complete);
3605
+ if (changed || gateMoved) this.deps.changed();
3606
+ }
3607
+ /** Every pass, not just on adoption; the free subset, since a partial pin beats none. */
3608
+ pin(transferId, tokenIds) {
3609
+ if (this.deps.ledger.tokensOf(transferId).length > 0) return false;
3610
+ const free = tokenIds.filter((tokenId) => this.deps.ledger.holderOf(tokenId) === void 0);
3611
+ if (free.length === 0) return false;
3612
+ this.deps.ledger.reserve(transferId, free.map((tokenId) => ({ tokenId })));
3613
+ return true;
3614
+ }
3615
+ };
3616
+
3520
3617
  // modules/payments-v2/select/selector.ts
3521
3618
  var DEFAULT_WORK_BUDGET = 2e4;
3522
3619
  var MAX_COMBINATION_SIZE = 5;
@@ -3607,6 +3704,11 @@ function searchCombinationsOfSize(window, size, target, search) {
3607
3704
  // modules/payments-v2/select/queue.ts
3608
3705
  var QUEUE_TIMEOUT_MS = 3e4;
3609
3706
  var QUEUE_MAX_SIZE = 100;
3707
+ function insufficientBalance(view, amount) {
3708
+ if (view.unproven !== null) return new SphereError(view.unproven, "SEND_INSUFFICIENT_BALANCE");
3709
+ 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";
3710
+ return new SphereError(message, "SEND_INSUFFICIENT_BALANCE");
3711
+ }
3610
3712
  var SpendQueue = class {
3611
3713
  constructor(deps) {
3612
3714
  this.deps = deps;
@@ -3619,12 +3721,10 @@ var SpendQueue = class {
3619
3721
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
3620
3722
  }
3621
3723
  const amount = parseAmount(request.amount);
3622
- const { freeView, freeTotal } = this.freeView(request.coinId);
3724
+ const view = this.freeView(request.coinId);
3725
+ const { freeView, freeTotal } = view;
3623
3726
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
3624
- throw new SphereError(
3625
- "Insufficient balance for this transaction",
3626
- "SEND_INSUFFICIENT_BALANCE"
3627
- );
3727
+ throw insufficientBalance(view, amount);
3628
3728
  }
3629
3729
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
3630
3730
  if (plan !== null) {
@@ -3674,11 +3774,10 @@ var SpendQueue = class {
3674
3774
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3675
3775
  return "rejected";
3676
3776
  }
3677
- const { freeView, freeTotal } = this.freeView(entry.coinId);
3777
+ const view = this.freeView(entry.coinId);
3778
+ const { freeView, freeTotal } = view;
3678
3779
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
3679
- entry.reject(
3680
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
3681
- );
3780
+ entry.reject(insufficientBalance(view, entry.amount));
3682
3781
  return "rejected";
3683
3782
  }
3684
3783
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -3723,18 +3822,26 @@ var SpendQueue = class {
3723
3822
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3724
3823
  if (entries.length === 0) this.queues.delete(coinId);
3725
3824
  }
3726
- // Whole-token semantics: only fully-free tokens enter the selector pool.
3825
+ // Whole-token semantics: only fully-free tokens enter the selector pool. What
3826
+ // the reservations hold back is counted too — the refusal has to name it (#737).
3727
3827
  freeView(coinId) {
3728
3828
  const freeView = [];
3729
3829
  let freeTotal = 0n;
3830
+ let pinnedTotal = 0n;
3831
+ const holders = /* @__PURE__ */ new Set();
3832
+ const unproven = this.deps.ledger.unprovenReason();
3730
3833
  for (const entry of this.deps.getPool(coinId)) {
3731
3834
  if (entry.amount <= 0n) continue;
3732
- if (this.deps.ledger.getFreeAmount(entry.tokenId, entry.amount) === entry.amount) {
3835
+ const holder = unproven === null ? this.deps.ledger.holderOf(entry.tokenId) : entry.tokenId;
3836
+ if (holder === void 0) {
3733
3837
  freeView.push(entry);
3734
3838
  freeTotal += entry.amount;
3839
+ } else {
3840
+ pinnedTotal += entry.amount;
3841
+ holders.add(holder);
3735
3842
  }
3736
3843
  }
3737
- return { freeView, freeTotal };
3844
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
3738
3845
  }
3739
3846
  expectedChangeTotal(coinId) {
3740
3847
  let total = 0n;
@@ -3776,13 +3883,17 @@ function supportsDeterministicMint(engine) {
3776
3883
  }
3777
3884
  function composeFacadeParts(deps, hooks) {
3778
3885
  const ownPubkeyBytes = hexToBytes(deps.ownPubkey);
3886
+ const ledger = new ReservationLedger();
3779
3887
  const view = new InventoryView({
3780
3888
  port: deps.storagePort,
3781
3889
  kv: deps.kv,
3782
3890
  emit: (event) => deps.emit(event, {}),
3891
+ // #737: a reserved token is unselectable, so it is never confirmed balance.
3892
+ // #738: while the held-set is unproven, EVERY token reads pinned — the report
3893
+ // must not call a token spendable that the queue is about to refuse to spend.
3894
+ isPinned: (tokenId) => ledger.unprovenReason() !== null || ledger.holderOf(tokenId) !== void 0,
3783
3895
  ...deps.now !== void 0 ? { now: deps.now } : {}
3784
3896
  });
3785
- const ledger = new ReservationLedger();
3786
3897
  const queue = new SpendQueue({
3787
3898
  ledger,
3788
3899
  getPool: (coinId) => view.pool(coinId),
@@ -3803,6 +3914,7 @@ function composeFacadeParts(deps, hooks) {
3803
3914
  ownPubkeyBytes,
3804
3915
  view,
3805
3916
  ledger,
3917
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
3806
3918
  queue,
3807
3919
  historyStore,
3808
3920
  machineStores,
@@ -3814,6 +3926,17 @@ function composeFacadeParts(deps, hooks) {
3814
3926
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3815
3927
  };
3816
3928
  }
3929
+ function buildPins(deps, hooks, parts) {
3930
+ return new IntentPins({
3931
+ ledger: parts.ledger,
3932
+ openIntents: () => deriveOpenIntentHolds(parts.machineStores, parts.machineDeps.decryptPayload),
3933
+ isActive: (transferId) => hooks.isActiveOp(transferId),
3934
+ release: (tokenId) => {
3935
+ parts.view.release(tokenId);
3936
+ },
3937
+ changed: () => deps.emit("inventory:updated", {})
3938
+ });
3939
+ }
3817
3940
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3818
3941
  return {
3819
3942
  stores: machineStores,
@@ -3923,7 +4046,8 @@ var PaymentsFacade = class {
3923
4046
  this.deps = deps;
3924
4047
  const parts = composeFacadeParts(deps, {
3925
4048
  engine: () => this.engine(),
3926
- send: (request) => this.send(request)
4049
+ send: (request) => this.send(request),
4050
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
3927
4051
  });
3928
4052
  this.view = parts.view;
3929
4053
  this.ledger = parts.ledger;
@@ -3937,6 +4061,7 @@ var PaymentsFacade = class {
3937
4061
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3938
4062
  this.restoreDeps = parts.restoreDeps;
3939
4063
  this.requests = parts.requests;
4064
+ this.pins = parts.pins;
3940
4065
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3941
4066
  this.heartbeat = new ConvergenceHeartbeat({
3942
4067
  now: () => this.nowMs(),
@@ -3979,6 +4104,8 @@ var PaymentsFacade = class {
3979
4104
  passChain = new SerialChain();
3980
4105
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3981
4106
  activeMoneyOps = /* @__PURE__ */ new Set();
4107
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
4108
+ pins;
3982
4109
  // ── lifecycle ──────────────────────────────────────────────────────────────
3983
4110
  async start() {
3984
4111
  if (this.started) return;
@@ -4001,6 +4128,7 @@ var PaymentsFacade = class {
4001
4128
  });
4002
4129
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4003
4130
  await this.deps.session.start();
4131
+ await this.pins.sync().catch(() => void 0);
4004
4132
  this.trackTail(this.runConvergencePass());
4005
4133
  this.trackTail(this.seedHeldStates());
4006
4134
  await this.view.fullPull().catch(() => void 0);
@@ -4071,6 +4199,7 @@ var PaymentsFacade = class {
4071
4199
  }
4072
4200
  async convergeBody() {
4073
4201
  const outcome = await this.converger.convergeOnce();
4202
+ await this.pins.sync().catch(() => void 0);
4074
4203
  const pending = await this.converger.pendingWork().catch(() => true);
4075
4204
  this.heartbeat.settle(outcome, pending);
4076
4205
  }
@@ -4310,6 +4439,7 @@ var PaymentsFacade = class {
4310
4439
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
4311
4440
  settleKeepOpen(ctx) {
4312
4441
  this.queue.clearExpectedChange(ctx.transferId);
4442
+ this.pins.adopt(ctx.transferId);
4313
4443
  this.heartbeat.arm();
4314
4444
  }
4315
4445
  settlePartial(ctx, partial) {