@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.
@@ -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) {
@@ -2677,10 +2701,21 @@ var InventoryView = class {
2677
2701
  release(tokenId) {
2678
2702
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
2679
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.
2680
2714
  pool(coinId) {
2681
2715
  const out = [];
2682
2716
  for (const [tokenId, entry] of this.mirror) {
2683
- 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;
2684
2719
  if (this.suspected.has(stateKey(tokenId, entry.stateHash))) continue;
2685
2720
  const asset = entry.assets.find((a) => a.coinId === coinId);
2686
2721
  if (asset) out.push({ tokenId, amount: BigInt(asset.amount) });
@@ -2696,7 +2731,7 @@ var InventoryView = class {
2696
2731
  if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
2697
2732
  out.push(
2698
2733
  toToken(tokenId, entry, asset, registry, {
2699
- transferring: this.inFlight.has(tokenId),
2734
+ transferring: this.held(tokenId),
2700
2735
  suspectedSpent: this.suspected.has(stateKey(tokenId, entry.stateHash))
2701
2736
  })
2702
2737
  );
@@ -2704,7 +2739,7 @@ var InventoryView = class {
2704
2739
  return out;
2705
2740
  }
2706
2741
  async assets(registry, price) {
2707
- const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.inFlight.has(tokenId), registry);
2742
+ const raw = aggregateAssets(this.activeEntries(), (tokenId) => this.held(tokenId), registry);
2708
2743
  if (!price || raw.length === 0) return raw;
2709
2744
  return withPrices(raw, registry, price);
2710
2745
  }
@@ -3482,6 +3517,20 @@ var Requests = class {
3482
3517
  var ReservationLedger = class {
3483
3518
  reservations = /* @__PURE__ */ new Map();
3484
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
+ }
3485
3534
  // All-or-nothing: every entry is validated before any state mutates.
3486
3535
  reserve(reservationId, entries) {
3487
3536
  if (entries.length === 0) throw new Error("EMPTY_RESERVATION");
@@ -3508,6 +3557,13 @@ var ReservationLedger = class {
3508
3557
  getFreeAmount(tokenId, tokenAmount) {
3509
3558
  return this.tokenHolder.has(tokenId) ? 0n : tokenAmount;
3510
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
+ }
3511
3567
  clear() {
3512
3568
  this.reservations.clear();
3513
3569
  this.tokenHolder.clear();
@@ -3520,6 +3576,44 @@ var ReservationLedger = class {
3520
3576
  }
3521
3577
  };
3522
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
+
3523
3617
  // modules/payments-v2/select/selector.ts
3524
3618
  var DEFAULT_WORK_BUDGET = 2e4;
3525
3619
  var MAX_COMBINATION_SIZE = 5;
@@ -3610,6 +3704,11 @@ function searchCombinationsOfSize(window, size, target, search) {
3610
3704
  // modules/payments-v2/select/queue.ts
3611
3705
  var QUEUE_TIMEOUT_MS = 3e4;
3612
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
+ }
3613
3712
  var SpendQueue = class {
3614
3713
  constructor(deps) {
3615
3714
  this.deps = deps;
@@ -3622,12 +3721,10 @@ var SpendQueue = class {
3622
3721
  throw new SphereError("Module has been destroyed", "MODULE_DESTROYED");
3623
3722
  }
3624
3723
  const amount = parseAmount(request.amount);
3625
- const { freeView, freeTotal } = this.freeView(request.coinId);
3724
+ const view = this.freeView(request.coinId);
3725
+ const { freeView, freeTotal } = view;
3626
3726
  if (freeTotal + this.expectedChangeTotal(request.coinId) < amount) {
3627
- throw new SphereError(
3628
- "Insufficient balance for this transaction",
3629
- "SEND_INSUFFICIENT_BALANCE"
3630
- );
3727
+ throw insufficientBalance(view, amount);
3631
3728
  }
3632
3729
  const plan = selectCoins(freeView, amount, { workBudget: this.deps.workBudget });
3633
3730
  if (plan !== null) {
@@ -3677,11 +3774,10 @@ var SpendQueue = class {
3677
3774
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3678
3775
  return "rejected";
3679
3776
  }
3680
- const { freeView, freeTotal } = this.freeView(entry.coinId);
3777
+ const view = this.freeView(entry.coinId);
3778
+ const { freeView, freeTotal } = view;
3681
3779
  if (freeTotal + this.expectedChangeTotal(entry.coinId) < entry.amount) {
3682
- entry.reject(
3683
- new SphereError("Insufficient balance for this transaction", "SEND_INSUFFICIENT_BALANCE")
3684
- );
3780
+ entry.reject(insufficientBalance(view, entry.amount));
3685
3781
  return "rejected";
3686
3782
  }
3687
3783
  const plan = selectCoins(freeView, entry.amount, { workBudget: this.deps.workBudget });
@@ -3726,18 +3822,26 @@ var SpendQueue = class {
3726
3822
  entry.reject(new SphereError("Send queue timeout", "SEND_QUEUE_TIMEOUT"));
3727
3823
  if (entries.length === 0) this.queues.delete(coinId);
3728
3824
  }
3729
- // 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).
3730
3827
  freeView(coinId) {
3731
3828
  const freeView = [];
3732
3829
  let freeTotal = 0n;
3830
+ let pinnedTotal = 0n;
3831
+ const holders = /* @__PURE__ */ new Set();
3832
+ const unproven = this.deps.ledger.unprovenReason();
3733
3833
  for (const entry of this.deps.getPool(coinId)) {
3734
3834
  if (entry.amount <= 0n) continue;
3735
- 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) {
3736
3837
  freeView.push(entry);
3737
3838
  freeTotal += entry.amount;
3839
+ } else {
3840
+ pinnedTotal += entry.amount;
3841
+ holders.add(holder);
3738
3842
  }
3739
3843
  }
3740
- return { freeView, freeTotal };
3844
+ return { freeView, freeTotal, pinnedTotal, pinnedBy: holders.size, unproven };
3741
3845
  }
3742
3846
  expectedChangeTotal(coinId) {
3743
3847
  let total = 0n;
@@ -3779,13 +3883,17 @@ function supportsDeterministicMint(engine) {
3779
3883
  }
3780
3884
  function composeFacadeParts(deps, hooks) {
3781
3885
  const ownPubkeyBytes = hexToBytes(deps.ownPubkey);
3886
+ const ledger = new ReservationLedger();
3782
3887
  const view = new InventoryView({
3783
3888
  port: deps.storagePort,
3784
3889
  kv: deps.kv,
3785
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,
3786
3895
  ...deps.now !== void 0 ? { now: deps.now } : {}
3787
3896
  });
3788
- const ledger = new ReservationLedger();
3789
3897
  const queue = new SpendQueue({
3790
3898
  ledger,
3791
3899
  getPool: (coinId) => view.pool(coinId),
@@ -3806,6 +3914,7 @@ function composeFacadeParts(deps, hooks) {
3806
3914
  ownPubkeyBytes,
3807
3915
  view,
3808
3916
  ledger,
3917
+ pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
3809
3918
  queue,
3810
3919
  historyStore,
3811
3920
  machineStores,
@@ -3817,6 +3926,17 @@ function composeFacadeParts(deps, hooks) {
3817
3926
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
3818
3927
  };
3819
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
+ }
3820
3940
  function buildRestoreDeps(deps, machineDeps, machineStores, view) {
3821
3941
  return {
3822
3942
  stores: machineStores,
@@ -3926,7 +4046,8 @@ var PaymentsFacade = class {
3926
4046
  this.deps = deps;
3927
4047
  const parts = composeFacadeParts(deps, {
3928
4048
  engine: () => this.engine(),
3929
- send: (request) => this.send(request)
4049
+ send: (request) => this.send(request),
4050
+ isActiveOp: (id) => this.activeMoneyOps.has(id)
3930
4051
  });
3931
4052
  this.view = parts.view;
3932
4053
  this.ledger = parts.ledger;
@@ -3940,6 +4061,7 @@ var PaymentsFacade = class {
3940
4061
  this.ownPubkeyBytes = parts.ownPubkeyBytes;
3941
4062
  this.restoreDeps = parts.restoreDeps;
3942
4063
  this.requests = parts.requests;
4064
+ this.pins = parts.pins;
3943
4065
  deps.deliveryPort.bindDeliveryKeys((blob) => this.engine().deliveryKeys(blob));
3944
4066
  this.heartbeat = new ConvergenceHeartbeat({
3945
4067
  now: () => this.nowMs(),
@@ -3982,6 +4104,8 @@ var PaymentsFacade = class {
3982
4104
  passChain = new SerialChain();
3983
4105
  /** §7 ownership: ids with an in-process machine attempt — the pass never adopts one. */
3984
4106
  activeMoneyOps = /* @__PURE__ */ new Set();
4107
+ /** #737: the ledger holds exactly the sources of the still-open intents. */
4108
+ pins;
3985
4109
  // ── lifecycle ──────────────────────────────────────────────────────────────
3986
4110
  async start() {
3987
4111
  if (this.started) return;
@@ -4004,6 +4128,7 @@ var PaymentsFacade = class {
4004
4128
  });
4005
4129
  if (statusUnsub !== void 0) this.unsubscribers.push(statusUnsub);
4006
4130
  await this.deps.session.start();
4131
+ await this.pins.sync().catch(() => void 0);
4007
4132
  this.trackTail(this.runConvergencePass());
4008
4133
  this.trackTail(this.seedHeldStates());
4009
4134
  await this.view.fullPull().catch(() => void 0);
@@ -4074,6 +4199,7 @@ var PaymentsFacade = class {
4074
4199
  }
4075
4200
  async convergeBody() {
4076
4201
  const outcome = await this.converger.convergeOnce();
4202
+ await this.pins.sync().catch(() => void 0);
4077
4203
  const pending = await this.converger.pendingWork().catch(() => true);
4078
4204
  this.heartbeat.settle(outcome, pending);
4079
4205
  }
@@ -4313,6 +4439,7 @@ var PaymentsFacade = class {
4313
4439
  /** Open intent: sources stay reserved + in-flight (§5.2) until resume adopts them. */
4314
4440
  settleKeepOpen(ctx) {
4315
4441
  this.queue.clearExpectedChange(ctx.transferId);
4442
+ this.pins.adopt(ctx.transferId);
4316
4443
  this.heartbeat.arm();
4317
4444
  }
4318
4445
  settlePartial(ctx, partial) {