@unicitylabs/sphere-sdk 0.14.11-dev.5 → 0.14.11-dev.7

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.js CHANGED
@@ -11233,6 +11233,26 @@ var SerialChain = class {
11233
11233
  return run;
11234
11234
  }
11235
11235
  };
11236
+ function coalesced(run) {
11237
+ let inFlight = false;
11238
+ let queued = false;
11239
+ const start = () => {
11240
+ inFlight = true;
11241
+ void run().catch(() => void 0).finally(() => {
11242
+ inFlight = false;
11243
+ if (!queued) return;
11244
+ queued = false;
11245
+ start();
11246
+ });
11247
+ };
11248
+ return () => {
11249
+ if (inFlight) {
11250
+ queued = true;
11251
+ return;
11252
+ }
11253
+ start();
11254
+ };
11255
+ }
11236
11256
 
11237
11257
  // modules/payments-v2/stores.ts
11238
11258
  function createScopedKV(storage, network, chainPubkey) {
@@ -12680,6 +12700,16 @@ var InventoryView = class {
12680
12700
  this.inFlight.add(tokenId);
12681
12701
  this.deps.emit("inventory:updated");
12682
12702
  }
12703
+ /** Reserve a whole plan as ONE change — per token emitted 54 events per send. */
12704
+ markInFlightMany(tokenIds) {
12705
+ let changed = false;
12706
+ for (const tokenId of tokenIds) {
12707
+ if (this.inFlight.has(tokenId)) continue;
12708
+ this.inFlight.add(tokenId);
12709
+ changed = true;
12710
+ }
12711
+ if (changed) this.deps.emit("inventory:updated");
12712
+ }
12683
12713
  release(tokenId) {
12684
12714
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12685
12715
  }
@@ -12904,6 +12934,11 @@ var InventoryView = class {
12904
12934
  }
12905
12935
  };
12906
12936
 
12937
+ // modules/payments-v2/ports.ts
12938
+ function isRetryableAckError(err) {
12939
+ return err !== null && typeof err === "object" && err.retryable === true;
12940
+ }
12941
+
12907
12942
  // modules/payments-v2/receive/Receive.ts
12908
12943
  var ACK_BATCH_SIZE = 200;
12909
12944
  var REFRESH_INTERVAL_MS = 2500;
@@ -12985,6 +13020,7 @@ var Receive = class {
12985
13020
  }
12986
13021
  } catch (err) {
12987
13022
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
13023
+ if (isRetryableAckError(err)) return stored;
12988
13024
  await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12989
13025
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12990
13026
  });
@@ -13059,14 +13095,18 @@ async function announce(deps, entry, record) {
13059
13095
  };
13060
13096
  }
13061
13097
  async function flushAcks(deps, pending, epochOf) {
13098
+ if (pending.length === 0) return;
13062
13099
  let lastAcked = null;
13063
13100
  try {
13064
- while (pending.length > 0) {
13065
- const next = pending[0];
13066
- await ackOne(deps, next);
13067
- lastAcked = next.cursor;
13068
- pending.shift();
13101
+ const settled = await settleAcks(deps, pending);
13102
+ let i = 0;
13103
+ while (i < pending.length && settled.has(pending[i].deliveryId)) {
13104
+ lastAcked = pending[i].cursor;
13105
+ i += 1;
13069
13106
  }
13107
+ const stuck = pending.filter((p) => !settled.has(p.deliveryId));
13108
+ pending.length = 0;
13109
+ pending.push(...stuck);
13070
13110
  } finally {
13071
13111
  if (lastAcked !== null) {
13072
13112
  const record = { cursor: lastAcked, syncEpoch: epochOf() };
@@ -13074,14 +13114,65 @@ async function flushAcks(deps, pending, epochOf) {
13074
13114
  }
13075
13115
  }
13076
13116
  }
13117
+ async function settleAcks(deps, pending) {
13118
+ const batch = deps.delivery.ackBatch?.bind(deps.delivery);
13119
+ if (batch === void 0) return settleOneByOne(deps, pending);
13120
+ let outcomes;
13121
+ try {
13122
+ outcomes = await batch(pending.map(toAckRequest));
13123
+ } catch (err) {
13124
+ if (isRetryableAckError(err)) throw err;
13125
+ logger.warn("PaymentsV2", "batched mailbox ack failed \u2014 settling one at a time:", err);
13126
+ return settleOneByOne(deps, pending);
13127
+ }
13128
+ const settled = new Set(outcomes.filter((o) => o.status === "settled").map((o) => o.deliveryId));
13129
+ const conflicts = outcomes.filter((o) => o.status === "conflict").map((o) => o.deliveryId);
13130
+ if (conflicts.length > 0) await resolveConflicts(deps, pending, conflicts, settled);
13131
+ return settled;
13132
+ }
13133
+ async function resolveConflicts(deps, pending, conflicts, settled) {
13134
+ for (const deliveryId of conflicts) {
13135
+ const entry = pending.find((p) => p.deliveryId === deliveryId);
13136
+ try {
13137
+ await rejectStaleClaim(deps, deliveryId, entry?.transferId);
13138
+ } catch (err) {
13139
+ logger.warn("PaymentsV2", `stale-reject failed for ${deliveryId} \u2014 cursor holds here:`, err);
13140
+ continue;
13141
+ }
13142
+ settled.add(deliveryId);
13143
+ }
13144
+ }
13145
+ async function rejectStaleClaim(deps, deliveryId, transferId) {
13146
+ logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${deliveryId} \u2014 rejected('other') as stale`);
13147
+ await deps.delivery.ack(deliveryId, "rejected", "other");
13148
+ deps.attention(transferId ?? "", ATTENTION_CLAIM_CONFLICT, deliveryId);
13149
+ }
13150
+ async function settleOneByOne(deps, pending) {
13151
+ const settled = /* @__PURE__ */ new Set();
13152
+ for (const ack of pending) {
13153
+ try {
13154
+ await ackOne(deps, ack);
13155
+ } catch (err) {
13156
+ logger.warn("PaymentsV2", `mailbox ack failed for ${ack.deliveryId} \u2014 cursor holds here:`, err);
13157
+ break;
13158
+ }
13159
+ settled.add(ack.deliveryId);
13160
+ }
13161
+ return settled;
13162
+ }
13163
+ function toAckRequest(ack) {
13164
+ return {
13165
+ deliveryId: ack.deliveryId,
13166
+ disposition: ack.disposition,
13167
+ ...ack.reason !== void 0 ? { reason: ack.reason } : {}
13168
+ };
13169
+ }
13077
13170
  async function ackOne(deps, ack) {
13078
13171
  try {
13079
13172
  await deps.delivery.ack(ack.deliveryId, ack.disposition, ack.reason);
13080
13173
  } catch (err) {
13081
13174
  if (ack.disposition !== "claimed" || !isClaimConflict(err)) throw err;
13082
- logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${ack.deliveryId} \u2014 rejected('other') as stale`);
13083
- await deps.delivery.ack(ack.deliveryId, "rejected", "other");
13084
- deps.attention(ack.transferId ?? "", ATTENTION_CLAIM_CONFLICT, ack.deliveryId);
13175
+ await rejectStaleClaim(deps, ack.deliveryId, ack.transferId);
13085
13176
  }
13086
13177
  }
13087
13178
  function claimAck(entry) {
@@ -13835,16 +13926,6 @@ function parseAmount(raw) {
13835
13926
  function supportsDeterministicMint(engine) {
13836
13927
  return engine.deterministicMint === true;
13837
13928
  }
13838
- function makeSingleFlightRefresh(refresh) {
13839
- let inFlight = false;
13840
- return () => {
13841
- if (inFlight) return;
13842
- inFlight = true;
13843
- void refresh().catch(() => void 0).finally(() => {
13844
- inFlight = false;
13845
- });
13846
- };
13847
- }
13848
13929
  function composeFacadeParts(deps, hooks) {
13849
13930
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13850
13931
  const ledger = new ReservationLedger();
@@ -13874,9 +13955,11 @@ function composeFacadeParts(deps, hooks) {
13874
13955
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13875
13956
  const machineStores = createMachineStores(deps.kv);
13876
13957
  const heldStates = /* @__PURE__ */ new Map();
13958
+ const refreshView = coalesced(() => view.delta());
13877
13959
  return {
13878
13960
  ownPubkeyBytes,
13879
13961
  view,
13962
+ refreshView,
13880
13963
  ledger,
13881
13964
  pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13882
13965
  queue,
@@ -13885,7 +13968,7 @@ function composeFacadeParts(deps, hooks) {
13885
13968
  machineDeps,
13886
13969
  machine: new TransferMachine(machineDeps),
13887
13970
  heldStates,
13888
- receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, view),
13971
+ receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, refreshView),
13889
13972
  requests: buildRequests(deps, hooks),
13890
13973
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13891
13974
  };
@@ -13952,7 +14035,7 @@ function buildMachineDeps(deps, hooks, historyStore) {
13952
14035
  }
13953
14036
  };
13954
14037
  }
13955
- function buildReceive(deps, hooks, historyStore, heldStates, view) {
14038
+ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
13956
14039
  return new Receive({
13957
14040
  delivery: deps.deliveryPort,
13958
14041
  engine: () => hooks.engine(),
@@ -13966,10 +14049,7 @@ function buildReceive(deps, hooks, historyStore, heldStates, view) {
13966
14049
  registry: deps.registry,
13967
14050
  recordReceived: (record) => recordReceived(historyStore, record),
13968
14051
  emit: (event, transfer) => deps.emit(event, transfer),
13969
- // Single-flight: view.delta() enqueues on a strict-FIFO SerialChain, so
13970
- // firing per throttle tick regardless of the last one builds a queue that
13971
- // drains in a burst at the END — a slower version of the bug being fixed.
13972
- refreshView: makeSingleFlightRefresh(() => view.delta()),
14052
+ refreshView,
13973
14053
  attention: (transferId, code, detail) => {
13974
14054
  deps.emit(
13975
14055
  "transfer:attention",
@@ -14021,6 +14101,7 @@ var PaymentsFacade = class {
14021
14101
  isActiveOp: (id) => this.activeMoneyOps.has(id)
14022
14102
  });
14023
14103
  this.view = parts.view;
14104
+ this.wakeRefresh = parts.refreshView;
14024
14105
  this.ledger = parts.ledger;
14025
14106
  this.queue = parts.queue;
14026
14107
  this.machine = parts.machine;
@@ -14050,6 +14131,7 @@ var PaymentsFacade = class {
14050
14131
  now: () => this.nowMs()
14051
14132
  });
14052
14133
  }
14134
+ wakeRefresh;
14053
14135
  prewarmed = new PrewarmCache();
14054
14136
  view;
14055
14137
  ledger;
@@ -14088,9 +14170,7 @@ var PaymentsFacade = class {
14088
14170
  this.deps.session.subscribeStream("mailbox", () => {
14089
14171
  this.trackTail(this.receiveLoop.drainOnce());
14090
14172
  }),
14091
- this.deps.session.subscribeStream("inventory", () => {
14092
- this.trackTail(this.view.delta());
14093
- }),
14173
+ this.deps.session.subscribeStream("inventory", this.wakeRefresh),
14094
14174
  this.deps.session.subscribeStream("payment_requests", () => {
14095
14175
  this.trackTail(this.requests.drainIncoming());
14096
14176
  })
@@ -14354,7 +14434,7 @@ var PaymentsFacade = class {
14354
14434
  ...spend.plan.direct,
14355
14435
  ...spend.plan.split !== void 0 ? [spend.plan.split.tokenId] : []
14356
14436
  ];
14357
- for (const tokenId of sourceIds) this.view.markInFlight(tokenId);
14437
+ this.view.markInFlightMany(sourceIds);
14358
14438
  if (spend.plan.split !== void 0) {
14359
14439
  this.queue.declareExpectedChange(transferId, coinId, spend.plan.split.remainderAmount);
14360
14440
  }
@@ -14640,6 +14720,10 @@ var WalletApiHttpError = class extends Error {
14640
14720
  if (retryAfter !== void 0) this.retryAfter = retryAfter;
14641
14721
  }
14642
14722
  };
14723
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([0, 408, 425, 429, 500, 502, 503, 504]);
14724
+ function isRetryableStatus(err) {
14725
+ return err instanceof WalletApiHttpError && RETRYABLE_STATUSES.has(err.status);
14726
+ }
14643
14727
  function isWalletApiHttpError(err, status) {
14644
14728
  return err instanceof WalletApiHttpError && (status === void 0 || err.status === status);
14645
14729
  }
@@ -15352,6 +15436,23 @@ function computeDeliveryId(tokenIdHex, stateHashHex) {
15352
15436
  return bytesToHex3(sha256(joined));
15353
15437
  }
15354
15438
  var DELIVERY_SEEN_KEY = "delivery:seen";
15439
+ var ACK_CHUNK = 50;
15440
+ function chunked(items, size) {
15441
+ const out = [];
15442
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
15443
+ return out;
15444
+ }
15445
+ var RetryableAckFailure = class extends Error {
15446
+ retryable = true;
15447
+ constructor(cause) {
15448
+ super(`mailbox ack batch could not be attempted: ${String(cause)}`);
15449
+ this.name = "RetryableAckFailure";
15450
+ this.cause = cause;
15451
+ }
15452
+ };
15453
+ function asRetryable(err) {
15454
+ return isRetryableStatus(err) ? new RetryableAckFailure(err) : err;
15455
+ }
15355
15456
  var WalletApiDeliveryPort = class {
15356
15457
  custody;
15357
15458
  client;
@@ -15517,7 +15618,53 @@ var WalletApiDeliveryPort = class {
15517
15618
  } else {
15518
15619
  await this.client.reject([deliveryId], reason);
15519
15620
  }
15520
- await this.addSeen(deliveryId);
15621
+ await this.addSeenAll([deliveryId]);
15622
+ }
15623
+ /**
15624
+ * §5.7/#757 batched settle. Claims go out grouped, rejects grouped by reason
15625
+ * (the endpoint takes one reason per call), and the seen-set is written ONCE
15626
+ * after every HTTP call has succeeded — never on a throw, so an aborted batch
15627
+ * re-lists rather than being silently forgotten.
15628
+ */
15629
+ async ackBatch(acks) {
15630
+ const settled = [];
15631
+ const outcomes = [];
15632
+ try {
15633
+ await this.settleClaims(acks, settled, outcomes);
15634
+ await this.settleRejects(acks, settled);
15635
+ } catch (err) {
15636
+ throw asRetryable(err);
15637
+ }
15638
+ await this.addSeenAll(settled);
15639
+ return [...outcomes, ...settled.map((deliveryId) => ({ deliveryId, status: "settled" }))];
15640
+ }
15641
+ async settleClaims(acks, settled, outcomes) {
15642
+ const ids = acks.filter((a) => a.disposition === "claimed").map((a) => a.deliveryId);
15643
+ for (const chunk of chunked(ids, ACK_CHUNK)) {
15644
+ const result = await this.client.claim(chunk, this.custody === "inventory");
15645
+ settled.push(...result.claimed);
15646
+ settled.push(...result.alreadyClaimed.map((e) => e.entryId));
15647
+ for (const f of result.failed) {
15648
+ if (f.code === "CONFLICT") outcomes.push({ deliveryId: f.entryId, status: "conflict" });
15649
+ else throw new MailboxClaimFailedError(f.entryId, f.code);
15650
+ }
15651
+ }
15652
+ }
15653
+ async settleRejects(acks, settled) {
15654
+ const byReason = /* @__PURE__ */ new Map();
15655
+ for (const a of acks) {
15656
+ if (a.disposition !== "rejected") continue;
15657
+ const key = a.reason ?? "";
15658
+ const group = byReason.get(key) ?? [];
15659
+ group.push(a.deliveryId);
15660
+ byReason.set(key, group);
15661
+ }
15662
+ for (const [reason, group] of byReason) {
15663
+ for (const chunk of chunked(group, ACK_CHUNK)) {
15664
+ await this.client.reject(chunk, reason === "" ? void 0 : reason);
15665
+ settled.push(...chunk);
15666
+ }
15667
+ }
15521
15668
  }
15522
15669
  onWake(cb) {
15523
15670
  if (this.wake === null) return () => void 0;
@@ -15528,10 +15675,12 @@ var WalletApiDeliveryPort = class {
15528
15675
  const raw = await this.kv.get(DELIVERY_SEEN_KEY);
15529
15676
  return new Set(raw ?? []);
15530
15677
  }
15531
- addSeen(deliveryId) {
15678
+ /** SerialChain settles-not-fulfills: a rejected write never bricks later acks. */
15679
+ addSeenAll(deliveryIds) {
15680
+ if (deliveryIds.length === 0) return Promise.resolve();
15532
15681
  return this.seenChain.enqueue(async () => {
15533
15682
  const seen = await this.readSeen();
15534
- seen.add(deliveryId);
15683
+ for (const id of deliveryIds) seen.add(id);
15535
15684
  await this.kv.set(DELIVERY_SEEN_KEY, [...seen]);
15536
15685
  });
15537
15686
  }