mppx 0.13.6 → 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.
- package/CHANGELOG.md +14 -0
- package/dist/cli/cli.js +1 -1
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/tempo/server/Methods.d.ts +7 -2
- package/dist/tempo/server/Methods.js +9 -2
- package/dist/tempo/session/precompile/Chain.d.ts +2 -2
- package/dist/tempo/session/precompile/Chain.js +3 -2
- package/dist/tempo/session/server/ChannelStore.d.ts +152 -3
- package/dist/tempo/session/server/ChannelStore.js +243 -3
- package/dist/tempo/session/server/CloseSupervisor.d.ts +98 -0
- package/dist/tempo/session/server/CloseSupervisor.js +289 -0
- package/dist/tempo/session/server/Session.d.ts +41 -2
- package/dist/tempo/session/server/Session.js +43 -4
- package/dist/tempo/session/server/Settlement.d.ts +9 -4
- package/dist/tempo/session/server/Settlement.js +107 -32
- package/dist/tempo/session/server/SponsorshipLimit.d.ts +52 -0
- package/dist/tempo/session/server/SponsorshipLimit.js +107 -0
- package/dist/tempo/session/server/index.d.ts +9 -1
- package/dist/tempo/session/server/index.js +5 -1
- package/package.json +1 -1
|
@@ -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,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
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { getAbiItem } from 'viem';
|
|
2
|
+
import { getBlock, getLogs } from 'viem/actions';
|
|
3
|
+
import { VerificationFailedError } from '../../../Errors.js';
|
|
4
|
+
import * as Chain from '../precompile/Chain.js';
|
|
5
|
+
import { escrowAbi } from '../precompile/escrow.abi.js';
|
|
6
|
+
import * as ChannelStore from './ChannelStore.js';
|
|
7
|
+
import { assertSettlementSender, getClientAccount, reportSessionSettlementFailure, resolveChannelStore, } from './Settlement.js';
|
|
8
|
+
/** Escrow event every close and withdrawal emits, with the amount paid to the payee. */
|
|
9
|
+
const channelClosedEvent = getAbiItem({ abi: escrowAbi, name: 'ChannelClosed' });
|
|
10
|
+
/** Channels whose escrow states one call reads, bounding its calldata and response size. */
|
|
11
|
+
export const defaultBatchSize = 50;
|
|
12
|
+
/** Widest log query `rpc.tempo.xyz` accepts; wider ones fail with "query exceeds max block range". */
|
|
13
|
+
export const defaultLogBlockRange = 100000n;
|
|
14
|
+
/**
|
|
15
|
+
* Blocks searched before the previous complete supervision for the close event of a channel no
|
|
16
|
+
* supervision has read open yet: about 18 minutes of 0.55-second Tempo blocks, longer than the
|
|
17
|
+
* escrow's 900-second close grace period and the interval between supervisions.
|
|
18
|
+
*/
|
|
19
|
+
export const defaultCloseSearchMarginBlocks = 2000n;
|
|
20
|
+
/**
|
|
21
|
+
* Answers payer close requests on channels the server is not otherwise touching. After a payer
|
|
22
|
+
* requests a close on-chain, the escrow lets it withdraw the whole deposit once its close grace
|
|
23
|
+
* period (900 seconds on Tempo) ends, so call this from a scheduled job well within that period,
|
|
24
|
+
* for example every minute.
|
|
25
|
+
*
|
|
26
|
+
* Each run reads the escrow state of every channel at one pinned block, in batches. A close request
|
|
27
|
+
* is answered with a payee close capturing `max(spent, settled)`, bounded by the highest voucher and
|
|
28
|
+
* the deposit, which pays the accepted charges and refunds the rest; the close pays fees as
|
|
29
|
+
* configured, e.g. `feeToken` when the channel token cannot pay them. A deposit that already left
|
|
30
|
+
* the escrow is finalized from its `ChannelClosed` event, searched in pages of `logBlockRange`
|
|
31
|
+
* blocks. A close request cancelled on-chain reopens the channel.
|
|
32
|
+
*/
|
|
33
|
+
export async function superviseCloses(parameters) {
|
|
34
|
+
const { client } = parameters;
|
|
35
|
+
const store = resolveChannelStore(parameters.store);
|
|
36
|
+
const batchSize = parameters.batchSize ?? defaultBatchSize;
|
|
37
|
+
// A read pinned to this block fails on a node that has not reached it, instead of answering
|
|
38
|
+
// with older state that would hide a close request.
|
|
39
|
+
const block = await getBlock(client, { blockTag: 'latest' });
|
|
40
|
+
const pinned = { number: block.number, timestamp: Number(block.timestamp) };
|
|
41
|
+
const supervision = {
|
|
42
|
+
block: pinned.number,
|
|
43
|
+
cancelled: 0,
|
|
44
|
+
channels: [],
|
|
45
|
+
closed: 0,
|
|
46
|
+
complete: false,
|
|
47
|
+
failed: 0,
|
|
48
|
+
supervised: 0,
|
|
49
|
+
timestamp: pinned.timestamp,
|
|
50
|
+
unrecovered: 0,
|
|
51
|
+
};
|
|
52
|
+
const report = (channelId, outcome, details = {}) => {
|
|
53
|
+
supervision.supervised += 1;
|
|
54
|
+
if (outcome === 'cancelled' ||
|
|
55
|
+
outcome === 'closed' ||
|
|
56
|
+
outcome === 'failed' ||
|
|
57
|
+
outcome === 'unrecovered')
|
|
58
|
+
supervision[outcome] += 1;
|
|
59
|
+
supervision.channels.push({ channelId, outcome, ...details });
|
|
60
|
+
};
|
|
61
|
+
const superviseBatch = async (channelIds) => {
|
|
62
|
+
const live = [];
|
|
63
|
+
for (const channelId of channelIds) {
|
|
64
|
+
let channel;
|
|
65
|
+
try {
|
|
66
|
+
channel = await store.getChannel(channelId);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
report(channelId, 'failed', { error });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!channel || !ChannelStore.isPrecompileState(channel))
|
|
73
|
+
report(channelId, 'missing');
|
|
74
|
+
else if (channel.finalized)
|
|
75
|
+
report(channelId, 'finalized');
|
|
76
|
+
else if (client.chain && client.chain.id !== channel.chainId)
|
|
77
|
+
report(channelId, 'failed', {
|
|
78
|
+
error: new Error(`Channel is on chain ${channel.chainId}, not ${client.chain.id}.`),
|
|
79
|
+
});
|
|
80
|
+
else
|
|
81
|
+
live.push(channel);
|
|
82
|
+
}
|
|
83
|
+
const byEscrow = new Map();
|
|
84
|
+
for (const channel of live) {
|
|
85
|
+
const escrow = channel.escrowContract.toLowerCase();
|
|
86
|
+
byEscrow.set(escrow, [...(byEscrow.get(escrow) ?? []), channel]);
|
|
87
|
+
}
|
|
88
|
+
for (const members of byEscrow.values()) {
|
|
89
|
+
let states;
|
|
90
|
+
try {
|
|
91
|
+
states = await Chain.getChannelStatesBatch(client, members.map(({ channelId }) => channelId), members[0].escrowContract, pinned.number);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
for (const { channelId } of members)
|
|
95
|
+
report(channelId, 'failed', { error });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const [index, channel] of members.entries()) {
|
|
99
|
+
const state = states[index];
|
|
100
|
+
if (!state) {
|
|
101
|
+
report(channel.channelId, 'failed', { error: new Error('Escrow state missing.') });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const { outcome, txHash } = await superviseChannel(parameters, store, channel, state, {
|
|
106
|
+
pinned,
|
|
107
|
+
previous: parameters.previousBlock,
|
|
108
|
+
});
|
|
109
|
+
report(channel.channelId, outcome, { txHash });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// A close that landed before this failure is found by its close event next time.
|
|
113
|
+
report(channel.channelId, 'failed', { error });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let batch = [];
|
|
119
|
+
for await (const channelId of parameters.channelIds) {
|
|
120
|
+
batch.push(channelId);
|
|
121
|
+
if (batch.length < batchSize)
|
|
122
|
+
continue;
|
|
123
|
+
await superviseBatch(batch);
|
|
124
|
+
batch = [];
|
|
125
|
+
}
|
|
126
|
+
if (batch.length > 0)
|
|
127
|
+
await superviseBatch(batch);
|
|
128
|
+
supervision.complete = supervision.failed === 0;
|
|
129
|
+
return supervision;
|
|
130
|
+
}
|
|
131
|
+
/** Applies one pinned escrow read of a supervised channel. */
|
|
132
|
+
async function superviseChannel(parameters, store, channel, state, blocks) {
|
|
133
|
+
const { channelId } = channel;
|
|
134
|
+
if (state.deposit === 0n) {
|
|
135
|
+
// A complete supervision read every listed channel that is still supervised, so a channel read
|
|
136
|
+
// open lost its deposit after the later of that read and the previous complete supervision. A
|
|
137
|
+
// channel no supervision has read open was listed after the previous complete one.
|
|
138
|
+
const since = channel.closeSupervisedFromBlock;
|
|
139
|
+
const { previous } = blocks;
|
|
140
|
+
const margin = parameters.closeSearchMarginBlocks ?? defaultCloseSearchMarginBlocks;
|
|
141
|
+
const fromBlock = since === undefined
|
|
142
|
+
? (previous ?? blocks.pinned.number) - margin
|
|
143
|
+
: previous !== undefined && previous > since
|
|
144
|
+
? previous
|
|
145
|
+
: since;
|
|
146
|
+
const paid = await findSettledToPayee(parameters, channel, {
|
|
147
|
+
fromBlock: fromBlock > 0n ? fromBlock : 0n,
|
|
148
|
+
toBlock: blocks.pinned.number,
|
|
149
|
+
});
|
|
150
|
+
if (paid === undefined) {
|
|
151
|
+
// An open landing after the pinned block also reads as no deposit; a later run reads it.
|
|
152
|
+
if (since === undefined)
|
|
153
|
+
return { outcome: 'open' };
|
|
154
|
+
throw new VerificationFailedError({
|
|
155
|
+
reason: 'channel left the escrow without a ChannelClosed event in its search range',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const finalized = await store.updateChannel(channelId, (current) => current && !current.finalized
|
|
159
|
+
? {
|
|
160
|
+
...current,
|
|
161
|
+
closeRequestedAt: 0n,
|
|
162
|
+
deposit: 0n,
|
|
163
|
+
finalized: true,
|
|
164
|
+
settledOnChain: ChannelStore.keepGreater(current.settledOnChain, paid),
|
|
165
|
+
}
|
|
166
|
+
: current);
|
|
167
|
+
return { outcome: paid >= (finalized?.spent ?? channel.spent) ? 'closed' : 'unrecovered' };
|
|
168
|
+
}
|
|
169
|
+
if (channel.closeSupervisedFromBlock === undefined)
|
|
170
|
+
await store.updateChannel(channelId, (current) => current && current.closeSupervisedFromBlock === undefined
|
|
171
|
+
? { ...current, closeSupervisedFromBlock: blocks.pinned.number }
|
|
172
|
+
: current);
|
|
173
|
+
if (state.closeRequestedAt === 0) {
|
|
174
|
+
if (channel.closeRequestedAt === 0n)
|
|
175
|
+
return { outcome: 'open' };
|
|
176
|
+
// A pinned read without a request means the payer cancelled it, unless the record holds a
|
|
177
|
+
// server close that may still land: those expire before they are cleared.
|
|
178
|
+
const cleared = {};
|
|
179
|
+
await store.updateChannel(channelId, (current) => {
|
|
180
|
+
cleared.channel = ChannelStore.reconcileExpiredPendingClose({
|
|
181
|
+
current,
|
|
182
|
+
now: blocks.pinned.timestamp,
|
|
183
|
+
state,
|
|
184
|
+
});
|
|
185
|
+
return cleared.channel;
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
outcome: cleared.channel && cleared.channel.closeRequestedAt === 0n && !cleared.channel.finalized
|
|
189
|
+
? 'cancelled'
|
|
190
|
+
: 'open',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// A pending close stops charges and credits, so the spend this close captures stays fixed.
|
|
194
|
+
const closing = await store.updateChannel(channelId, (current) => current && !current.finalized
|
|
195
|
+
? { ...current, ...ChannelStore.mergeActiveOnChainState(current, state) }
|
|
196
|
+
: current);
|
|
197
|
+
if (!closing || closing.finalized || !ChannelStore.isPrecompileState(closing))
|
|
198
|
+
return { outcome: 'finalized' };
|
|
199
|
+
return closeAtSpend(parameters, store, closing, state);
|
|
200
|
+
}
|
|
201
|
+
/** Sends a payee close capturing the channel's spend and finalizes the channel. */
|
|
202
|
+
async function closeAtSpend(parameters, store, channel, state) {
|
|
203
|
+
const { client } = parameters;
|
|
204
|
+
const { channelId } = channel;
|
|
205
|
+
try {
|
|
206
|
+
const voucher = channel.highestVoucher;
|
|
207
|
+
if (!voucher)
|
|
208
|
+
throw new VerificationFailedError({ reason: 'no voucher to close with' });
|
|
209
|
+
const capture = ChannelStore.resolveCloseCaptureAmount({
|
|
210
|
+
cumulativeAmount: voucher.cumulativeAmount,
|
|
211
|
+
onChainDeposit: state.deposit,
|
|
212
|
+
onChainSettled: state.settled,
|
|
213
|
+
spent: channel.spent,
|
|
214
|
+
});
|
|
215
|
+
if (capture <= state.settled)
|
|
216
|
+
return { outcome: 'closing' };
|
|
217
|
+
const account = parameters.account ?? getClientAccount(client);
|
|
218
|
+
assertSettlementSender({
|
|
219
|
+
operation: 'close',
|
|
220
|
+
channelId,
|
|
221
|
+
operator: channel.operator,
|
|
222
|
+
payee: channel.payee,
|
|
223
|
+
sender: account?.address,
|
|
224
|
+
});
|
|
225
|
+
const txHash = await Chain.closeOnChain(client, channel.descriptor, voucher.cumulativeAmount, capture, voucher.signature, channel.escrowContract, account
|
|
226
|
+
? {
|
|
227
|
+
account,
|
|
228
|
+
candidateFeeTokens: parameters.candidateFeeTokens ?? [channel.token],
|
|
229
|
+
...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
|
|
230
|
+
...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
|
|
231
|
+
...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
|
|
232
|
+
}
|
|
233
|
+
: undefined);
|
|
234
|
+
const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
|
|
235
|
+
const { refundedToPayer, settledToPayee } = Chain.readChannelClosedReceiptFields(Chain.getChannelEvent(receipt, 'ChannelClosed', channelId));
|
|
236
|
+
if (settledToPayee > capture || settledToPayee + refundedToPayer > state.deposit)
|
|
237
|
+
throw new VerificationFailedError({ reason: 'ChannelClosed amounts do not match state' });
|
|
238
|
+
await store.updateChannel(channelId, (current) => ChannelStore.finalizeClosedChannelState({
|
|
239
|
+
captureAmount: settledToPayee,
|
|
240
|
+
channelId,
|
|
241
|
+
cumulativeAmount: voucher.cumulativeAmount,
|
|
242
|
+
current,
|
|
243
|
+
signature: voucher.signature,
|
|
244
|
+
}));
|
|
245
|
+
if (parameters.onSessionSettlement) {
|
|
246
|
+
try {
|
|
247
|
+
await parameters.onSessionSettlement(Object.freeze({
|
|
248
|
+
amount: settledToPayee,
|
|
249
|
+
channelId,
|
|
250
|
+
delta: settledToPayee - state.settled,
|
|
251
|
+
trigger: 'close',
|
|
252
|
+
txHash,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Errors are isolated: observers cannot break the close.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { outcome: 'closed', txHash };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
await reportSessionSettlementFailure(parameters.onSessionSettlementFailure, {
|
|
263
|
+
chainId: channel.chainId,
|
|
264
|
+
channelId,
|
|
265
|
+
error,
|
|
266
|
+
trigger: 'close',
|
|
267
|
+
});
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Amount paid to the payee when the channel closed within the blocks, if it did. */
|
|
272
|
+
async function findSettledToPayee(parameters, channel, range) {
|
|
273
|
+
const step = parameters.logBlockRange ?? defaultLogBlockRange;
|
|
274
|
+
for (let from = range.fromBlock; from <= range.toBlock; from += step) {
|
|
275
|
+
const to = from + step - 1n < range.toBlock ? from + step - 1n : range.toBlock;
|
|
276
|
+
const logs = await getLogs(parameters.client, {
|
|
277
|
+
address: channel.escrowContract,
|
|
278
|
+
args: { channelId: channel.channelId },
|
|
279
|
+
event: channelClosedEvent,
|
|
280
|
+
fromBlock: from,
|
|
281
|
+
toBlock: to,
|
|
282
|
+
});
|
|
283
|
+
const paid = logs.find((log) => !log.removed)?.args.settledToPayee;
|
|
284
|
+
if (paid !== undefined)
|
|
285
|
+
return paid;
|
|
286
|
+
}
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
//# sourceMappingURL=CloseSupervisor.js.map
|