mppx 0.13.5 → 0.14.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/cli/cli.js +131 -131
  5. package/dist/cli/plugins/evm.js +2 -2
  6. package/dist/cli/plugins/index.js +11 -11
  7. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  8. package/dist/client/internal/MethodChallenge.js +4 -0
  9. package/dist/internal/version.d.ts +2 -2
  10. package/dist/internal/version.js +1 -1
  11. package/dist/server/Mppx.js +31 -5
  12. package/dist/stripe/server/Methods.js +4 -1
  13. package/dist/tempo/client/Charge.js +1 -0
  14. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  15. package/dist/tempo/internal/auto-swap.js +35 -8
  16. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  17. package/dist/tempo/internal/fee-payer.js +20 -12
  18. package/dist/tempo/legacy/session/Chain.js +8 -2
  19. package/dist/tempo/server/Methods.d.ts +7 -2
  20. package/dist/tempo/server/Methods.js +20 -2
  21. package/dist/tempo/server/Relay.js +6 -22
  22. package/dist/tempo/server/Subscription.js +3 -0
  23. package/dist/tempo/server/internal/html.gen.js +1 -1
  24. package/dist/tempo/server/internal/transport.d.ts +3 -0
  25. package/dist/tempo/server/internal/transport.js +5 -1
  26. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  27. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  28. package/dist/tempo/session/client/ChannelStore.js +47 -0
  29. package/dist/tempo/session/client/CredentialState.js +8 -3
  30. package/dist/tempo/session/client/Session.js +50 -33
  31. package/dist/tempo/session/client/SessionManager.js +186 -65
  32. package/dist/tempo/session/client/Transports.d.ts +6 -0
  33. package/dist/tempo/session/client/Transports.js +52 -7
  34. package/dist/tempo/session/precompile/Chain.d.ts +2 -2
  35. package/dist/tempo/session/precompile/Chain.js +8 -4
  36. package/dist/tempo/session/server/ChannelStore.d.ts +186 -3
  37. package/dist/tempo/session/server/ChannelStore.js +312 -12
  38. package/dist/tempo/session/server/CloseSupervisor.d.ts +98 -0
  39. package/dist/tempo/session/server/CloseSupervisor.js +289 -0
  40. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  41. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  42. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  43. package/dist/tempo/session/server/MeteredStream.js +10 -26
  44. package/dist/tempo/session/server/Session.d.ts +70 -4
  45. package/dist/tempo/session/server/Session.js +156 -36
  46. package/dist/tempo/session/server/Settlement.d.ts +18 -4
  47. package/dist/tempo/session/server/Settlement.js +119 -32
  48. package/dist/tempo/session/server/SponsorshipLimit.d.ts +52 -0
  49. package/dist/tempo/session/server/SponsorshipLimit.js +107 -0
  50. package/dist/tempo/session/server/Sse.d.ts +4 -3
  51. package/dist/tempo/session/server/Sse.js +3 -3
  52. package/dist/tempo/session/server/Ws.d.ts +9 -1
  53. package/dist/tempo/session/server/Ws.js +1 -0
  54. package/dist/tempo/session/server/index.d.ts +9 -1
  55. package/dist/tempo/session/server/index.js +5 -1
  56. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { AmountExceedsDepositError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js';
1
+ import { AmountExceedsDepositError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InsufficientBalanceError, InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js';
2
2
  import { createSessionReceipt } from '../precompile/Protocol.js';
3
3
  import { uint96 } from '../precompile/Protocol.js';
4
4
  import * as Voucher from '../precompile/Voucher.js';
@@ -82,6 +82,11 @@ export function openChannelState(parameters) {
82
82
  settledOnChain: onChain.settledOnChain,
83
83
  highestVoucherAmount: highestVoucher.highestVoucherAmount,
84
84
  highestVoucher: highestVoucher.highestVoucher,
85
+ ...(current && highestVoucher.highestVoucherAmount > current.highestVoucherAmount
86
+ ? {
87
+ retainedVouchers: retainVouchers(current, highestVoucher.highestVoucherAmount, onChain.settledOnChain),
88
+ }
89
+ : {}),
85
90
  spent: keepGreater(current?.spent ?? 0n, state.settled),
86
91
  units: current?.units ?? 0,
87
92
  finalized: current?.finalized ?? false,
@@ -98,16 +103,91 @@ export function topUpChannelState(parameters) {
98
103
  ...mergeActiveOnChainState(current, state),
99
104
  };
100
105
  }
106
+ /**
107
+ * Number of accepted vouchers kept below the highest one. Clients sign every voucher above their
108
+ * last one, so once a credit lowers `spent` the highest voucher stays above it for good and only
109
+ * earlier vouchers can be settled; those fall at or below `spent` as later charges accumulate.
110
+ * Each costs about 200 bytes of the channel record. In a simulated 20,000-request channel crediting
111
+ * up to 80% of each request back, which left the highest voucher 80,000 units above `spent`,
112
+ * settling every 50 requests left on average 1,290 units unsettled with 16 retained vouchers and
113
+ * 784 with 32.
114
+ */
115
+ export const maxRetainedVouchers = 16;
116
+ /**
117
+ * Returns the vouchers to keep once `highestVoucherAmount` replaces the highest voucher: the
118
+ * retained ones and the superseded highest voucher, above `settledOnChain`. Beyond
119
+ * {@link maxRetainedVouchers} it repeatedly drops the voucher whose neighbours are closest, so the
120
+ * kept ones stay spread over the unsettled range and some keep falling at or below `spent` however
121
+ * far credits have moved it below the highest voucher.
122
+ */
123
+ export function retainVouchers(current, highestVoucherAmount, settledOnChain = current.settledOnChain) {
124
+ const candidates = [...(current.retainedVouchers ?? [])];
125
+ if (current.highestVoucher)
126
+ candidates.push({
127
+ cumulativeAmount: current.highestVoucher.cumulativeAmount,
128
+ signature: current.highestVoucher.signature,
129
+ });
130
+ const kept = candidates
131
+ .filter(({ cumulativeAmount }) => cumulativeAmount > settledOnChain && cumulativeAmount < highestVoucherAmount)
132
+ .sort((a, b) => a.cumulativeAmount < b.cumulativeAmount
133
+ ? -1
134
+ : a.cumulativeAmount > b.cumulativeAmount
135
+ ? 1
136
+ : 0);
137
+ while (kept.length > maxRetainedVouchers) {
138
+ let drop = 0;
139
+ let narrowest;
140
+ for (let index = 0; index < kept.length; index++) {
141
+ const below = index === 0 ? settledOnChain : kept[index - 1].cumulativeAmount;
142
+ const above = index === kept.length - 1 ? highestVoucherAmount : kept[index + 1].cumulativeAmount;
143
+ if (narrowest === undefined || above - below < narrowest) {
144
+ narrowest = above - below;
145
+ drop = index;
146
+ }
147
+ }
148
+ kept.splice(drop, 1);
149
+ }
150
+ return kept;
151
+ }
152
+ /**
153
+ * Returns the largest accepted voucher a settlement may capture. The escrow's `settle` captures a
154
+ * voucher's whole cumulative amount, and a credit may leave the highest voucher above `spent`, so
155
+ * the voucher must be at most `spent`; one at or below `settledOnChain` captures nothing.
156
+ */
157
+ export function resolveSettleableVoucher(state) {
158
+ let settleable;
159
+ for (const voucher of [
160
+ ...(state.retainedVouchers ?? []),
161
+ ...(state.highestVoucher ? [state.highestVoucher] : []),
162
+ ]) {
163
+ if (voucher.cumulativeAmount > state.spent || voucher.cumulativeAmount <= state.settledOnChain)
164
+ continue;
165
+ if (!settleable || voucher.cumulativeAmount > settleable.cumulativeAmount)
166
+ settleable = voucher;
167
+ }
168
+ if (!settleable)
169
+ return null;
170
+ return {
171
+ channelId: state.channelId,
172
+ cumulativeAmount: settleable.cumulativeAmount,
173
+ signature: settleable.signature,
174
+ };
175
+ }
101
176
  /** Merges an accepted voucher into persisted channel state after signature and on-chain checks pass. */
102
177
  export function acceptVoucherStateUpdate(parameters) {
103
- const { channelState, current, voucher } = parameters;
178
+ const { channelState, current, verifiedAt, voucher } = parameters;
104
179
  if (!current)
105
180
  throw new ChannelNotFoundError({ reason: 'channel not found' });
106
181
  if (current.finalized)
107
182
  throw new ChannelClosedError({ reason: 'channel is finalized' });
108
183
  if (current.closeRequestedAt !== 0n)
109
184
  throw new ChannelClosedError({ reason: 'channel has a pending close request' });
110
- const onChain = mergeActiveOnChainState(current, channelState);
185
+ const onChain = {
186
+ ...mergeActiveOnChainState(current, channelState),
187
+ ...(verifiedAt === undefined
188
+ ? {}
189
+ : { onChainVerifiedAt: Math.max(verifiedAt, current.onChainVerifiedAt ?? 0) }),
190
+ };
111
191
  if (voucher.cumulativeAmount <= current.highestVoucherAmount) {
112
192
  return { ...current, ...onChain };
113
193
  }
@@ -116,6 +196,7 @@ export function acceptVoucherStateUpdate(parameters) {
116
196
  ...onChain,
117
197
  highestVoucherAmount: voucher.cumulativeAmount,
118
198
  highestVoucher: voucher,
199
+ retainedVouchers: retainVouchers(current, voucher.cumulativeAmount, onChain.settledOnChain),
119
200
  };
120
201
  }
121
202
  /**
@@ -145,7 +226,7 @@ export function resolveCloseCaptureAmount(parameters) {
145
226
  /** Seconds a server close transaction stays valid after its pending-close marker is written. */
146
227
  export const pendingCloseValiditySeconds = 25;
147
228
  /** Extra seconds allowed for clock differences between the server and the chain. */
148
- const pendingCloseClockSkewSeconds = 30;
229
+ export const pendingCloseClockSkewSeconds = 30;
149
230
  /** Clears a server pending-close marker whose close transaction expired without closing the channel. */
150
231
  export function reconcileExpiredPendingClose(parameters) {
151
232
  const { current, now, state } = parameters;
@@ -239,13 +320,58 @@ export async function validateVoucher(parameters) {
239
320
  });
240
321
  return { alreadyAccepted: false };
241
322
  }
323
+ /**
324
+ * Checks a cumulative voucher's amounts against channel state, without its signature, so the
325
+ * check can run inside the store update that accepts it.
326
+ */
327
+ export function assertVoucherAmounts(parameters) {
328
+ const { channel, channelState, minVoucherDelta, voucher } = parameters;
329
+ validateChannelState(channelState);
330
+ if (voucher.cumulativeAmount > channelState.deposit)
331
+ throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' });
332
+ if (voucher.cumulativeAmount < channel.highestVoucherAmount)
333
+ throw new VerificationFailedError({
334
+ reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
335
+ });
336
+ if (voucher.cumulativeAmount <= channelState.settled)
337
+ throw new VerificationFailedError({
338
+ reason: 'voucher cumulativeAmount is below on-chain settled amount',
339
+ });
340
+ if (voucher.cumulativeAmount === channel.highestVoucherAmount)
341
+ return { alreadyAccepted: true };
342
+ const delta = voucher.cumulativeAmount - channel.highestVoucherAmount;
343
+ if (delta < minVoucherDelta)
344
+ throw new DeltaTooSmallError({
345
+ reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
346
+ });
347
+ return { alreadyAccepted: false };
348
+ }
349
+ /**
350
+ * Charges `chargeAmount` to a channel state that just accepted a voucher, for the same store
351
+ * update. A charge the available balance cannot cover leaves the accepted state uncharged.
352
+ */
353
+ export function planVoucherAcceptance(parameters) {
354
+ const { accepted, chargeAmount } = parameters;
355
+ if (chargeAmount === undefined)
356
+ return { channel: accepted, charged: false };
357
+ const deduction = planDeduction(accepted, chargeAmount);
358
+ if (deduction.op !== 'set')
359
+ return { channel: accepted, charged: false };
360
+ return { channel: deduction.value, charged: true };
361
+ }
362
+ /** Reports a request charge that the channel's available balance could not cover. */
363
+ export function insufficientBalanceForCharge(channel, chargeAmount) {
364
+ return new InsufficientBalanceError({
365
+ reason: `requested ${chargeAmount}, available ${channel.highestVoucherAmount - channel.spent - reservedStreamAmount(channel)}`,
366
+ });
367
+ }
242
368
  /** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
243
369
  export async function verifyAndAcceptVoucher(parameters) {
244
- const { store, challenge, channel, voucher, channelState, requireAdvance } = parameters;
370
+ const { store, challenge, channel, voucher, channelState, requireAdvance, verifiedAt, chargeAmount, } = parameters;
245
371
  const validation = await validateVoucher(parameters);
246
- if (validation.alreadyAccepted) {
247
- if (requireAdvance)
248
- throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
372
+ if (validation.alreadyAccepted && requireAdvance)
373
+ throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
374
+ if (validation.alreadyAccepted && chargeAmount === undefined)
249
375
  return createSessionReceipt({
250
376
  challengeId: challenge.id,
251
377
  channelId: voucher.channelId,
@@ -253,16 +379,26 @@ export async function verifyAndAcceptVoucher(parameters) {
253
379
  spent: channel.spent,
254
380
  units: channel.units,
255
381
  });
256
- }
382
+ let charged = false;
257
383
  const updated = await store.updateChannel(voucher.channelId, (current) => {
258
384
  if (requireAdvance &&
259
385
  current !== null &&
260
386
  voucher.cumulativeAmount <= current.highestVoucherAmount)
261
387
  throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
262
- return acceptVoucherStateUpdate({ channelState, current, voucher });
388
+ const acceptance = planVoucherAcceptance({
389
+ accepted: acceptVoucherStateUpdate({ channelState, current, verifiedAt, voucher }),
390
+ chargeAmount,
391
+ });
392
+ charged = acceptance.charged;
393
+ return acceptance.channel;
263
394
  });
264
395
  if (!updated)
265
396
  throw new ChannelNotFoundError({ reason: 'channel not found' });
397
+ if (chargeAmount !== undefined) {
398
+ if (!charged)
399
+ throw insufficientBalanceForCharge(updated, chargeAmount);
400
+ parameters.onCharged?.(updated);
401
+ }
266
402
  return createSessionReceipt({
267
403
  challengeId: challenge.id,
268
404
  channelId: voucher.channelId,
@@ -413,14 +549,76 @@ export function fromStore(store) {
413
549
  storeCache.set(store, cs);
414
550
  return cs;
415
551
  }
552
+ /**
553
+ * Default time a keyed charge or credit outcome is kept: a day, longer than any retry within one
554
+ * request or durable job step. Callers whose retries span longer pass their own `retention`.
555
+ */
556
+ export const defaultIdempotencyRetention = 24 * 60 * 60 * 1000;
557
+ /** Raised when an idempotency key is reused with terms other than those it recorded. */
558
+ export class IdempotencyKeyConflictError extends Error {
559
+ name = 'IdempotencyKeyConflictError';
560
+ /** The reused key. */
561
+ idempotencyKey;
562
+ constructor(options) {
563
+ super(`Session ${options.operation} "${options.idempotencyKey}" was recorded with different terms.`);
564
+ this.idempotencyKey = options.idempotencyKey;
565
+ }
566
+ }
567
+ /** Atomic read-modify-write through `updateChannelResult`, or `updateChannel` when absent. */
568
+ async function applyChange(store, channelId, fn) {
569
+ if (store.updateChannelResult)
570
+ return store.updateChannelResult(channelId, fn);
571
+ // The callback can rerun on a contended write; the result of its last run is the stored one.
572
+ let result;
573
+ await store.updateChannel(channelId, (current) => {
574
+ const change = fn(current);
575
+ result = change.result;
576
+ if (change.op === 'set')
577
+ return change.value;
578
+ if (change.op === 'delete')
579
+ return null;
580
+ return current;
581
+ });
582
+ return result;
583
+ }
584
+ /** Validates the idempotency options of a keyed charge or credit. */
585
+ export function assertIdempotencyOptions(options) {
586
+ const { idempotencyKey, retention } = options;
587
+ if (idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !idempotencyKey))
588
+ throw new Error('`idempotencyKey` must be a non-empty string.');
589
+ if (retention !== undefined && (!Number.isFinite(retention) || retention <= 0))
590
+ throw new Error('`retention` must be a positive number.');
591
+ }
592
+ /** Markers of a channel that have not expired. */
593
+ function liveMarkers(state, now) {
594
+ const markers = {};
595
+ for (const [key, marker] of Object.entries(state.idempotencyMarkers ?? {}))
596
+ if (marker.expiresAt > now)
597
+ markers[key] = marker;
598
+ return markers;
599
+ }
416
600
  /**
417
601
  * Atomically deducts `amount` from a channel's available voucher balance.
418
602
  *
419
603
  * Returns `{ ok: true, channel }` with updated spend/unit counters when the
420
604
  * deduction succeeds. Returns `{ ok: false, channel }` without mutating state
421
- * when the channel exists but cannot currently be charged.
605
+ * when the channel exists but cannot currently be charged. With an
606
+ * `idempotencyKey`, a charge recorded under the key returns
607
+ * `{ ok: true, channel, replayed: true }` without charging again.
422
608
  */
423
- export async function deductFromChannel(store, channelId, amount) {
609
+ export async function deductFromChannel(store, channelId, amount, options) {
610
+ const idempotencyKey = options?.idempotencyKey;
611
+ if (idempotencyKey !== undefined) {
612
+ assertIdempotencyOptions(options);
613
+ const now = Date.now();
614
+ const expiresAt = now + (options?.retention ?? defaultIdempotencyRetention);
615
+ const result = await applyChange(store, channelId, (current) => planKeyedDeduction(current, amount, idempotencyKey, expiresAt, now));
616
+ if (!result)
617
+ throw new Error('channel not found');
618
+ if (result === 'conflict')
619
+ throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'charge' });
620
+ return result;
621
+ }
424
622
  if (store.updateChannelResult) {
425
623
  const result = await store.updateChannelResult(channelId, (current) => planDeduction(current, amount));
426
624
  if (!result)
@@ -439,6 +637,26 @@ export async function deductFromChannel(store, channelId, amount) {
439
637
  throw new Error('channel not found');
440
638
  return result ?? { ok: false, channel };
441
639
  }
640
+ function planKeyedDeduction(current, amount, idempotencyKey, expiresAt, now) {
641
+ if (!current)
642
+ return { op: 'noop', result: null };
643
+ const markers = liveMarkers(current, now);
644
+ const markerKey = `charge:${idempotencyKey}`;
645
+ const recorded = markers[markerKey];
646
+ if (recorded) {
647
+ if (recorded.outcome !== 'charged' || recorded.amount !== amount)
648
+ return { op: 'noop', result: 'conflict' };
649
+ return { op: 'noop', result: { ok: true, channel: current, replayed: true } };
650
+ }
651
+ const deduction = planDeduction(current, amount);
652
+ if (deduction.op !== 'set')
653
+ return deduction;
654
+ const channel = {
655
+ ...deduction.value,
656
+ idempotencyMarkers: { ...markers, [markerKey]: { amount, expiresAt, outcome: 'charged' } },
657
+ };
658
+ return { op: 'set', value: channel, result: { ok: true, channel } };
659
+ }
442
660
  function planDeduction(current, amount) {
443
661
  if (!current)
444
662
  return { op: 'noop', result: null };
@@ -451,6 +669,88 @@ function planDeduction(current, amount) {
451
669
  const next = { ...current, spent: current.spent + amount, units: current.units + 1 };
452
670
  return { op: 'set', value: next, result: { ok: true, channel: next } };
453
671
  }
672
+ /**
673
+ * Returns `amount` to a channel's `spent` at most once per idempotency key. The credit is declined
674
+ * when the channel is closing or finalized, or when it would take `spent` below zero or below
675
+ * what settled, or may still settle, on-chain, because a close captures `max(spent, settled)`.
676
+ * The first outcome for a key is final, so a declined credit is never applied later.
677
+ */
678
+ export async function creditChannel(store, channelId, amount, options) {
679
+ const { idempotencyKey, units = 0 } = options;
680
+ assertIdempotencyOptions(options);
681
+ if (typeof idempotencyKey !== 'string')
682
+ throw new Error('`idempotencyKey` is required.');
683
+ if (amount <= 0n)
684
+ throw new Error('Credit amount must be positive.');
685
+ if (!Number.isSafeInteger(units) || units < 0)
686
+ throw new Error('`units` must be a non-negative integer.');
687
+ const now = Date.now();
688
+ const expiresAt = now + (options.retention ?? defaultIdempotencyRetention);
689
+ let result;
690
+ try {
691
+ result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units }));
692
+ }
693
+ catch (error) {
694
+ return { status: 'unknown', error };
695
+ }
696
+ if (!result)
697
+ throw new ChannelNotFoundError({ reason: 'channel not found' });
698
+ if (result === 'conflict')
699
+ throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'credit' });
700
+ return result;
701
+ }
702
+ function planCredit(current, terms) {
703
+ const { amount, expiresAt, idempotencyKey, now, units } = terms;
704
+ if (!current)
705
+ return { op: 'noop', result: null };
706
+ const markers = liveMarkers(current, now);
707
+ const markerKey = `credit:${idempotencyKey}`;
708
+ const recorded = markers[markerKey];
709
+ if (recorded) {
710
+ if (recorded.outcome === 'charged' ||
711
+ recorded.amount !== amount ||
712
+ (recorded.units ?? 0) !== units)
713
+ return { op: 'noop', result: 'conflict' };
714
+ return {
715
+ op: 'noop',
716
+ result: recorded.outcome === 'credited'
717
+ ? { status: 'credited', channel: current, replayed: true }
718
+ : { status: 'declined', reason: recorded.outcome, channel: current, replayed: true },
719
+ };
720
+ }
721
+ const reason = creditDeclineReason(current, amount, now);
722
+ const marker = {
723
+ amount,
724
+ expiresAt,
725
+ outcome: reason ?? 'credited',
726
+ ...(units > 0 ? { units } : {}),
727
+ };
728
+ const channel = {
729
+ ...current,
730
+ ...(reason ? {} : { spent: current.spent - amount, units: Math.max(0, current.units - units) }),
731
+ idempotencyMarkers: { ...markers, [markerKey]: marker },
732
+ };
733
+ return {
734
+ op: 'set',
735
+ value: channel,
736
+ result: reason
737
+ ? { status: 'declined', reason, channel, replayed: false }
738
+ : { status: 'credited', channel, replayed: false },
739
+ };
740
+ }
741
+ function creditDeclineReason(current, amount, now) {
742
+ if (current.finalized)
743
+ return 'finalized';
744
+ if (current.closeRequestedAt !== 0n)
745
+ return 'closing';
746
+ if (amount > current.spent)
747
+ return 'exceedsSpent';
748
+ const pending = current.pendingSettlement;
749
+ const settling = pending && pending.expiresAt > now ? pending.amount : 0n;
750
+ if (current.spent - amount < keepGreater(current.settledOnChain, settling))
751
+ return 'belowSettled';
752
+ return undefined;
753
+ }
454
754
  /** Returns unexpired stream reservations that ordinary deductions must respect. */
455
755
  export function reservedStreamAmount(state) {
456
756
  const now = Date.now();
@@ -0,0 +1,98 @@
1
+ import { type Hex } from 'viem';
2
+ import * as Chain from '../precompile/Chain.js';
3
+ import { type OnSessionSettlementFailure, type SessionStoreInput, type SettlementTransactionOptions } from './Settlement.js';
4
+ /** Channels whose escrow states one call reads, bounding its calldata and response size. */
5
+ export declare const defaultBatchSize = 50;
6
+ /** Widest log query `rpc.tempo.xyz` accepts; wider ones fail with "query exceeds max block range". */
7
+ export declare const defaultLogBlockRange = 100000n;
8
+ /**
9
+ * Blocks searched before the previous complete supervision for the close event of a channel no
10
+ * supervision has read open yet: about 18 minutes of 0.55-second Tempo blocks, longer than the
11
+ * escrow's 900-second close grace period and the interval between supervisions.
12
+ */
13
+ export declare const defaultCloseSearchMarginBlocks = 2000n;
14
+ /** What one supervision did with a channel. */
15
+ export type CloseSupervisionOutcome =
16
+ /** Open with no close request, or not yet open at the pinned block. */
17
+ 'open'
18
+ /** Close requested, with nothing above the settled amount to capture; the payer's withdrawal finalizes it. */
19
+ | 'closing'
20
+ /** A close request recorded on the channel was cancelled on-chain, so the channel accepts charges again. */
21
+ | 'cancelled'
22
+ /** A payee close captured the channel's spend, or its deposit left the escrow with it paid. */
23
+ | 'closed'
24
+ /** The deposit left the escrow paying the payee less than the channel's spend. */
25
+ | 'unrecovered'
26
+ /** Already finalized in the store. */
27
+ | 'finalized'
28
+ /** No precompile channel record in the store. */
29
+ | 'missing'
30
+ /** The channel could not be read or answered; a later supervision retries it. */
31
+ | 'failed';
32
+ /** Result of one {@link superviseCloses} run. */
33
+ export type CloseSupervision = {
34
+ /** Block every escrow read was pinned to. Pass it as `previousBlock` once `complete`. */
35
+ block: bigint;
36
+ /** Timestamp of `block` in Unix seconds, e.g. to alert when the last complete run grows old. */
37
+ timestamp: number;
38
+ /** Whether every channel was read and handled, so `block` bounds later close-event searches. */
39
+ complete: boolean;
40
+ /** Channels supervised. */
41
+ supervised: number;
42
+ /** Close requests found cancelled. */
43
+ cancelled: number;
44
+ /** Channels closed or found closed with their spend paid. */
45
+ closed: number;
46
+ /** Channels that failed. */
47
+ failed: number;
48
+ /** Channels whose deposit left the escrow before their spend was captured. */
49
+ unrecovered: number;
50
+ /**
51
+ * Per-channel outcomes, in supervision order. Channels whose outcome is `closed`, `unrecovered`,
52
+ * `finalized` or `missing` need no further supervision.
53
+ */
54
+ channels: {
55
+ channelId: Hex;
56
+ outcome: CloseSupervisionOutcome;
57
+ error?: unknown;
58
+ txHash?: Hex | undefined;
59
+ }[];
60
+ };
61
+ /** Inputs for {@link superviseCloses}. */
62
+ export type SuperviseClosesParameters = Pick<SettlementTransactionOptions, 'account' | 'candidateFeeTokens' | 'feePayer' | 'feePayerPolicy' | 'feeToken' | 'onSessionSettlement'> & {
63
+ /** Channels per escrow state read. @default 50 */
64
+ batchSize?: number | undefined;
65
+ /**
66
+ * Channels to supervise. A channel store cannot list its channels, so the caller keeps their IDs:
67
+ * add a channel before its first charge and drop it once a supervision reports it `closed`,
68
+ * `unrecovered`, `finalized` or `missing`.
69
+ */
70
+ channelIds: Iterable<Hex> | AsyncIterable<Hex>;
71
+ /** viem client for reads and payee closes on the channels' chain. */
72
+ client: Chain.TransactionClient;
73
+ /** Blocks searched before `previousBlock` for a channel no supervision has read open. @default 2_000n */
74
+ closeSearchMarginBlocks?: bigint | undefined;
75
+ /** Widest block range of one log query. @default 100_000n */
76
+ logBlockRange?: bigint | undefined;
77
+ /** Callback invoked when a payee close fails. Observer errors are ignored. */
78
+ onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
79
+ /** `block` of the last `complete` supervision, which bounds close-event searches. */
80
+ previousBlock?: bigint | undefined;
81
+ /** Server channel store. */
82
+ store: SessionStoreInput;
83
+ };
84
+ /**
85
+ * Answers payer close requests on channels the server is not otherwise touching. After a payer
86
+ * requests a close on-chain, the escrow lets it withdraw the whole deposit once its close grace
87
+ * period (900 seconds on Tempo) ends, so call this from a scheduled job well within that period,
88
+ * for example every minute.
89
+ *
90
+ * Each run reads the escrow state of every channel at one pinned block, in batches. A close request
91
+ * is answered with a payee close capturing `max(spent, settled)`, bounded by the highest voucher and
92
+ * the deposit, which pays the accepted charges and refunds the rest; the close pays fees as
93
+ * configured, e.g. `feeToken` when the channel token cannot pay them. A deposit that already left
94
+ * the escrow is finalized from its `ChannelClosed` event, searched in pages of `logBlockRange`
95
+ * blocks. A close request cancelled on-chain reopens the channel.
96
+ */
97
+ export declare function superviseCloses(parameters: SuperviseClosesParameters): Promise<CloseSupervision>;
98
+ //# sourceMappingURL=CloseSupervisor.d.ts.map