@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.
@@ -11303,6 +11303,33 @@ var SerialChain = class {
11303
11303
  return run;
11304
11304
  }
11305
11305
  };
11306
+ function coalesced(run, observe) {
11307
+ let inFlight = false;
11308
+ let queued = false;
11309
+ const start = () => {
11310
+ inFlight = true;
11311
+ let running;
11312
+ try {
11313
+ running = run();
11314
+ } catch {
11315
+ running = Promise.resolve();
11316
+ }
11317
+ observe?.(running);
11318
+ void running.catch(() => void 0).finally(() => {
11319
+ inFlight = false;
11320
+ if (!queued) return;
11321
+ queued = false;
11322
+ start();
11323
+ });
11324
+ };
11325
+ return () => {
11326
+ if (inFlight) {
11327
+ queued = true;
11328
+ return;
11329
+ }
11330
+ start();
11331
+ };
11332
+ }
11306
11333
 
11307
11334
  // modules/payments-v2/stores.ts
11308
11335
  function createScopedKV(storage, network, chainPubkey) {
@@ -12750,6 +12777,16 @@ var InventoryView = class {
12750
12777
  this.inFlight.add(tokenId);
12751
12778
  this.deps.emit("inventory:updated");
12752
12779
  }
12780
+ /** Reserve a whole plan as ONE change — per token emitted 54 events per send. */
12781
+ markInFlightMany(tokenIds) {
12782
+ let changed = false;
12783
+ for (const tokenId of tokenIds) {
12784
+ if (this.inFlight.has(tokenId)) continue;
12785
+ this.inFlight.add(tokenId);
12786
+ changed = true;
12787
+ }
12788
+ if (changed) this.deps.emit("inventory:updated");
12789
+ }
12753
12790
  release(tokenId) {
12754
12791
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12755
12792
  }
@@ -12974,6 +13011,11 @@ var InventoryView = class {
12974
13011
  }
12975
13012
  };
12976
13013
 
13014
+ // modules/payments-v2/ports.ts
13015
+ function isRetryableAckError(err) {
13016
+ return err !== null && typeof err === "object" && err.retryable === true;
13017
+ }
13018
+
12977
13019
  // modules/payments-v2/receive/Receive.ts
12978
13020
  var ACK_BATCH_SIZE = 200;
12979
13021
  var REFRESH_INTERVAL_MS = 2500;
@@ -13030,6 +13072,17 @@ var Receive = class {
13030
13072
  await flushAcks(deps, pending, epochOf);
13031
13073
  deps.refreshView();
13032
13074
  }
13075
+ /** One listing pass: process each entry, flushing and refreshing as it goes. */
13076
+ async drainPages(ctx, basis) {
13077
+ const { deps, engine, pending, stored, pageEpoch } = ctx;
13078
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
13079
+ const storedBefore = stored.length;
13080
+ await this.processEntry(deps, engine, entry, pending, stored);
13081
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
13082
+ if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
13083
+ }
13084
+ await flushAcks(deps, pending, pageEpoch);
13085
+ }
13033
13086
  async doDrain() {
13034
13087
  const deps = this.deps;
13035
13088
  this.lastRefreshAt = deps.now?.() ?? Date.now();
@@ -13041,13 +13094,7 @@ var Receive = class {
13041
13094
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
13042
13095
  let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
13043
13096
  for (let pass = 0; pass < 2; pass++) {
13044
- for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
13045
- const storedBefore = stored.length;
13046
- await this.processEntry(deps, engine, entry, pending, stored);
13047
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
13048
- if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
13049
- }
13050
- await flushAcks(deps, pending, pageEpoch);
13097
+ await this.drainPages({ deps, engine, pending, stored, pageEpoch }, basis);
13051
13098
  const served = deps.delivery.incomingEpoch();
13052
13099
  if (basis === null || served === null || served === basis.syncEpoch) break;
13053
13100
  await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
@@ -13055,10 +13102,12 @@ var Receive = class {
13055
13102
  }
13056
13103
  } catch (err) {
13057
13104
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
13105
+ if (isRetryableAckError(err)) return stored;
13058
13106
  await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
13059
13107
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
13060
13108
  });
13061
13109
  }
13110
+ if (stored.length > 0) deps.refreshView?.();
13062
13111
  return stored;
13063
13112
  }
13064
13113
  async processEntry(deps, engine, entry, pending, stored) {
@@ -13129,14 +13178,18 @@ async function announce(deps, entry, record) {
13129
13178
  };
13130
13179
  }
13131
13180
  async function flushAcks(deps, pending, epochOf) {
13181
+ if (pending.length === 0) return;
13132
13182
  let lastAcked = null;
13133
13183
  try {
13134
- while (pending.length > 0) {
13135
- const next = pending[0];
13136
- await ackOne(deps, next);
13137
- lastAcked = next.cursor;
13138
- pending.shift();
13184
+ const settled = await settleAcks(deps, pending);
13185
+ let i = 0;
13186
+ while (i < pending.length && settled.has(pending[i].deliveryId)) {
13187
+ lastAcked = pending[i].cursor;
13188
+ i += 1;
13139
13189
  }
13190
+ const stuck = pending.filter((p) => !settled.has(p.deliveryId));
13191
+ pending.length = 0;
13192
+ pending.push(...stuck);
13140
13193
  } finally {
13141
13194
  if (lastAcked !== null) {
13142
13195
  const record = { cursor: lastAcked, syncEpoch: epochOf() };
@@ -13144,14 +13197,65 @@ async function flushAcks(deps, pending, epochOf) {
13144
13197
  }
13145
13198
  }
13146
13199
  }
13200
+ async function settleAcks(deps, pending) {
13201
+ const batch = deps.delivery.ackBatch?.bind(deps.delivery);
13202
+ if (batch === void 0) return settleOneByOne(deps, pending);
13203
+ let outcomes;
13204
+ try {
13205
+ outcomes = await batch(pending.map(toAckRequest));
13206
+ } catch (err) {
13207
+ if (isRetryableAckError(err)) throw err;
13208
+ logger.warn("PaymentsV2", "batched mailbox ack failed \u2014 settling one at a time:", err);
13209
+ return settleOneByOne(deps, pending);
13210
+ }
13211
+ const settled = new Set(outcomes.filter((o) => o.status === "settled").map((o) => o.deliveryId));
13212
+ const conflicts = outcomes.filter((o) => o.status === "conflict").map((o) => o.deliveryId);
13213
+ if (conflicts.length > 0) await resolveConflicts(deps, pending, conflicts, settled);
13214
+ return settled;
13215
+ }
13216
+ async function resolveConflicts(deps, pending, conflicts, settled) {
13217
+ for (const deliveryId of conflicts) {
13218
+ const entry = pending.find((p) => p.deliveryId === deliveryId);
13219
+ try {
13220
+ await rejectStaleClaim(deps, deliveryId, entry?.transferId);
13221
+ } catch (err) {
13222
+ logger.warn("PaymentsV2", `stale-reject failed for ${deliveryId} \u2014 cursor holds here:`, err);
13223
+ continue;
13224
+ }
13225
+ settled.add(deliveryId);
13226
+ }
13227
+ }
13228
+ async function rejectStaleClaim(deps, deliveryId, transferId) {
13229
+ logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${deliveryId} \u2014 rejected('other') as stale`);
13230
+ await deps.delivery.ack(deliveryId, "rejected", "other");
13231
+ deps.attention(transferId ?? "", ATTENTION_CLAIM_CONFLICT, deliveryId);
13232
+ }
13233
+ async function settleOneByOne(deps, pending) {
13234
+ const settled = /* @__PURE__ */ new Set();
13235
+ for (const ack of pending) {
13236
+ try {
13237
+ await ackOne(deps, ack);
13238
+ } catch (err) {
13239
+ logger.warn("PaymentsV2", `mailbox ack failed for ${ack.deliveryId} \u2014 cursor holds here:`, err);
13240
+ break;
13241
+ }
13242
+ settled.add(ack.deliveryId);
13243
+ }
13244
+ return settled;
13245
+ }
13246
+ function toAckRequest(ack) {
13247
+ return {
13248
+ deliveryId: ack.deliveryId,
13249
+ disposition: ack.disposition,
13250
+ ...ack.reason !== void 0 ? { reason: ack.reason } : {}
13251
+ };
13252
+ }
13147
13253
  async function ackOne(deps, ack) {
13148
13254
  try {
13149
13255
  await deps.delivery.ack(ack.deliveryId, ack.disposition, ack.reason);
13150
13256
  } catch (err) {
13151
13257
  if (ack.disposition !== "claimed" || !isClaimConflict(err)) throw err;
13152
- logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${ack.deliveryId} \u2014 rejected('other') as stale`);
13153
- await deps.delivery.ack(ack.deliveryId, "rejected", "other");
13154
- deps.attention(ack.transferId ?? "", ATTENTION_CLAIM_CONFLICT, ack.deliveryId);
13258
+ await rejectStaleClaim(deps, ack.deliveryId, ack.transferId);
13155
13259
  }
13156
13260
  }
13157
13261
  function claimAck(entry) {
@@ -13905,26 +14009,6 @@ function parseAmount(raw) {
13905
14009
  function supportsDeterministicMint(engine) {
13906
14010
  return engine.deterministicMint === true;
13907
14011
  }
13908
- function makeCoalescedRefresh(refresh) {
13909
- let inFlight = false;
13910
- let queued = false;
13911
- const run = () => {
13912
- inFlight = true;
13913
- void refresh().catch(() => void 0).finally(() => {
13914
- inFlight = false;
13915
- if (!queued) return;
13916
- queued = false;
13917
- run();
13918
- });
13919
- };
13920
- return () => {
13921
- if (inFlight) {
13922
- queued = true;
13923
- return;
13924
- }
13925
- run();
13926
- };
13927
- }
13928
14012
  function composeFacadeParts(deps, hooks) {
13929
14013
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13930
14014
  const ledger = new ReservationLedger();
@@ -13954,9 +14038,11 @@ function composeFacadeParts(deps, hooks) {
13954
14038
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13955
14039
  const machineStores = createMachineStores(deps.kv);
13956
14040
  const heldStates = /* @__PURE__ */ new Map();
14041
+ const refreshView = coalesced(() => view.delta(), hooks.track);
13957
14042
  return {
13958
14043
  ownPubkeyBytes,
13959
14044
  view,
14045
+ refreshView,
13960
14046
  ledger,
13961
14047
  pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13962
14048
  queue,
@@ -13965,7 +14051,7 @@ function composeFacadeParts(deps, hooks) {
13965
14051
  machineDeps,
13966
14052
  machine: new TransferMachine(machineDeps),
13967
14053
  heldStates,
13968
- receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, view),
14054
+ receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, refreshView),
13969
14055
  requests: buildRequests(deps, hooks),
13970
14056
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13971
14057
  };
@@ -14032,7 +14118,7 @@ function buildMachineDeps(deps, hooks, historyStore) {
14032
14118
  }
14033
14119
  };
14034
14120
  }
14035
- function buildReceive(deps, hooks, historyStore, heldStates, view) {
14121
+ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
14036
14122
  return new Receive({
14037
14123
  delivery: deps.deliveryPort,
14038
14124
  engine: () => hooks.engine(),
@@ -14046,8 +14132,7 @@ function buildReceive(deps, hooks, historyStore, heldStates, view) {
14046
14132
  registry: deps.registry,
14047
14133
  recordReceived: (record) => recordReceived(historyStore, record),
14048
14134
  emit: (event, transfer) => deps.emit(event, transfer),
14049
- // Coalesced — see makeCoalescedRefresh.
14050
- refreshView: makeCoalescedRefresh(() => view.delta()),
14135
+ refreshView,
14051
14136
  attention: (transferId, code, detail) => {
14052
14137
  deps.emit(
14053
14138
  "transfer:attention",
@@ -14096,10 +14181,11 @@ var PaymentsFacade = class {
14096
14181
  const parts = composeFacadeParts(deps, {
14097
14182
  engine: () => this.engine(),
14098
14183
  send: (request) => this.send(request),
14099
- isActiveOp: (id) => this.activeMoneyOps.has(id)
14184
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
14185
+ track: (op) => this.trackTail(op)
14100
14186
  });
14101
14187
  this.view = parts.view;
14102
- this.wakeRefresh = makeCoalescedRefresh(() => this.view.delta());
14188
+ this.wakeRefresh = parts.refreshView;
14103
14189
  this.ledger = parts.ledger;
14104
14190
  this.queue = parts.queue;
14105
14191
  this.machine = parts.machine;
@@ -14236,7 +14322,6 @@ var PaymentsFacade = class {
14236
14322
  }
14237
14323
  async receive() {
14238
14324
  const transfers = await this.track(this.receiveLoop.drainOnce());
14239
- if (transfers.length > 0) this.trackTail(this.view.delta());
14240
14325
  return { transfers };
14241
14326
  }
14242
14327
  mint(coinId, amount) {
@@ -14432,7 +14517,7 @@ var PaymentsFacade = class {
14432
14517
  ...spend.plan.direct,
14433
14518
  ...spend.plan.split !== void 0 ? [spend.plan.split.tokenId] : []
14434
14519
  ];
14435
- for (const tokenId of sourceIds) this.view.markInFlight(tokenId);
14520
+ this.view.markInFlightMany(sourceIds);
14436
14521
  if (spend.plan.split !== void 0) {
14437
14522
  this.queue.declareExpectedChange(transferId, coinId, spend.plan.split.remainderAmount);
14438
14523
  }
@@ -14718,6 +14803,10 @@ var WalletApiHttpError = class extends Error {
14718
14803
  if (retryAfter !== void 0) this.retryAfter = retryAfter;
14719
14804
  }
14720
14805
  };
14806
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([0, 408, 425, 429, 500, 502, 503, 504]);
14807
+ function isRetryableStatus(err) {
14808
+ return err instanceof WalletApiHttpError && RETRYABLE_STATUSES.has(err.status);
14809
+ }
14721
14810
  function isWalletApiHttpError(err, status) {
14722
14811
  return err instanceof WalletApiHttpError && (status === void 0 || err.status === status);
14723
14812
  }
@@ -15430,6 +15519,23 @@ function computeDeliveryId(tokenIdHex, stateHashHex) {
15430
15519
  return bytesToHex3(sha256(joined));
15431
15520
  }
15432
15521
  var DELIVERY_SEEN_KEY = "delivery:seen";
15522
+ var ACK_CHUNK = 50;
15523
+ function chunked(items, size) {
15524
+ const out = [];
15525
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
15526
+ return out;
15527
+ }
15528
+ var RetryableAckFailure = class extends Error {
15529
+ retryable = true;
15530
+ constructor(cause) {
15531
+ super(`mailbox ack batch could not be attempted: ${String(cause)}`);
15532
+ this.name = "RetryableAckFailure";
15533
+ this.cause = cause;
15534
+ }
15535
+ };
15536
+ function asRetryable(err) {
15537
+ return isRetryableStatus(err) ? new RetryableAckFailure(err) : err;
15538
+ }
15433
15539
  var WalletApiDeliveryPort = class {
15434
15540
  custody;
15435
15541
  client;
@@ -15595,7 +15701,54 @@ var WalletApiDeliveryPort = class {
15595
15701
  } else {
15596
15702
  await this.client.reject([deliveryId], reason);
15597
15703
  }
15598
- await this.addSeen(deliveryId);
15704
+ await this.addSeenAll([deliveryId]);
15705
+ }
15706
+ /**
15707
+ * §5.7/#757 batched settle. Claims go out grouped, rejects grouped by reason
15708
+ * (the endpoint takes one reason per call), and the seen-set is written ONCE
15709
+ * after every HTTP call has succeeded — never on a throw, so an aborted batch
15710
+ * re-lists rather than being silently forgotten.
15711
+ */
15712
+ async ackBatch(acks) {
15713
+ const settled = [];
15714
+ const outcomes = [];
15715
+ try {
15716
+ await this.settleClaims(acks, settled, outcomes);
15717
+ await this.settleRejects(acks, settled);
15718
+ } catch (err) {
15719
+ await this.addSeenAll(settled).catch(() => void 0);
15720
+ throw asRetryable(err);
15721
+ }
15722
+ await this.addSeenAll(settled);
15723
+ return [...outcomes, ...settled.map((deliveryId) => ({ deliveryId, status: "settled" }))];
15724
+ }
15725
+ async settleClaims(acks, settled, outcomes) {
15726
+ const ids = acks.filter((a) => a.disposition === "claimed").map((a) => a.deliveryId);
15727
+ for (const chunk of chunked(ids, ACK_CHUNK)) {
15728
+ const result = await this.client.claim(chunk, this.custody === "inventory");
15729
+ settled.push(...result.claimed);
15730
+ settled.push(...result.alreadyClaimed.map((e) => e.entryId));
15731
+ for (const f of result.failed) {
15732
+ if (f.code === "CONFLICT") outcomes.push({ deliveryId: f.entryId, status: "conflict" });
15733
+ else throw new MailboxClaimFailedError(f.entryId, f.code);
15734
+ }
15735
+ }
15736
+ }
15737
+ async settleRejects(acks, settled) {
15738
+ const byReason = /* @__PURE__ */ new Map();
15739
+ for (const a of acks) {
15740
+ if (a.disposition !== "rejected") continue;
15741
+ const key = a.reason ?? "";
15742
+ const group = byReason.get(key) ?? [];
15743
+ group.push(a.deliveryId);
15744
+ byReason.set(key, group);
15745
+ }
15746
+ for (const [reason, group] of byReason) {
15747
+ for (const chunk of chunked(group, ACK_CHUNK)) {
15748
+ await this.client.reject(chunk, reason === "" ? void 0 : reason);
15749
+ settled.push(...chunk);
15750
+ }
15751
+ }
15599
15752
  }
15600
15753
  onWake(cb) {
15601
15754
  if (this.wake === null) return () => void 0;
@@ -15606,10 +15759,12 @@ var WalletApiDeliveryPort = class {
15606
15759
  const raw = await this.kv.get(DELIVERY_SEEN_KEY);
15607
15760
  return new Set(raw ?? []);
15608
15761
  }
15609
- addSeen(deliveryId) {
15762
+ /** SerialChain settles-not-fulfills: a rejected write never bricks later acks. */
15763
+ addSeenAll(deliveryIds) {
15764
+ if (deliveryIds.length === 0) return Promise.resolve();
15610
15765
  return this.seenChain.enqueue(async () => {
15611
15766
  const seen = await this.readSeen();
15612
- seen.add(deliveryId);
15767
+ for (const id of deliveryIds) seen.add(id);
15613
15768
  await this.kv.set(DELIVERY_SEEN_KEY, [...seen]);
15614
15769
  });
15615
15770
  }