mppx 0.13.6 → 0.14.1

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.
@@ -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,6 +103,76 @@ 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
178
  const { channelState, current, verifiedAt, voucher } = parameters;
@@ -121,6 +196,7 @@ export function acceptVoucherStateUpdate(parameters) {
121
196
  ...onChain,
122
197
  highestVoucherAmount: voucher.cumulativeAmount,
123
198
  highestVoucher: voucher,
199
+ retainedVouchers: retainVouchers(current, voucher.cumulativeAmount, onChain.settledOnChain),
124
200
  };
125
201
  }
126
202
  /**
@@ -150,7 +226,7 @@ export function resolveCloseCaptureAmount(parameters) {
150
226
  /** Seconds a server close transaction stays valid after its pending-close marker is written. */
151
227
  export const pendingCloseValiditySeconds = 25;
152
228
  /** Extra seconds allowed for clock differences between the server and the chain. */
153
- const pendingCloseClockSkewSeconds = 30;
229
+ export const pendingCloseClockSkewSeconds = 30;
154
230
  /** Clears a server pending-close marker whose close transaction expired without closing the channel. */
155
231
  export function reconcileExpiredPendingClose(parameters) {
156
232
  const { current, now, state } = parameters;
@@ -473,14 +549,76 @@ export function fromStore(store) {
473
549
  storeCache.set(store, cs);
474
550
  return cs;
475
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
+ }
476
600
  /**
477
601
  * Atomically deducts `amount` from a channel's available voucher balance.
478
602
  *
479
603
  * Returns `{ ok: true, channel }` with updated spend/unit counters when the
480
604
  * deduction succeeds. Returns `{ ok: false, channel }` without mutating state
481
- * 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.
482
608
  */
483
- 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
+ }
484
622
  if (store.updateChannelResult) {
485
623
  const result = await store.updateChannelResult(channelId, (current) => planDeduction(current, amount));
486
624
  if (!result)
@@ -499,6 +637,26 @@ export async function deductFromChannel(store, channelId, amount) {
499
637
  throw new Error('channel not found');
500
638
  return result ?? { ok: false, channel };
501
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
+ }
502
660
  function planDeduction(current, amount) {
503
661
  if (!current)
504
662
  return { op: 'noop', result: null };
@@ -511,6 +669,88 @@ function planDeduction(current, amount) {
511
669
  const next = { ...current, spent: current.spent + amount, units: current.units + 1 };
512
670
  return { op: 'set', value: next, result: { ok: true, channel: next } };
513
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
+ }
514
754
  /** Returns unexpired stream reservations that ordinary deductions must respect. */
515
755
  export function reservedStreamAmount(state) {
516
756
  const now = Date.now();
@@ -0,0 +1,110 @@
1
+ import { type Hex } from 'viem';
2
+ import * as Chain from '../precompile/Chain.js';
3
+ import { type OnSessionSettlementFailure, type ResolvedSettlementSchedule, 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
+ /** Open, and settled because its spend crossed `settlementSchedule`. */
19
+ | 'settled'
20
+ /** Close requested, with nothing above the settled amount to capture; the payer's withdrawal finalizes it. */
21
+ | 'closing'
22
+ /** A close request recorded on the channel was cancelled on-chain, so the channel accepts charges again. */
23
+ | 'cancelled'
24
+ /** A payee close captured the channel's spend, or its deposit left the escrow with it paid. */
25
+ | 'closed'
26
+ /** The deposit left the escrow paying the payee less than the channel's spend. */
27
+ | 'unrecovered'
28
+ /** Already finalized in the store. */
29
+ | 'finalized'
30
+ /** No precompile channel record in the store. */
31
+ | 'missing'
32
+ /** The channel could not be read or answered; a later supervision retries it. */
33
+ | 'failed';
34
+ /** Result of one {@link superviseCloses} run. */
35
+ export type CloseSupervision = {
36
+ /** Block every escrow read was pinned to. Pass it as `previousBlock` once `complete`. */
37
+ block: bigint;
38
+ /** Timestamp of `block` in Unix seconds, e.g. to alert when the last complete run grows old. */
39
+ timestamp: number;
40
+ /** Whether every channel was read and handled, so `block` bounds later close-event searches. */
41
+ complete: boolean;
42
+ /** Channels supervised. */
43
+ supervised: number;
44
+ /** Close requests found cancelled. */
45
+ cancelled: number;
46
+ /** Channels closed or found closed with their spend paid. */
47
+ closed: number;
48
+ /** Channels that failed. */
49
+ failed: number;
50
+ /** Open channels settled on `settlementSchedule`. */
51
+ settled: number;
52
+ /** Channels whose deposit left the escrow before their spend was captured. */
53
+ unrecovered: number;
54
+ /**
55
+ * Per-channel outcomes, in supervision order. Channels whose outcome is `closed`, `unrecovered`,
56
+ * `finalized` or `missing` need no further supervision.
57
+ */
58
+ channels: {
59
+ channelId: Hex;
60
+ outcome: CloseSupervisionOutcome;
61
+ error?: unknown;
62
+ txHash?: Hex | undefined;
63
+ }[];
64
+ };
65
+ /** Inputs for {@link superviseCloses}. */
66
+ export type SuperviseClosesParameters = Pick<SettlementTransactionOptions, 'account' | 'candidateFeeTokens' | 'feePayer' | 'feePayerPolicy' | 'feeToken' | 'onSessionSettlement'> & {
67
+ /** Channels per escrow state read. @default 50 */
68
+ batchSize?: number | undefined;
69
+ /**
70
+ * Channels to supervise. A channel store cannot list its channels, so the caller keeps their IDs:
71
+ * add a channel before its first charge and drop it once a supervision reports it `closed`,
72
+ * `unrecovered`, `finalized` or `missing`.
73
+ */
74
+ channelIds: Iterable<Hex> | AsyncIterable<Hex>;
75
+ /** viem client for reads and payee closes on the channels' chain. */
76
+ client: Chain.TransactionClient;
77
+ /** Blocks searched before `previousBlock` for a channel no supervision has read open. @default 2_000n */
78
+ closeSearchMarginBlocks?: bigint | undefined;
79
+ /** Widest block range of one log query. @default 100_000n */
80
+ logBlockRange?: bigint | undefined;
81
+ /** Callback invoked when a payee close fails. Observer errors are ignored. */
82
+ onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
83
+ /** `block` of the last `complete` supervision, which bounds close-event searches. */
84
+ previousBlock?: bigint | undefined;
85
+ /**
86
+ * Settles open channels whose spend since their last settlement crossed this schedule, in raw
87
+ * token units, with the account and fee options of closes. Servers that charge channels with
88
+ * `charge()` rather than through the session handler, whose own schedule runs on each
89
+ * charge, use it to collect spend without waiting for a close. Settlement captures only a voucher
90
+ * within spend. Without it, open channels are only read.
91
+ */
92
+ settlementSchedule?: ResolvedSettlementSchedule | undefined;
93
+ /** Server channel store. */
94
+ store: SessionStoreInput;
95
+ };
96
+ /**
97
+ * Answers payer close requests on channels the server is not otherwise touching. After a payer
98
+ * requests a close on-chain, the escrow lets it withdraw the whole deposit once its close grace
99
+ * period (900 seconds on Tempo) ends, so call this from a scheduled job well within that period,
100
+ * for example every minute.
101
+ *
102
+ * Each run reads the escrow state of every channel at one pinned block, in batches. A close request
103
+ * is answered with a payee close capturing `max(spent, settled)`, bounded by the highest voucher and
104
+ * the deposit, which pays the accepted charges and refunds the rest; the close pays fees as
105
+ * configured, e.g. `feeToken` when the channel token cannot pay them. A deposit that already left
106
+ * the escrow is finalized from its `ChannelClosed` event, searched in pages of `logBlockRange`
107
+ * blocks. A close request cancelled on-chain reopens the channel.
108
+ */
109
+ export declare function superviseCloses(parameters: SuperviseClosesParameters): Promise<CloseSupervision>;
110
+ //# sourceMappingURL=CloseSupervisor.d.ts.map