@unicitylabs/sphere-sdk 0.14.11-dev.6 → 0.14.11-dev.8

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.cjs CHANGED
@@ -11361,6 +11361,33 @@ var SerialChain = class {
11361
11361
  return run;
11362
11362
  }
11363
11363
  };
11364
+ function coalesced(run, observe) {
11365
+ let inFlight = false;
11366
+ let queued = false;
11367
+ const start = () => {
11368
+ inFlight = true;
11369
+ let running;
11370
+ try {
11371
+ running = run();
11372
+ } catch {
11373
+ running = Promise.resolve();
11374
+ }
11375
+ observe?.(running);
11376
+ void running.catch(() => void 0).finally(() => {
11377
+ inFlight = false;
11378
+ if (!queued) return;
11379
+ queued = false;
11380
+ start();
11381
+ });
11382
+ };
11383
+ return () => {
11384
+ if (inFlight) {
11385
+ queued = true;
11386
+ return;
11387
+ }
11388
+ start();
11389
+ };
11390
+ }
11364
11391
 
11365
11392
  // modules/payments-v2/stores.ts
11366
11393
  function createScopedKV(storage, network, chainPubkey) {
@@ -12808,6 +12835,16 @@ var InventoryView = class {
12808
12835
  this.inFlight.add(tokenId);
12809
12836
  this.deps.emit("inventory:updated");
12810
12837
  }
12838
+ /** Reserve a whole plan as ONE change — per token emitted 54 events per send. */
12839
+ markInFlightMany(tokenIds) {
12840
+ let changed = false;
12841
+ for (const tokenId of tokenIds) {
12842
+ if (this.inFlight.has(tokenId)) continue;
12843
+ this.inFlight.add(tokenId);
12844
+ changed = true;
12845
+ }
12846
+ if (changed) this.deps.emit("inventory:updated");
12847
+ }
12811
12848
  release(tokenId) {
12812
12849
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12813
12850
  }
@@ -13032,6 +13069,11 @@ var InventoryView = class {
13032
13069
  }
13033
13070
  };
13034
13071
 
13072
+ // modules/payments-v2/ports.ts
13073
+ function isRetryableAckError(err) {
13074
+ return err !== null && typeof err === "object" && err.retryable === true;
13075
+ }
13076
+
13035
13077
  // modules/payments-v2/receive/Receive.ts
13036
13078
  var ACK_BATCH_SIZE = 200;
13037
13079
  var REFRESH_INTERVAL_MS = 2500;
@@ -13088,6 +13130,17 @@ var Receive = class {
13088
13130
  await flushAcks(deps, pending, epochOf);
13089
13131
  deps.refreshView();
13090
13132
  }
13133
+ /** One listing pass: process each entry, flushing and refreshing as it goes. */
13134
+ async drainPages(ctx, basis) {
13135
+ const { deps, engine, pending, stored, pageEpoch } = ctx;
13136
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
13137
+ const storedBefore = stored.length;
13138
+ await this.processEntry(deps, engine, entry, pending, stored);
13139
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
13140
+ if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
13141
+ }
13142
+ await flushAcks(deps, pending, pageEpoch);
13143
+ }
13091
13144
  async doDrain() {
13092
13145
  const deps = this.deps;
13093
13146
  this.lastRefreshAt = deps.now?.() ?? Date.now();
@@ -13099,13 +13152,7 @@ var Receive = class {
13099
13152
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
13100
13153
  let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
13101
13154
  for (let pass = 0; pass < 2; pass++) {
13102
- for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
13103
- const storedBefore = stored.length;
13104
- await this.processEntry(deps, engine, entry, pending, stored);
13105
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
13106
- if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
13107
- }
13108
- await flushAcks(deps, pending, pageEpoch);
13155
+ await this.drainPages({ deps, engine, pending, stored, pageEpoch }, basis);
13109
13156
  const served = deps.delivery.incomingEpoch();
13110
13157
  if (basis === null || served === null || served === basis.syncEpoch) break;
13111
13158
  await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
@@ -13113,10 +13160,12 @@ var Receive = class {
13113
13160
  }
13114
13161
  } catch (err) {
13115
13162
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
13163
+ if (isRetryableAckError(err)) return stored;
13116
13164
  await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
13117
13165
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
13118
13166
  });
13119
13167
  }
13168
+ if (stored.length > 0) deps.refreshView?.();
13120
13169
  return stored;
13121
13170
  }
13122
13171
  async processEntry(deps, engine, entry, pending, stored) {
@@ -13187,14 +13236,18 @@ async function announce(deps, entry, record) {
13187
13236
  };
13188
13237
  }
13189
13238
  async function flushAcks(deps, pending, epochOf) {
13239
+ if (pending.length === 0) return;
13190
13240
  let lastAcked = null;
13191
13241
  try {
13192
- while (pending.length > 0) {
13193
- const next = pending[0];
13194
- await ackOne(deps, next);
13195
- lastAcked = next.cursor;
13196
- pending.shift();
13242
+ const settled = await settleAcks(deps, pending);
13243
+ let i = 0;
13244
+ while (i < pending.length && settled.has(pending[i].deliveryId)) {
13245
+ lastAcked = pending[i].cursor;
13246
+ i += 1;
13197
13247
  }
13248
+ const stuck = pending.filter((p) => !settled.has(p.deliveryId));
13249
+ pending.length = 0;
13250
+ pending.push(...stuck);
13198
13251
  } finally {
13199
13252
  if (lastAcked !== null) {
13200
13253
  const record = { cursor: lastAcked, syncEpoch: epochOf() };
@@ -13202,14 +13255,65 @@ async function flushAcks(deps, pending, epochOf) {
13202
13255
  }
13203
13256
  }
13204
13257
  }
13258
+ async function settleAcks(deps, pending) {
13259
+ const batch = deps.delivery.ackBatch?.bind(deps.delivery);
13260
+ if (batch === void 0) return settleOneByOne(deps, pending);
13261
+ let outcomes;
13262
+ try {
13263
+ outcomes = await batch(pending.map(toAckRequest));
13264
+ } catch (err) {
13265
+ if (isRetryableAckError(err)) throw err;
13266
+ logger.warn("PaymentsV2", "batched mailbox ack failed \u2014 settling one at a time:", err);
13267
+ return settleOneByOne(deps, pending);
13268
+ }
13269
+ const settled = new Set(outcomes.filter((o) => o.status === "settled").map((o) => o.deliveryId));
13270
+ const conflicts = outcomes.filter((o) => o.status === "conflict").map((o) => o.deliveryId);
13271
+ if (conflicts.length > 0) await resolveConflicts(deps, pending, conflicts, settled);
13272
+ return settled;
13273
+ }
13274
+ async function resolveConflicts(deps, pending, conflicts, settled) {
13275
+ for (const deliveryId of conflicts) {
13276
+ const entry = pending.find((p) => p.deliveryId === deliveryId);
13277
+ try {
13278
+ await rejectStaleClaim(deps, deliveryId, entry?.transferId);
13279
+ } catch (err) {
13280
+ logger.warn("PaymentsV2", `stale-reject failed for ${deliveryId} \u2014 cursor holds here:`, err);
13281
+ continue;
13282
+ }
13283
+ settled.add(deliveryId);
13284
+ }
13285
+ }
13286
+ async function rejectStaleClaim(deps, deliveryId, transferId) {
13287
+ logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${deliveryId} \u2014 rejected('other') as stale`);
13288
+ await deps.delivery.ack(deliveryId, "rejected", "other");
13289
+ deps.attention(transferId ?? "", ATTENTION_CLAIM_CONFLICT, deliveryId);
13290
+ }
13291
+ async function settleOneByOne(deps, pending) {
13292
+ const settled = /* @__PURE__ */ new Set();
13293
+ for (const ack of pending) {
13294
+ try {
13295
+ await ackOne(deps, ack);
13296
+ } catch (err) {
13297
+ logger.warn("PaymentsV2", `mailbox ack failed for ${ack.deliveryId} \u2014 cursor holds here:`, err);
13298
+ break;
13299
+ }
13300
+ settled.add(ack.deliveryId);
13301
+ }
13302
+ return settled;
13303
+ }
13304
+ function toAckRequest(ack) {
13305
+ return {
13306
+ deliveryId: ack.deliveryId,
13307
+ disposition: ack.disposition,
13308
+ ...ack.reason !== void 0 ? { reason: ack.reason } : {}
13309
+ };
13310
+ }
13205
13311
  async function ackOne(deps, ack) {
13206
13312
  try {
13207
13313
  await deps.delivery.ack(ack.deliveryId, ack.disposition, ack.reason);
13208
13314
  } catch (err) {
13209
13315
  if (ack.disposition !== "claimed" || !isClaimConflict(err)) throw err;
13210
- logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${ack.deliveryId} \u2014 rejected('other') as stale`);
13211
- await deps.delivery.ack(ack.deliveryId, "rejected", "other");
13212
- deps.attention(ack.transferId ?? "", ATTENTION_CLAIM_CONFLICT, ack.deliveryId);
13316
+ await rejectStaleClaim(deps, ack.deliveryId, ack.transferId);
13213
13317
  }
13214
13318
  }
13215
13319
  function claimAck(entry) {
@@ -13963,26 +14067,6 @@ function parseAmount(raw) {
13963
14067
  function supportsDeterministicMint(engine) {
13964
14068
  return engine.deterministicMint === true;
13965
14069
  }
13966
- function makeCoalescedRefresh(refresh) {
13967
- let inFlight = false;
13968
- let queued = false;
13969
- const run = () => {
13970
- inFlight = true;
13971
- void refresh().catch(() => void 0).finally(() => {
13972
- inFlight = false;
13973
- if (!queued) return;
13974
- queued = false;
13975
- run();
13976
- });
13977
- };
13978
- return () => {
13979
- if (inFlight) {
13980
- queued = true;
13981
- return;
13982
- }
13983
- run();
13984
- };
13985
- }
13986
14070
  function composeFacadeParts(deps, hooks) {
13987
14071
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13988
14072
  const ledger = new ReservationLedger();
@@ -14012,9 +14096,11 @@ function composeFacadeParts(deps, hooks) {
14012
14096
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
14013
14097
  const machineStores = createMachineStores(deps.kv);
14014
14098
  const heldStates = /* @__PURE__ */ new Map();
14099
+ const refreshView = coalesced(() => view.delta(), hooks.track);
14015
14100
  return {
14016
14101
  ownPubkeyBytes,
14017
14102
  view,
14103
+ refreshView,
14018
14104
  ledger,
14019
14105
  pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
14020
14106
  queue,
@@ -14023,7 +14109,7 @@ function composeFacadeParts(deps, hooks) {
14023
14109
  machineDeps,
14024
14110
  machine: new TransferMachine(machineDeps),
14025
14111
  heldStates,
14026
- receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, view),
14112
+ receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, refreshView),
14027
14113
  requests: buildRequests(deps, hooks),
14028
14114
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
14029
14115
  };
@@ -14090,7 +14176,7 @@ function buildMachineDeps(deps, hooks, historyStore) {
14090
14176
  }
14091
14177
  };
14092
14178
  }
14093
- function buildReceive(deps, hooks, historyStore, heldStates, view) {
14179
+ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
14094
14180
  return new Receive({
14095
14181
  delivery: deps.deliveryPort,
14096
14182
  engine: () => hooks.engine(),
@@ -14104,8 +14190,7 @@ function buildReceive(deps, hooks, historyStore, heldStates, view) {
14104
14190
  registry: deps.registry,
14105
14191
  recordReceived: (record) => recordReceived(historyStore, record),
14106
14192
  emit: (event, transfer) => deps.emit(event, transfer),
14107
- // Coalesced — see makeCoalescedRefresh.
14108
- refreshView: makeCoalescedRefresh(() => view.delta()),
14193
+ refreshView,
14109
14194
  attention: (transferId, code, detail) => {
14110
14195
  deps.emit(
14111
14196
  "transfer:attention",
@@ -14154,10 +14239,11 @@ var PaymentsFacade = class {
14154
14239
  const parts = composeFacadeParts(deps, {
14155
14240
  engine: () => this.engine(),
14156
14241
  send: (request) => this.send(request),
14157
- isActiveOp: (id) => this.activeMoneyOps.has(id)
14242
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
14243
+ track: (op) => this.trackTail(op)
14158
14244
  });
14159
14245
  this.view = parts.view;
14160
- this.wakeRefresh = makeCoalescedRefresh(() => this.view.delta());
14246
+ this.wakeRefresh = parts.refreshView;
14161
14247
  this.ledger = parts.ledger;
14162
14248
  this.queue = parts.queue;
14163
14249
  this.machine = parts.machine;
@@ -14294,7 +14380,6 @@ var PaymentsFacade = class {
14294
14380
  }
14295
14381
  async receive() {
14296
14382
  const transfers = await this.track(this.receiveLoop.drainOnce());
14297
- if (transfers.length > 0) this.trackTail(this.view.delta());
14298
14383
  return { transfers };
14299
14384
  }
14300
14385
  mint(coinId, amount) {
@@ -14490,7 +14575,7 @@ var PaymentsFacade = class {
14490
14575
  ...spend.plan.direct,
14491
14576
  ...spend.plan.split !== void 0 ? [spend.plan.split.tokenId] : []
14492
14577
  ];
14493
- for (const tokenId of sourceIds) this.view.markInFlight(tokenId);
14578
+ this.view.markInFlightMany(sourceIds);
14494
14579
  if (spend.plan.split !== void 0) {
14495
14580
  this.queue.declareExpectedChange(transferId, coinId, spend.plan.split.remainderAmount);
14496
14581
  }
@@ -14776,6 +14861,10 @@ var WalletApiHttpError = class extends Error {
14776
14861
  if (retryAfter !== void 0) this.retryAfter = retryAfter;
14777
14862
  }
14778
14863
  };
14864
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([0, 408, 425, 429, 500, 502, 503, 504]);
14865
+ function isRetryableStatus(err) {
14866
+ return err instanceof WalletApiHttpError && RETRYABLE_STATUSES.has(err.status);
14867
+ }
14779
14868
  function isWalletApiHttpError(err, status) {
14780
14869
  return err instanceof WalletApiHttpError && (status === void 0 || err.status === status);
14781
14870
  }
@@ -15488,6 +15577,23 @@ function computeDeliveryId(tokenIdHex, stateHashHex) {
15488
15577
  return bytesToHex3(sha256(joined));
15489
15578
  }
15490
15579
  var DELIVERY_SEEN_KEY = "delivery:seen";
15580
+ var ACK_CHUNK = 50;
15581
+ function chunked(items, size) {
15582
+ const out = [];
15583
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
15584
+ return out;
15585
+ }
15586
+ var RetryableAckFailure = class extends Error {
15587
+ retryable = true;
15588
+ constructor(cause) {
15589
+ super(`mailbox ack batch could not be attempted: ${String(cause)}`);
15590
+ this.name = "RetryableAckFailure";
15591
+ this.cause = cause;
15592
+ }
15593
+ };
15594
+ function asRetryable(err) {
15595
+ return isRetryableStatus(err) ? new RetryableAckFailure(err) : err;
15596
+ }
15491
15597
  var WalletApiDeliveryPort = class {
15492
15598
  custody;
15493
15599
  client;
@@ -15653,7 +15759,54 @@ var WalletApiDeliveryPort = class {
15653
15759
  } else {
15654
15760
  await this.client.reject([deliveryId], reason);
15655
15761
  }
15656
- await this.addSeen(deliveryId);
15762
+ await this.addSeenAll([deliveryId]);
15763
+ }
15764
+ /**
15765
+ * §5.7/#757 batched settle. Claims go out grouped, rejects grouped by reason
15766
+ * (the endpoint takes one reason per call), and the seen-set is written ONCE
15767
+ * after every HTTP call has succeeded — never on a throw, so an aborted batch
15768
+ * re-lists rather than being silently forgotten.
15769
+ */
15770
+ async ackBatch(acks) {
15771
+ const settled = [];
15772
+ const outcomes = [];
15773
+ try {
15774
+ await this.settleClaims(acks, settled, outcomes);
15775
+ await this.settleRejects(acks, settled);
15776
+ } catch (err) {
15777
+ await this.addSeenAll(settled).catch(() => void 0);
15778
+ throw asRetryable(err);
15779
+ }
15780
+ await this.addSeenAll(settled);
15781
+ return [...outcomes, ...settled.map((deliveryId) => ({ deliveryId, status: "settled" }))];
15782
+ }
15783
+ async settleClaims(acks, settled, outcomes) {
15784
+ const ids = acks.filter((a) => a.disposition === "claimed").map((a) => a.deliveryId);
15785
+ for (const chunk of chunked(ids, ACK_CHUNK)) {
15786
+ const result = await this.client.claim(chunk, this.custody === "inventory");
15787
+ settled.push(...result.claimed);
15788
+ settled.push(...result.alreadyClaimed.map((e) => e.entryId));
15789
+ for (const f of result.failed) {
15790
+ if (f.code === "CONFLICT") outcomes.push({ deliveryId: f.entryId, status: "conflict" });
15791
+ else throw new MailboxClaimFailedError(f.entryId, f.code);
15792
+ }
15793
+ }
15794
+ }
15795
+ async settleRejects(acks, settled) {
15796
+ const byReason = /* @__PURE__ */ new Map();
15797
+ for (const a of acks) {
15798
+ if (a.disposition !== "rejected") continue;
15799
+ const key = a.reason ?? "";
15800
+ const group = byReason.get(key) ?? [];
15801
+ group.push(a.deliveryId);
15802
+ byReason.set(key, group);
15803
+ }
15804
+ for (const [reason, group] of byReason) {
15805
+ for (const chunk of chunked(group, ACK_CHUNK)) {
15806
+ await this.client.reject(chunk, reason === "" ? void 0 : reason);
15807
+ settled.push(...chunk);
15808
+ }
15809
+ }
15657
15810
  }
15658
15811
  onWake(cb) {
15659
15812
  if (this.wake === null) return () => void 0;
@@ -15664,10 +15817,12 @@ var WalletApiDeliveryPort = class {
15664
15817
  const raw = await this.kv.get(DELIVERY_SEEN_KEY);
15665
15818
  return new Set(raw ?? []);
15666
15819
  }
15667
- addSeen(deliveryId) {
15820
+ /** SerialChain settles-not-fulfills: a rejected write never bricks later acks. */
15821
+ addSeenAll(deliveryIds) {
15822
+ if (deliveryIds.length === 0) return Promise.resolve();
15668
15823
  return this.seenChain.enqueue(async () => {
15669
15824
  const seen = await this.readSeen();
15670
- seen.add(deliveryId);
15825
+ for (const id of deliveryIds) seen.add(id);
15671
15826
  await this.kv.set(DELIVERY_SEEN_KEY, [...seen]);
15672
15827
  });
15673
15828
  }