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
@@ -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
@@ -85,6 +85,12 @@ export type BroadcastCredentialPayloadParameters = {
85
85
  requireVoucherAdvance?: boolean | undefined;
86
86
  /** Server-side channel store. */
87
87
  store: ChannelStore.ChannelStore;
88
+ /** Maximum age of verified on-chain state a voucher may be accepted against. See `session.Parameters`. @default 0 */
89
+ voucherStateMaxAge?: number | undefined;
90
+ /** Request price a voucher credential pays in the same store update that accepts it. */
91
+ chargeAmount?: bigint | undefined;
92
+ /** Called with the stored channel after `chargeAmount` was charged. */
93
+ onCharged?: ((channel: ChannelStore.State) => void) | undefined;
88
94
  };
89
95
  /** @deprecated Use {@link BroadcastCredentialPayloadParameters}. */
90
96
  export type VerifyCredentialPayloadParameters = BroadcastCredentialPayloadParameters;
@@ -106,7 +112,7 @@ export type VoucherCredentialActionParameters = BroadcastCredentialActionParamet
106
112
  /** Inputs for broadcasting and settling a cooperative close credential. */
107
113
  export type CloseCredentialActionParameters = BroadcastCredentialActionParameters<'close'>;
108
114
  /** Inputs for validating a session credential without applying its state transition. */
109
- export type ValidateCredentialPayloadParameters = Pick<BroadcastCredentialPayloadParameters, 'account' | 'channelStateTtl' | 'chainId' | 'client' | 'credentialSource' | 'escrow' | 'expectedOperator' | 'feePayer' | 'feePayerPolicy' | 'feeToken' | 'lastOnChainVerified' | 'minVoucherDelta' | 'payload' | 'store' | 'challenge'> & {
115
+ export type ValidateCredentialPayloadParameters = Pick<BroadcastCredentialPayloadParameters, 'account' | 'channelStateTtl' | 'chainId' | 'client' | 'credentialSource' | 'escrow' | 'expectedOperator' | 'feePayer' | 'feePayerPolicy' | 'feeToken' | 'lastOnChainVerified' | 'minVoucherDelta' | 'payload' | 'store' | 'challenge' | 'voucherStateMaxAge'> & {
110
116
  /** Allows terminal broadcast to recover accepted funds before rejecting a pending close. */
111
117
  allowPendingClose?: boolean | undefined;
112
118
  };
@@ -368,14 +368,22 @@ async function validateVoucherCredential(parameters, payload) {
368
368
  assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource });
369
369
  if (channel.finalized)
370
370
  throw new ChannelClosedError({ reason: 'channel is finalized' });
371
- const channelState = await resolveVoucherChannelState({
371
+ const channelState = canUseStoredChannelState({
372
372
  channel,
373
373
  channelId,
374
- channelStateTtl,
375
- client,
376
- escrow,
377
374
  lastOnChainVerified,
378
- });
375
+ voucher,
376
+ voucherStateMaxAge: parameters.voucherStateMaxAge ?? 0,
377
+ })
378
+ ? storedChannelState(channel)
379
+ : await resolveVoucherChannelState({
380
+ channel,
381
+ channelId,
382
+ channelStateTtl,
383
+ client,
384
+ escrow,
385
+ lastOnChainVerified,
386
+ });
379
387
  if (parameters.allowPendingClose && channelState.closeRequestedAt !== 0)
380
388
  return;
381
389
  await ChannelStore.validateVoucher({
@@ -387,17 +395,36 @@ async function validateVoucherCredential(parameters, payload) {
387
395
  });
388
396
  }
389
397
  async function resolveVoucherChannelState(parameters) {
390
- const { channel, channelId, channelStateTtl, client, escrow, forceRefresh, lastOnChainVerified } = parameters;
398
+ const { channelId, channelStateTtl, client, escrow, forceRefresh, lastOnChainVerified } = parameters;
391
399
  const shouldRefresh = forceRefresh || Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl;
392
400
  const state = shouldRefresh ? await Chain.getChannelState(client, channelId, escrow) : undefined;
393
- if (state?.closeRequestedAt)
401
+ if (state && (state.closeRequestedAt !== 0 || state.deposit === 0n))
394
402
  lastOnChainVerified.delete(channelId);
395
403
  else if (state)
396
404
  lastOnChainVerified.set(channelId, Date.now());
405
+ if (state)
406
+ return state;
407
+ const { channel } = parameters;
408
+ return storedChannelState(typeof channel === 'function' ? await channel() : channel);
409
+ }
410
+ /**
411
+ * Whether a voucher may be checked against the stored channel instead of waiting for a chain read.
412
+ * A payer can withdraw only after the escrow's close grace period, so a voucher within the deposit
413
+ * of recently verified open state stays settleable while a fresh read finishes.
414
+ */
415
+ function canUseStoredChannelState(parameters) {
416
+ const { channel, channelId, lastOnChainVerified, voucher, voucherStateMaxAge } = parameters;
417
+ const verifiedAt = Math.max(lastOnChainVerified.get(channelId) ?? 0, channel.onChainVerifiedAt ?? 0);
418
+ return (voucherStateMaxAge > 0 &&
419
+ channel.closeRequestedAt === 0n &&
420
+ Date.now() - verifiedAt <= voucherStateMaxAge &&
421
+ voucher.cumulativeAmount <= channel.deposit);
422
+ }
423
+ function storedChannelState(channel) {
397
424
  return {
398
- deposit: state?.deposit ?? uint96(channel.deposit),
399
- settled: state?.settled ?? uint96(channel.settledOnChain),
400
- closeRequestedAt: state?.closeRequestedAt ?? Number(channel.closeRequestedAt),
425
+ deposit: uint96(channel.deposit),
426
+ settled: uint96(channel.settledOnChain),
427
+ closeRequestedAt: Number(channel.closeRequestedAt),
401
428
  };
402
429
  }
403
430
  async function validateCloseCredential(parameters, payload) {
@@ -602,26 +629,81 @@ async function handleVoucherCredential(parameters) {
602
629
  const voucher = Voucher.parseVoucherFromPayload(channelId, payload.cumulativeAmount, payload.signature);
603
630
  assertDescriptor(payload);
604
631
  validateChannelDescriptor(payload.descriptor, channelId, chainId, escrow, request.recipient, request.currency, expectedOperator);
605
- const channel = await ChannelStore.loadPrecompileChannel({
606
- descriptor: payload.descriptor,
607
- channelId,
608
- chainId,
609
- escrow,
610
- store,
611
- validateDescriptor: true,
612
- });
632
+ const voucherStateMaxAge = parameters.voucherStateMaxAge ?? 0;
633
+ const { chargeAmount, onCharged } = parameters;
634
+ let loadingChannel;
635
+ const loadChannel = () => {
636
+ loadingChannel ??= ChannelStore.loadPrecompileChannel({
637
+ descriptor: payload.descriptor,
638
+ channelId,
639
+ chainId,
640
+ escrow,
641
+ store,
642
+ validateDescriptor: true,
643
+ });
644
+ return loadingChannel;
645
+ };
646
+ // The on-chain read does not depend on the stored channel, so it overlaps the store read.
647
+ // Store errors still take precedence because the stored channel is awaited first.
648
+ let reading;
649
+ const readChannelState = () => {
650
+ if (reading)
651
+ return reading;
652
+ const startedAt = Date.now();
653
+ const state = resolveVoucherChannelState({
654
+ channel: loadChannel,
655
+ channelId,
656
+ channelStateTtl,
657
+ client,
658
+ escrow,
659
+ forceRefresh: true,
660
+ lastOnChainVerified,
661
+ });
662
+ state.catch(() => { });
663
+ reading = { startedAt, state };
664
+ return reading;
665
+ };
666
+ const recentlyRead = voucherStateMaxAge > 0 &&
667
+ Date.now() - (lastOnChainVerified.get(channelId) ?? 0) <=
668
+ Math.min(channelStateTtl, voucherStateMaxAge);
669
+ if (!recentlyRead)
670
+ readChannelState();
671
+ if (voucherStateMaxAge > 0 && store.updateChannelResult) {
672
+ const receipt = await acceptVoucherInOneUpdate(parameters, voucher, payload.descriptor);
673
+ if (receipt) {
674
+ if (reading)
675
+ recordOnChainReadInBackground(parameters, channelId, reading);
676
+ return receipt;
677
+ }
678
+ }
679
+ const channel = await loadChannel();
613
680
  assertCredentialSourceCanSpend({ chainId, channel, source: credentialSource });
614
681
  if (channel.finalized)
615
682
  throw new ChannelClosedError({ reason: 'channel is finalized' });
616
- const channelState = await resolveVoucherChannelState({
683
+ if (canUseStoredChannelState({
617
684
  channel,
618
685
  channelId,
619
- channelStateTtl,
620
- client,
621
- escrow,
622
- forceRefresh: true,
623
686
  lastOnChainVerified,
624
- });
687
+ voucher,
688
+ voucherStateMaxAge,
689
+ })) {
690
+ if (reading)
691
+ recordOnChainReadInBackground(parameters, channelId, reading);
692
+ return ChannelStore.verifyAndAcceptVoucher({
693
+ store,
694
+ minVoucherDelta,
695
+ requireAdvance: parameters.requireVoucherAdvance,
696
+ challenge,
697
+ channel,
698
+ voucher,
699
+ channelState: storedChannelState(channel),
700
+ methodDetails: { chainId, escrowContract: escrow },
701
+ chargeAmount,
702
+ onCharged,
703
+ });
704
+ }
705
+ const { startedAt, state } = readChannelState();
706
+ const channelState = await state;
625
707
  const reconciled = channel.closeRequestedAt === 0n
626
708
  ? channel
627
709
  : ((await store.updateChannel(channelId, (current) => ChannelStore.reconcileExpiredPendingClose({
@@ -630,30 +712,7 @@ async function handleVoucherCredential(parameters) {
630
712
  state: channelState,
631
713
  }))) ?? channel);
632
714
  if (channelState.closeRequestedAt !== 0) {
633
- const closing = await store.updateChannel(channelId, (current) => current
634
- ? {
635
- ...current,
636
- settledOnChain: channelState.settled > current.settledOnChain
637
- ? channelState.settled
638
- : current.settledOnChain,
639
- closeRequestedAt: BigInt(channelState.closeRequestedAt) > current.closeRequestedAt
640
- ? BigInt(channelState.closeRequestedAt)
641
- : current.closeRequestedAt,
642
- }
643
- : current);
644
- if (closing && !closing.finalized && channelState.deposit > 0n)
645
- await maybeSettleScheduled({
646
- account: parameters.account,
647
- channel: closing,
648
- client,
649
- feePayer: parameters.feePayer,
650
- feePayerPolicy: parameters.feePayerPolicy,
651
- feeToken: parameters.feeToken,
652
- onSessionSettlement: parameters.onSessionSettlement,
653
- onSessionSettlementFailure: parameters.onSessionSettlementFailure,
654
- schedule: {},
655
- store,
656
- });
715
+ await recordPendingClose(parameters, channelId, channelState);
657
716
  throw new ChannelClosedError({ reason: 'channel has a pending close request' });
658
717
  }
659
718
  return ChannelStore.verifyAndAcceptVoucher({
@@ -665,8 +724,121 @@ async function handleVoucherCredential(parameters) {
665
724
  voucher,
666
725
  channelState,
667
726
  methodDetails: { chainId, escrowContract: escrow },
727
+ verifiedAt: startedAt,
728
+ chargeAmount,
729
+ onCharged,
668
730
  });
669
731
  }
732
+ /**
733
+ * Accepts a voucher, and charges its request, in one store update with no separate read: the
734
+ * update checks the stored channel itself. Returns `undefined` without writing when the stored
735
+ * on-chain state was not verified recently enough to accept against.
736
+ */
737
+ async function acceptVoucherInOneUpdate(parameters, voucher, descriptor) {
738
+ const { challenge, chainId, chargeAmount, credentialSource, escrow, lastOnChainVerified, minVoucherDelta, requireVoucherAdvance, store, } = parameters;
739
+ const voucherStateMaxAge = parameters.voucherStateMaxAge ?? 0;
740
+ const valid = await Voucher.verifyVoucher(escrow, chainId, voucher, authorizedSigner(descriptor));
741
+ if (!valid)
742
+ throw new InvalidSignatureError({ reason: 'invalid voucher signature' });
743
+ const outcome = await store.updateChannelResult(voucher.channelId, (current) => {
744
+ if (!current)
745
+ throw new ChannelNotFoundError({ reason: 'channel not found' });
746
+ if (!ChannelStore.isPrecompileState(current))
747
+ throw new VerificationFailedError({ reason: 'channel is not precompile-backed' });
748
+ assertSameDescriptor(descriptor, current.descriptor);
749
+ validateChannelDescriptor(descriptor, voucher.channelId, chainId, escrow, current.payee, current.token);
750
+ assertCredentialSourceCanSpend({ chainId, channel: current, source: credentialSource });
751
+ if (current.finalized)
752
+ throw new ChannelClosedError({ reason: 'channel is finalized' });
753
+ if (!canUseStoredChannelState({
754
+ channel: current,
755
+ channelId: voucher.channelId,
756
+ lastOnChainVerified,
757
+ voucher,
758
+ voucherStateMaxAge,
759
+ }))
760
+ return { op: 'noop', result: undefined };
761
+ if (requireVoucherAdvance && voucher.cumulativeAmount <= current.highestVoucherAmount)
762
+ throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
763
+ const channelState = storedChannelState(current);
764
+ const { alreadyAccepted } = ChannelStore.assertVoucherAmounts({
765
+ channel: current,
766
+ channelState,
767
+ minVoucherDelta,
768
+ voucher,
769
+ });
770
+ if (alreadyAccepted && chargeAmount === undefined)
771
+ return { op: 'noop', result: { channel: current, charged: false } };
772
+ const acceptance = ChannelStore.planVoucherAcceptance({
773
+ accepted: ChannelStore.acceptVoucherStateUpdate({ channelState, current, voucher }),
774
+ chargeAmount,
775
+ });
776
+ return { op: 'set', value: acceptance.channel, result: acceptance };
777
+ });
778
+ if (!outcome)
779
+ return undefined;
780
+ if (chargeAmount !== undefined) {
781
+ if (!outcome.charged)
782
+ throw ChannelStore.insufficientBalanceForCharge(outcome.channel, chargeAmount);
783
+ parameters.onCharged?.(outcome.channel);
784
+ }
785
+ return createSessionReceipt({
786
+ challengeId: challenge.id,
787
+ channelId: voucher.channelId,
788
+ acceptedCumulative: outcome.channel.highestVoucherAmount,
789
+ spent: outcome.channel.spent,
790
+ units: outcome.channel.units,
791
+ });
792
+ }
793
+ /** Persists a pending close seen on chain and settles the channel while the payee still can. */
794
+ async function recordPendingClose(parameters, channelId, channelState) {
795
+ const { client, store } = parameters;
796
+ const closing = await store.updateChannel(channelId, (current) => current
797
+ ? {
798
+ ...current,
799
+ settledOnChain: channelState.settled > current.settledOnChain
800
+ ? channelState.settled
801
+ : current.settledOnChain,
802
+ closeRequestedAt: BigInt(channelState.closeRequestedAt) > current.closeRequestedAt
803
+ ? BigInt(channelState.closeRequestedAt)
804
+ : current.closeRequestedAt,
805
+ }
806
+ : current);
807
+ if (closing && !closing.finalized && channelState.deposit > 0n)
808
+ await maybeSettleScheduled({
809
+ account: parameters.account,
810
+ channel: closing,
811
+ client,
812
+ feePayer: parameters.feePayer,
813
+ feePayerPolicy: parameters.feePayerPolicy,
814
+ feeToken: parameters.feeToken,
815
+ onSessionSettlement: parameters.onSessionSettlement,
816
+ onSessionSettlementFailure: parameters.onSessionSettlementFailure,
817
+ schedule: {},
818
+ store,
819
+ });
820
+ }
821
+ /**
822
+ * Records an on-chain read that a voucher did not wait for: a pending close is persisted so later
823
+ * vouchers are rejected, and open state extends the verified age of the stored channel.
824
+ */
825
+ function recordOnChainReadInBackground(parameters, channelId, read) {
826
+ read.state
827
+ .then(async (state) => {
828
+ if (state.closeRequestedAt !== 0)
829
+ return recordPendingClose(parameters, channelId, state);
830
+ if (state.deposit === 0n)
831
+ return;
832
+ await parameters.store.updateChannel(channelId, (current) => current && !current.finalized && current.closeRequestedAt === 0n
833
+ ? {
834
+ ...current,
835
+ ...ChannelStore.mergeActiveOnChainState(current, state),
836
+ onChainVerifiedAt: Math.max(read.startedAt, current.onChainVerifiedAt ?? 0),
837
+ }
838
+ : current);
839
+ })
840
+ .catch(() => { });
841
+ }
670
842
  const sendMethods = new Set([
671
843
  'eth_sendRawTransaction',
672
844
  'eth_sendRawTransactionSync',
@@ -5,12 +5,11 @@ import * as ChannelStore from './ChannelStore.js';
5
5
  /** Controller passed to manual-charge streaming generators. */
6
6
  export type SessionController = {
7
7
  /**
8
- * Reserve voucher coverage for the next emitted chunk.
8
+ * Commit a charge before performing billable work.
9
9
  *
10
- * The reservation blocks until sufficient voucher headroom exists, but the
11
- * charge is committed when a chunk is emitted or when the generator finishes
12
- * successfully. A reservation is only dropped when the stream aborts or the
13
- * generator fails before the charge can be committed.
10
+ * Waits for sufficient voucher coverage and commits the charge before resolving.
11
+ * Once resolved, the charge is retained even if the stream is canceled or the
12
+ * generator fails. Await this method before starting the corresponding work.
14
13
  *
15
14
  * Pass an explicit raw-unit `amount` for request-aware or otherwise dynamic
16
15
  * pricing. When omitted, the session challenge's configured tick cost is
@@ -32,6 +31,8 @@ export type MeteredStreamOptions = {
32
31
  formatNeedVoucher(parameters: NeedVoucherEvent): string;
33
32
  /** Async source or manual-charge source. */
34
33
  generate: SessionStreamGenerator;
34
+ /** Verifies that the channel can still fund work and delivery; failures stop the stream. */
35
+ beforeCharge?: (() => MaybePromise<void>) | undefined;
35
36
  /** Runs after a nonzero reserved charge has been committed. */
36
37
  onChargeCommitted?: ((channel: ChannelStore.State) => MaybePromise<unknown>) | undefined;
37
38
  /** Store polling interval when `waitForUpdate` is unavailable. */