@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.
@@ -340,8 +340,22 @@ interface DeliveryPort {
340
340
  */
341
341
  incomingEpoch(): string | null;
342
342
  ack(deliveryId: string, disposition: 'claimed' | 'rejected', reason?: 'invalid' | 'not-owned' | 'storage-rejected' | 'other'): Promise<void>;
343
+ /** Settle many acks at once (#757). Contract pinned by the delivery-port contract suite. */
344
+ ackBatch?(acks: readonly AckRequest[]): Promise<readonly AckOutcome[]>;
343
345
  onWake?(cb: () => void): () => void;
344
346
  }
347
+ interface AckRequest {
348
+ readonly deliveryId: string;
349
+ readonly disposition: 'claimed' | 'rejected';
350
+ readonly reason?: 'invalid' | 'not-owned' | 'storage-rejected' | 'other';
351
+ }
352
+ type AckOutcome = {
353
+ readonly deliveryId: string;
354
+ readonly status: 'settled';
355
+ } | {
356
+ readonly deliveryId: string;
357
+ readonly status: 'conflict';
358
+ };
345
359
 
346
360
  interface IntentPayload {
347
361
  v: 2;
@@ -340,8 +340,22 @@ interface DeliveryPort {
340
340
  */
341
341
  incomingEpoch(): string | null;
342
342
  ack(deliveryId: string, disposition: 'claimed' | 'rejected', reason?: 'invalid' | 'not-owned' | 'storage-rejected' | 'other'): Promise<void>;
343
+ /** Settle many acks at once (#757). Contract pinned by the delivery-port contract suite. */
344
+ ackBatch?(acks: readonly AckRequest[]): Promise<readonly AckOutcome[]>;
343
345
  onWake?(cb: () => void): () => void;
344
346
  }
347
+ interface AckRequest {
348
+ readonly deliveryId: string;
349
+ readonly disposition: 'claimed' | 'rejected';
350
+ readonly reason?: 'invalid' | 'not-owned' | 'storage-rejected' | 'other';
351
+ }
352
+ type AckOutcome = {
353
+ readonly deliveryId: string;
354
+ readonly status: 'settled';
355
+ } | {
356
+ readonly deliveryId: string;
357
+ readonly status: 'conflict';
358
+ };
345
359
 
346
360
  interface IntentPayload {
347
361
  v: 2;
@@ -536,6 +536,33 @@ var SerialChain = class {
536
536
  return run;
537
537
  }
538
538
  };
539
+ function coalesced(run, observe) {
540
+ let inFlight = false;
541
+ let queued = false;
542
+ const start = () => {
543
+ inFlight = true;
544
+ let running;
545
+ try {
546
+ running = run();
547
+ } catch {
548
+ running = Promise.resolve();
549
+ }
550
+ observe?.(running);
551
+ void running.catch(() => void 0).finally(() => {
552
+ inFlight = false;
553
+ if (!queued) return;
554
+ queued = false;
555
+ start();
556
+ });
557
+ };
558
+ return () => {
559
+ if (inFlight) {
560
+ queued = true;
561
+ return;
562
+ }
563
+ start();
564
+ };
565
+ }
539
566
 
540
567
  // modules/payments-v2/machine/journal.ts
541
568
  var MAX_DELIVERY_ATTEMPTS = 6;
@@ -2841,6 +2868,16 @@ var InventoryView = class {
2841
2868
  this.inFlight.add(tokenId);
2842
2869
  this.deps.emit("inventory:updated");
2843
2870
  }
2871
+ /** Reserve a whole plan as ONE change — per token emitted 54 events per send. */
2872
+ markInFlightMany(tokenIds) {
2873
+ let changed = false;
2874
+ for (const tokenId of tokenIds) {
2875
+ if (this.inFlight.has(tokenId)) continue;
2876
+ this.inFlight.add(tokenId);
2877
+ changed = true;
2878
+ }
2879
+ if (changed) this.deps.emit("inventory:updated");
2880
+ }
2844
2881
  release(tokenId) {
2845
2882
  if (this.inFlight.delete(tokenId)) this.deps.emit("inventory:updated");
2846
2883
  }
@@ -3157,6 +3194,11 @@ var logger = {
3157
3194
  }
3158
3195
  };
3159
3196
 
3197
+ // modules/payments-v2/ports.ts
3198
+ function isRetryableAckError(err) {
3199
+ return err !== null && typeof err === "object" && err.retryable === true;
3200
+ }
3201
+
3160
3202
  // modules/payments-v2/receive/Receive.ts
3161
3203
  var ACK_BATCH_SIZE = 200;
3162
3204
  var REFRESH_INTERVAL_MS = 2500;
@@ -3213,6 +3255,17 @@ var Receive = class {
3213
3255
  await flushAcks(deps, pending, epochOf);
3214
3256
  deps.refreshView();
3215
3257
  }
3258
+ /** One listing pass: process each entry, flushing and refreshing as it goes. */
3259
+ async drainPages(ctx, basis) {
3260
+ const { deps, engine, pending, stored, pageEpoch } = ctx;
3261
+ for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
3262
+ const storedBefore = stored.length;
3263
+ await this.processEntry(deps, engine, entry, pending, stored);
3264
+ if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
3265
+ if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
3266
+ }
3267
+ await flushAcks(deps, pending, pageEpoch);
3268
+ }
3216
3269
  async doDrain() {
3217
3270
  const deps = this.deps;
3218
3271
  this.lastRefreshAt = deps.now?.() ?? Date.now();
@@ -3224,13 +3277,7 @@ var Receive = class {
3224
3277
  const record = await deps.kv.get(STORE_KEYS.streamCursor("mailbox"));
3225
3278
  let basis = record !== null && record.syncEpoch === deps.syncEpoch() ? record : null;
3226
3279
  for (let pass = 0; pass < 2; pass++) {
3227
- for await (const entry of deps.delivery.incoming(basis === null ? void 0 : String(basis.cursor))) {
3228
- const storedBefore = stored.length;
3229
- await this.processEntry(deps, engine, entry, pending, stored);
3230
- if (pending.length >= ACK_BATCH_SIZE) await flushAcks(deps, pending, pageEpoch);
3231
- if (stored.length > storedBefore) await this.maybeRefresh(deps, pending, pageEpoch);
3232
- }
3233
- await flushAcks(deps, pending, pageEpoch);
3280
+ await this.drainPages({ deps, engine, pending, stored, pageEpoch }, basis);
3234
3281
  const served = deps.delivery.incomingEpoch();
3235
3282
  if (basis === null || served === null || served === basis.syncEpoch) break;
3236
3283
  await deps.kv.remove(STORE_KEYS.streamCursor("mailbox"));
@@ -3238,10 +3285,12 @@ var Receive = class {
3238
3285
  }
3239
3286
  } catch (err) {
3240
3287
  logger.warn("PaymentsV2", "receive drain interrupted \u2014 unacked entries retry next drain:", err);
3288
+ if (isRetryableAckError(err)) return stored;
3241
3289
  await flushAcks(deps, pending, pageEpoch).catch((flushErr) => {
3242
3290
  logger.warn("PaymentsV2", "receive ack flush failed \u2014 cursor holds at the acked prefix:", flushErr);
3243
3291
  });
3244
3292
  }
3293
+ if (stored.length > 0) deps.refreshView?.();
3245
3294
  return stored;
3246
3295
  }
3247
3296
  async processEntry(deps, engine, entry, pending, stored) {
@@ -3312,14 +3361,18 @@ async function announce(deps, entry, record) {
3312
3361
  };
3313
3362
  }
3314
3363
  async function flushAcks(deps, pending, epochOf) {
3364
+ if (pending.length === 0) return;
3315
3365
  let lastAcked = null;
3316
3366
  try {
3317
- while (pending.length > 0) {
3318
- const next = pending[0];
3319
- await ackOne(deps, next);
3320
- lastAcked = next.cursor;
3321
- pending.shift();
3367
+ const settled = await settleAcks(deps, pending);
3368
+ let i = 0;
3369
+ while (i < pending.length && settled.has(pending[i].deliveryId)) {
3370
+ lastAcked = pending[i].cursor;
3371
+ i += 1;
3322
3372
  }
3373
+ const stuck = pending.filter((p) => !settled.has(p.deliveryId));
3374
+ pending.length = 0;
3375
+ pending.push(...stuck);
3323
3376
  } finally {
3324
3377
  if (lastAcked !== null) {
3325
3378
  const record = { cursor: lastAcked, syncEpoch: epochOf() };
@@ -3327,14 +3380,65 @@ async function flushAcks(deps, pending, epochOf) {
3327
3380
  }
3328
3381
  }
3329
3382
  }
3383
+ async function settleAcks(deps, pending) {
3384
+ const batch = deps.delivery.ackBatch?.bind(deps.delivery);
3385
+ if (batch === void 0) return settleOneByOne(deps, pending);
3386
+ let outcomes;
3387
+ try {
3388
+ outcomes = await batch(pending.map(toAckRequest));
3389
+ } catch (err) {
3390
+ if (isRetryableAckError(err)) throw err;
3391
+ logger.warn("PaymentsV2", "batched mailbox ack failed \u2014 settling one at a time:", err);
3392
+ return settleOneByOne(deps, pending);
3393
+ }
3394
+ const settled = new Set(outcomes.filter((o) => o.status === "settled").map((o) => o.deliveryId));
3395
+ const conflicts = outcomes.filter((o) => o.status === "conflict").map((o) => o.deliveryId);
3396
+ if (conflicts.length > 0) await resolveConflicts(deps, pending, conflicts, settled);
3397
+ return settled;
3398
+ }
3399
+ async function resolveConflicts(deps, pending, conflicts, settled) {
3400
+ for (const deliveryId of conflicts) {
3401
+ const entry = pending.find((p) => p.deliveryId === deliveryId);
3402
+ try {
3403
+ await rejectStaleClaim(deps, deliveryId, entry?.transferId);
3404
+ } catch (err) {
3405
+ logger.warn("PaymentsV2", `stale-reject failed for ${deliveryId} \u2014 cursor holds here:`, err);
3406
+ continue;
3407
+ }
3408
+ settled.add(deliveryId);
3409
+ }
3410
+ }
3411
+ async function rejectStaleClaim(deps, deliveryId, transferId) {
3412
+ logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${deliveryId} \u2014 rejected('other') as stale`);
3413
+ await deps.delivery.ack(deliveryId, "rejected", "other");
3414
+ deps.attention(transferId ?? "", ATTENTION_CLAIM_CONFLICT, deliveryId);
3415
+ }
3416
+ async function settleOneByOne(deps, pending) {
3417
+ const settled = /* @__PURE__ */ new Set();
3418
+ for (const ack of pending) {
3419
+ try {
3420
+ await ackOne(deps, ack);
3421
+ } catch (err) {
3422
+ logger.warn("PaymentsV2", `mailbox ack failed for ${ack.deliveryId} \u2014 cursor holds here:`, err);
3423
+ break;
3424
+ }
3425
+ settled.add(ack.deliveryId);
3426
+ }
3427
+ return settled;
3428
+ }
3429
+ function toAckRequest(ack) {
3430
+ return {
3431
+ deliveryId: ack.deliveryId,
3432
+ disposition: ack.disposition,
3433
+ ...ack.reason !== void 0 ? { reason: ack.reason } : {}
3434
+ };
3435
+ }
3330
3436
  async function ackOne(deps, ack) {
3331
3437
  try {
3332
3438
  await deps.delivery.ack(ack.deliveryId, ack.disposition, ack.reason);
3333
3439
  } catch (err) {
3334
3440
  if (ack.disposition !== "claimed" || !isClaimConflict(err)) throw err;
3335
- logger.warn("PaymentsV2", `mailbox claim CONFLICT for ${ack.deliveryId} \u2014 rejected('other') as stale`);
3336
- await deps.delivery.ack(ack.deliveryId, "rejected", "other");
3337
- deps.attention(ack.transferId ?? "", ATTENTION_CLAIM_CONFLICT, ack.deliveryId);
3441
+ await rejectStaleClaim(deps, ack.deliveryId, ack.transferId);
3338
3442
  }
3339
3443
  }
3340
3444
  function claimAck(entry) {
@@ -4088,26 +4192,6 @@ function parseAmount(raw) {
4088
4192
  function supportsDeterministicMint(engine) {
4089
4193
  return engine.deterministicMint === true;
4090
4194
  }
4091
- function makeCoalescedRefresh(refresh) {
4092
- let inFlight = false;
4093
- let queued = false;
4094
- const run = () => {
4095
- inFlight = true;
4096
- void refresh().catch(() => void 0).finally(() => {
4097
- inFlight = false;
4098
- if (!queued) return;
4099
- queued = false;
4100
- run();
4101
- });
4102
- };
4103
- return () => {
4104
- if (inFlight) {
4105
- queued = true;
4106
- return;
4107
- }
4108
- run();
4109
- };
4110
- }
4111
4195
  function composeFacadeParts(deps, hooks) {
4112
4196
  const ownPubkeyBytes = hexToBytes(deps.ownPubkey);
4113
4197
  const ledger = new ReservationLedger();
@@ -4137,9 +4221,11 @@ function composeFacadeParts(deps, hooks) {
4137
4221
  const machineDeps = buildMachineDeps(deps, hooks, historyStore);
4138
4222
  const machineStores = createMachineStores(deps.kv);
4139
4223
  const heldStates = /* @__PURE__ */ new Map();
4224
+ const refreshView = coalesced(() => view.delta(), hooks.track);
4140
4225
  return {
4141
4226
  ownPubkeyBytes,
4142
4227
  view,
4228
+ refreshView,
4143
4229
  ledger,
4144
4230
  pins: buildPins(deps, hooks, { ledger, view, machineStores, machineDeps }),
4145
4231
  queue,
@@ -4148,7 +4234,7 @@ function composeFacadeParts(deps, hooks) {
4148
4234
  machineDeps,
4149
4235
  machine: new TransferMachine(machineDeps),
4150
4236
  heldStates,
4151
- receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, view),
4237
+ receiveLoop: buildReceive(deps, hooks, historyStore, heldStates, refreshView),
4152
4238
  requests: buildRequests(deps, hooks),
4153
4239
  restoreDeps: buildRestoreDeps(deps, machineDeps, machineStores, view)
4154
4240
  };
@@ -4215,7 +4301,7 @@ function buildMachineDeps(deps, hooks, historyStore) {
4215
4301
  }
4216
4302
  };
4217
4303
  }
4218
- function buildReceive(deps, hooks, historyStore, heldStates, view) {
4304
+ function buildReceive(deps, hooks, historyStore, heldStates, refreshView) {
4219
4305
  return new Receive({
4220
4306
  delivery: deps.deliveryPort,
4221
4307
  engine: () => hooks.engine(),
@@ -4229,8 +4315,7 @@ function buildReceive(deps, hooks, historyStore, heldStates, view) {
4229
4315
  registry: deps.registry,
4230
4316
  recordReceived: (record) => recordReceived(historyStore, record),
4231
4317
  emit: (event, transfer) => deps.emit(event, transfer),
4232
- // Coalesced — see makeCoalescedRefresh.
4233
- refreshView: makeCoalescedRefresh(() => view.delta()),
4318
+ refreshView,
4234
4319
  attention: (transferId, code, detail) => {
4235
4320
  deps.emit(
4236
4321
  "transfer:attention",
@@ -4279,10 +4364,11 @@ var PaymentsFacade = class {
4279
4364
  const parts = composeFacadeParts(deps, {
4280
4365
  engine: () => this.engine(),
4281
4366
  send: (request) => this.send(request),
4282
- isActiveOp: (id) => this.activeMoneyOps.has(id)
4367
+ isActiveOp: (id) => this.activeMoneyOps.has(id),
4368
+ track: (op) => this.trackTail(op)
4283
4369
  });
4284
4370
  this.view = parts.view;
4285
- this.wakeRefresh = makeCoalescedRefresh(() => this.view.delta());
4371
+ this.wakeRefresh = parts.refreshView;
4286
4372
  this.ledger = parts.ledger;
4287
4373
  this.queue = parts.queue;
4288
4374
  this.machine = parts.machine;
@@ -4419,7 +4505,6 @@ var PaymentsFacade = class {
4419
4505
  }
4420
4506
  async receive() {
4421
4507
  const transfers = await this.track(this.receiveLoop.drainOnce());
4422
- if (transfers.length > 0) this.trackTail(this.view.delta());
4423
4508
  return { transfers };
4424
4509
  }
4425
4510
  mint(coinId, amount) {
@@ -4615,7 +4700,7 @@ var PaymentsFacade = class {
4615
4700
  ...spend.plan.direct,
4616
4701
  ...spend.plan.split !== void 0 ? [spend.plan.split.tokenId] : []
4617
4702
  ];
4618
- for (const tokenId of sourceIds) this.view.markInFlight(tokenId);
4703
+ this.view.markInFlightMany(sourceIds);
4619
4704
  if (spend.plan.split !== void 0) {
4620
4705
  this.queue.declareExpectedChange(transferId, coinId, spend.plan.split.remainderAmount);
4621
4706
  }