@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.js CHANGED
@@ -11233,6 +11233,33 @@ var SerialChain = class {
11233
11233
  return run;
11234
11234
  }
11235
11235
  };
11236
+ function coalesced(run, observe) {
11237
+ let inFlight = false;
11238
+ let queued = false;
11239
+ const start = () => {
11240
+ inFlight = true;
11241
+ let running;
11242
+ try {
11243
+ running = run();
11244
+ } catch {
11245
+ running = Promise.resolve();
11246
+ }
11247
+ observe?.(running);
11248
+ void running.catch(() => void 0).finally(() => {
11249
+ inFlight = false;
11250
+ if (!queued) return;
11251
+ queued = false;
11252
+ start();
11253
+ });
11254
+ };
11255
+ return () => {
11256
+ if (inFlight) {
11257
+ queued = true;
11258
+ return;
11259
+ }
11260
+ start();
11261
+ };
11262
+ }
11236
11263
 
11237
11264
  // modules/payments-v2/stores.ts
11238
11265
  function createScopedKV(storage, network, chainPubkey) {
@@ -12680,6 +12707,16 @@ var InventoryView = class {
12680
12707
  this.inFlight.add(tokenId);
12681
12708
  this.deps.emit("inventory:updated");
12682
12709
  }
12710
+ /** Reserve a whole plan as ONE change — per token emitted 54 events per send. */
12711
+ markInFlightMany(tokenIds) {
12712
+ let changed = false;
12713
+ for (const tokenId of tokenIds) {
12714
+ if (this.inFlight.has(tokenId)) continue;
12715
+ this.inFlight.add(tokenId);
12716
+ changed = true;
12717
+ }
12718
+ if (changed) this.deps.emit("inventory:updated");
12719
+ }
12683
12720
  release(tokenId) {
12684
12721
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
12685
12722
  }
@@ -12904,6 +12941,11 @@ var InventoryView = class {
12904
12941
  }
12905
12942
  };
12906
12943
 
12944
+ // modules/payments-v2/ports.ts
12945
+ function isRetryableAckError(err) {
12946
+ return err !== null && typeof err === "object" && err.retryable === true;
12947
+ }
12948
+
12907
12949
  // modules/payments-v2/receive/Receive.ts
12908
12950
  var ACK_BATCH_SIZE = 200;
12909
12951
  var REFRESH_INTERVAL_MS = 2500;
@@ -12960,6 +13002,17 @@ var Receive = class {
12960
13002
  await flushAcks(deps, pending, epochOf);
12961
13003
  deps.refreshView();
12962
13004
  }
13005
+ /** One listing pass: process each entry, flushing and refreshing as it goes. */
13006
+ async drainPages(ctx, basis) {
13007
+ const { deps, engine, pending, stored, pageEpoch } = ctx;
13008
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
13009
+ const storedBefore = stored.length;
13010
+ await this.processEntry(deps, engine, entry, pending, stored);
13011
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
13012
+ if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
13013
+ }
13014
+ await flushAcks(deps, pending, pageEpoch);
13015
+ }
12963
13016
  async doDrain() {
12964
13017
  const deps = this.deps;
12965
13018
  this.lastRefreshAt = deps.now?.() ?? Date.now();
@@ -12971,13 +13024,7 @@ var Receive = class {
12971
13024
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
12972
13025
  let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
12973
13026
  for (let pass = 0; pass < 2; pass++) {
12974
- for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
12975
- const storedBefore = stored.length;
12976
- await this.processEntry(deps, engine, entry, pending, stored);
12977
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
12978
- if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
12979
- }
12980
- await flushAcks(deps, pending, pageEpoch);
13027
+ await this.drainPages({ deps, engine, pending, stored, pageEpoch }, basis);
12981
13028
  const served = deps.delivery.incomingEpoch();
12982
13029
  if (basis === null || served === null || served === basis.syncEpoch) break;
12983
13030
  await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
@@ -12985,10 +13032,12 @@ var Receive = class {
12985
13032
  }
12986
13033
  } catch (err) {
12987
13034
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
13035
+ if (isRetryableAckError(err)) return stored;
12988
13036
  await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
12989
13037
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
12990
13038
  });
12991
13039
  }
13040
+ if (stored.length > 0) deps.refreshView?.();
12992
13041
  return stored;
12993
13042
  }
12994
13043
  async processEntry(deps, engine, entry, pending, stored) {
@@ -13059,14 +13108,18 @@ async function announce(deps, entry, record) {
13059
13108
  };
13060
13109
  }
13061
13110
  async function flushAcks(deps, pending, epochOf) {
13111
+ if (pending.length === 0) return;
13062
13112
  let lastAcked = null;
13063
13113
  try {
13064
- while (pending.length > 0) {
13065
- const next = pending[0];
13066
- await ackOne(deps, next);
13067
- lastAcked = next.cursor;
13068
- pending.shift();
13114
+ const settled = await settleAcks(deps, pending);
13115
+ let i = 0;
13116
+ while (i < pending.length && settled.has(pending[i].deliveryId)) {
13117
+ lastAcked = pending[i].cursor;
13118
+ i += 1;
13069
13119
  }
13120
+ const stuck = pending.filter((p) => !settled.has(p.deliveryId));
13121
+ pending.length = 0;
13122
+ pending.push(...stuck);
13070
13123
  } finally {
13071
13124
  if (lastAcked !== null) {
13072
13125
  const record = { cursor: lastAcked, syncEpoch: epochOf() };
@@ -13074,14 +13127,65 @@ async function flushAcks(deps, pending, epochOf) {
13074
13127
  }
13075
13128
  }
13076
13129
  }
13130
+ async function settleAcks(deps, pending) {
13131
+ const batch = deps.delivery.ackBatch?.bind(deps.delivery);
13132
+ if (batch === void 0) return settleOneByOne(deps, pending);
13133
+ let outcomes;
13134
+ try {
13135
+ outcomes = await batch(pending.map(toAckRequest));
13136
+ } catch (err) {
13137
+ if (isRetryableAckError(err)) throw err;
13138
+ logger.warn("PaymentsV2", "batched mailbox ack failed \u2014 settling one at a time:", err);
13139
+ return settleOneByOne(deps, pending);
13140
+ }
13141
+ const settled = new Set(outcomes.filter((o) => o.status === "settled").map((o) => o.deliveryId));
13142
+ const conflicts = outcomes.filter((o) => o.status === "conflict").map((o) => o.deliveryId);
13143
+ if (conflicts.length > 0) await resolveConflicts(deps, pending, conflicts, settled);
13144
+ return settled;
13145
+ }
13146
+ async function resolveConflicts(deps, pending, conflicts, settled) {
13147
+ for (const deliveryId of conflicts) {
13148
+ const entry = pending.find((p) => p.deliveryId === deliveryId);
13149
+ try {
13150
+ await rejectStaleClaim(deps, deliveryId, entry?.transferId);
13151
+ } catch (err) {
13152
+ logger.warn("PaymentsV2", `stale-reject failed for ${deliveryId} \u2014 cursor holds here:`, err);
13153
+ continue;
13154
+ }
13155
+ settled.add(deliveryId);
13156
+ }
13157
+ }
13158
+ async function rejectStaleClaim(deps, deliveryId, transferId) {
13159
+ logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${deliveryId} \u2014 rejected('other') as stale`);
13160
+ await deps.delivery.ack(deliveryId, "rejected", "other");
13161
+ deps.attention(transferId ?? "", ATTENTION_CLAIM_CONFLICT, deliveryId);
13162
+ }
13163
+ async function settleOneByOne(deps, pending) {
13164
+ const settled = /* @__PURE__ */ new Set();
13165
+ for (const ack of pending) {
13166
+ try {
13167
+ await ackOne(deps, ack);
13168
+ } catch (err) {
13169
+ logger.warn("PaymentsV2", `mailbox ack failed for ${ack.deliveryId} \u2014 cursor holds here:`, err);
13170
+ break;
13171
+ }
13172
+ settled.add(ack.deliveryId);
13173
+ }
13174
+ return settled;
13175
+ }
13176
+ function toAckRequest(ack) {
13177
+ return {
13178
+ deliveryId: ack.deliveryId,
13179
+ disposition: ack.disposition,
13180
+ ...ack.reason !== void 0 ? { reason: ack.reason } : {}
13181
+ };
13182
+ }
13077
13183
  async function ackOne(deps, ack) {
13078
13184
  try {
13079
13185
  await deps.delivery.ack(ack.deliveryId, ack.disposition, ack.reason);
13080
13186
  } catch (err) {
13081
13187
  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);
13188
+ await rejectStaleClaim(deps, ack.deliveryId, ack.transferId);
13085
13189
  }
13086
13190
  }
13087
13191
  function claimAck(entry) {
@@ -13835,26 +13939,6 @@ function parseAmount(raw) {
13835
13939
  function supportsDeterministicMint(engine) {
13836
13940
  return engine.deterministicMint === true;
13837
13941
  }
13838
- function makeCoalescedRefresh(refresh) {
13839
- let inFlight = false;
13840
- let queued = false;
13841
- const run = () => {
13842
- inFlight = true;
13843
- void refresh().catch(() => void 0).finally(() => {
13844
- inFlight = false;
13845
- if (!queued) return;
13846
- queued = false;
13847
- run();
13848
- });
13849
- };
13850
- return () => {
13851
- if (inFlight) {
13852
- queued = true;
13853
- return;
13854
- }
13855
- run();
13856
- };
13857
- }
13858
13942
  function composeFacadeParts(deps, hooks) {
13859
13943
  const ownPubkeyBytes = hexToBytes4(deps.ownPubkey);
13860
13944
  const ledger = new ReservationLedger();
@@ -13884,9 +13968,11 @@ function composeFacadeParts(deps, hooks) {
13884
13968
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
13885
13969
  const machineStores = createMachineStores(deps.kv);
13886
13970
  const heldStates = /* @__PURE__ */ new Map();
13971
+ const refreshView = coalesced(() => view.delta(), hooks.track);
13887
13972
  return {
13888
13973
  ownPubkeyBytes,
13889
13974
  view,
13975
+ refreshView,
13890
13976
  ledger,
13891
13977
  pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
13892
13978
  queue,
@@ -13895,7 +13981,7 @@ function composeFacadeParts(deps, hooks) {
13895
13981
  machineDeps,
13896
13982
  machine: new TransferMachine(machineDeps),
13897
13983
  heldStates,
13898
- receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, view),
13984
+ receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, refreshView),
13899
13985
  requests: buildRequests(deps, hooks),
13900
13986
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
13901
13987
  };
@@ -13962,7 +14048,7 @@ function buildMachineDeps(deps, hooks, historyStore) {
13962
14048
  }
13963
14049
  };
13964
14050
  }
13965
- function buildReceive(deps, hooks, historyStore, heldStates, view) {
14051
+ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
13966
14052
  return new Receive({
13967
14053
  delivery: deps.deliveryPort,
13968
14054
  engine: () => hooks.engine(),
@@ -13976,8 +14062,7 @@ function buildReceive(deps, hooks, historyStore, heldStates, view) {
13976
14062
  registry: deps.registry,
13977
14063
  recordReceived: (record) => recordReceived(historyStore, record),
13978
14064
  emit: (event, transfer) => deps.emit(event, transfer),
13979
- // Coalesced — see makeCoalescedRefresh.
13980
- refreshView: makeCoalescedRefresh(() => view.delta()),
14065
+ refreshView,
13981
14066
  attention: (transferId, code, detail) => {
13982
14067
  deps.emit(
13983
14068
  "transfer:attention",
@@ -14026,10 +14111,11 @@ var PaymentsFacade = class {
14026
14111
  const parts = composeFacadeParts(deps, {
14027
14112
  engine: () => this.engine(),
14028
14113
  send: (request) => this.send(request),
14029
- isActiveOp: (id) => this.activeMoneyOps.has(id)
14114
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
14115
+ track: (op) => this.trackTail(op)
14030
14116
  });
14031
14117
  this.view = parts.view;
14032
- this.wakeRefresh = makeCoalescedRefresh(() => this.view.delta());
14118
+ this.wakeRefresh = parts.refreshView;
14033
14119
  this.ledger = parts.ledger;
14034
14120
  this.queue = parts.queue;
14035
14121
  this.machine = parts.machine;
@@ -14166,7 +14252,6 @@ var PaymentsFacade = class {
14166
14252
  }
14167
14253
  async receive() {
14168
14254
  const transfers = await this.track(this.receiveLoop.drainOnce());
14169
- if (transfers.length > 0) this.trackTail(this.view.delta());
14170
14255
  return { transfers };
14171
14256
  }
14172
14257
  mint(coinId, amount) {
@@ -14362,7 +14447,7 @@ var PaymentsFacade = class {
14362
14447
  ...spend.plan.direct,
14363
14448
  ...spend.plan.split !== void 0 ? [spend.plan.split.tokenId] : []
14364
14449
  ];
14365
- for (const tokenId of sourceIds) this.view.markInFlight(tokenId);
14450
+ this.view.markInFlightMany(sourceIds);
14366
14451
  if (spend.plan.split !== void 0) {
14367
14452
  this.queue.declareExpectedChange(transferId, coinId, spend.plan.split.remainderAmount);
14368
14453
  }
@@ -14648,6 +14733,10 @@ var WalletApiHttpError = class extends Error {
14648
14733
  if (retryAfter !== void 0) this.retryAfter = retryAfter;
14649
14734
  }
14650
14735
  };
14736
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([0, 408, 425, 429, 500, 502, 503, 504]);
14737
+ function isRetryableStatus(err) {
14738
+ return err instanceof WalletApiHttpError && RETRYABLE_STATUSES.has(err.status);
14739
+ }
14651
14740
  function isWalletApiHttpError(err, status) {
14652
14741
  return err instanceof WalletApiHttpError && (status === void 0 || err.status === status);
14653
14742
  }
@@ -15360,6 +15449,23 @@ function computeDeliveryId(tokenIdHex, stateHashHex) {
15360
15449
  return bytesToHex3(sha256(joined));
15361
15450
  }
15362
15451
  var DELIVERY_SEEN_KEY = "delivery:seen";
15452
+ var ACK_CHUNK = 50;
15453
+ function chunked(items, size) {
15454
+ const out = [];
15455
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
15456
+ return out;
15457
+ }
15458
+ var RetryableAckFailure = class extends Error {
15459
+ retryable = true;
15460
+ constructor(cause) {
15461
+ super(`mailbox ack batch could not be attempted: ${String(cause)}`);
15462
+ this.name = "RetryableAckFailure";
15463
+ this.cause = cause;
15464
+ }
15465
+ };
15466
+ function asRetryable(err) {
15467
+ return isRetryableStatus(err) ? new RetryableAckFailure(err) : err;
15468
+ }
15363
15469
  var WalletApiDeliveryPort = class {
15364
15470
  custody;
15365
15471
  client;
@@ -15525,7 +15631,54 @@ var WalletApiDeliveryPort = class {
15525
15631
  } else {
15526
15632
  await this.client.reject([deliveryId], reason);
15527
15633
  }
15528
- await this.addSeen(deliveryId);
15634
+ await this.addSeenAll([deliveryId]);
15635
+ }
15636
+ /**
15637
+ * §5.7/#757 batched settle. Claims go out grouped, rejects grouped by reason
15638
+ * (the endpoint takes one reason per call), and the seen-set is written ONCE
15639
+ * after every HTTP call has succeeded — never on a throw, so an aborted batch
15640
+ * re-lists rather than being silently forgotten.
15641
+ */
15642
+ async ackBatch(acks) {
15643
+ const settled = [];
15644
+ const outcomes = [];
15645
+ try {
15646
+ await this.settleClaims(acks, settled, outcomes);
15647
+ await this.settleRejects(acks, settled);
15648
+ } catch (err) {
15649
+ await this.addSeenAll(settled).catch(() => void 0);
15650
+ throw asRetryable(err);
15651
+ }
15652
+ await this.addSeenAll(settled);
15653
+ return [...outcomes, ...settled.map((deliveryId) => ({ deliveryId, status: "settled" }))];
15654
+ }
15655
+ async settleClaims(acks, settled, outcomes) {
15656
+ const ids = acks.filter((a) => a.disposition === "claimed").map((a) => a.deliveryId);
15657
+ for (const chunk of chunked(ids, ACK_CHUNK)) {
15658
+ const result = await this.client.claim(chunk, this.custody === "inventory");
15659
+ settled.push(...result.claimed);
15660
+ settled.push(...result.alreadyClaimed.map((e) => e.entryId));
15661
+ for (const f of result.failed) {
15662
+ if (f.code === "CONFLICT") outcomes.push({ deliveryId: f.entryId, status: "conflict" });
15663
+ else throw new MailboxClaimFailedError(f.entryId, f.code);
15664
+ }
15665
+ }
15666
+ }
15667
+ async settleRejects(acks, settled) {
15668
+ const byReason = /* @__PURE__ */ new Map();
15669
+ for (const a of acks) {
15670
+ if (a.disposition !== "rejected") continue;
15671
+ const key = a.reason ?? "";
15672
+ const group = byReason.get(key) ?? [];
15673
+ group.push(a.deliveryId);
15674
+ byReason.set(key, group);
15675
+ }
15676
+ for (const [reason, group] of byReason) {
15677
+ for (const chunk of chunked(group, ACK_CHUNK)) {
15678
+ await this.client.reject(chunk, reason === "" ? void 0 : reason);
15679
+ settled.push(...chunk);
15680
+ }
15681
+ }
15529
15682
  }
15530
15683
  onWake(cb) {
15531
15684
  if (this.wake === null) return () => void 0;
@@ -15536,10 +15689,12 @@ var WalletApiDeliveryPort = class {
15536
15689
  const raw = await this.kv.get(DELIVERY_SEEN_KEY);
15537
15690
  return new Set(raw ?? []);
15538
15691
  }
15539
- addSeen(deliveryId) {
15692
+ /** SerialChain settles-not-fulfills: a rejected write never bricks later acks. */
15693
+ addSeenAll(deliveryIds) {
15694
+ if (deliveryIds.length === 0) return Promise.resolve();
15540
15695
  return this.seenChain.enqueue(async () => {
15541
15696
  const seen = await this.readSeen();
15542
- seen.add(deliveryId);
15697
+ for (const id of deliveryIds) seen.add(id);
15543
15698
  await this.kv.set(DELIVERY_SEEN_KEY, [...seen]);
15544
15699
  });
15545
15700
  }