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.
- package/CHANGELOG.md +41 -0
- package/dist/Credential.d.ts +3 -0
- package/dist/Credential.js +9 -0
- package/dist/cli/cli.js +131 -131
- package/dist/cli/plugins/evm.js +2 -2
- package/dist/cli/plugins/index.js +11 -11
- package/dist/client/internal/MethodChallenge.d.ts +2 -0
- package/dist/client/internal/MethodChallenge.js +4 -0
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/server/Mppx.js +31 -5
- package/dist/stripe/server/Methods.js +4 -1
- package/dist/tempo/client/Charge.js +1 -0
- package/dist/tempo/internal/auto-swap.d.ts +10 -0
- package/dist/tempo/internal/auto-swap.js +35 -8
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.js +20 -12
- package/dist/tempo/legacy/session/Chain.js +8 -2
- package/dist/tempo/server/Methods.d.ts +7 -2
- package/dist/tempo/server/Methods.js +20 -2
- package/dist/tempo/server/Relay.js +6 -22
- package/dist/tempo/server/Subscription.js +3 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/transport.d.ts +3 -0
- package/dist/tempo/server/internal/transport.js +5 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/client/ChannelStore.js +47 -0
- package/dist/tempo/session/client/CredentialState.js +8 -3
- package/dist/tempo/session/client/Session.js +50 -33
- package/dist/tempo/session/client/SessionManager.js +186 -65
- package/dist/tempo/session/client/Transports.d.ts +6 -0
- package/dist/tempo/session/client/Transports.js +52 -7
- package/dist/tempo/session/precompile/Chain.d.ts +2 -2
- package/dist/tempo/session/precompile/Chain.js +8 -4
- package/dist/tempo/session/server/ChannelStore.d.ts +186 -3
- package/dist/tempo/session/server/ChannelStore.js +312 -12
- package/dist/tempo/session/server/CloseSupervisor.d.ts +98 -0
- package/dist/tempo/session/server/CloseSupervisor.js +289 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
- package/dist/tempo/session/server/CredentialVerification.js +220 -48
- package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
- package/dist/tempo/session/server/MeteredStream.js +10 -26
- package/dist/tempo/session/server/Session.d.ts +70 -4
- package/dist/tempo/session/server/Session.js +156 -36
- package/dist/tempo/session/server/Settlement.d.ts +18 -4
- package/dist/tempo/session/server/Settlement.js +119 -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/Sse.d.ts +4 -3
- package/dist/tempo/session/server/Sse.js +3 -3
- package/dist/tempo/session/server/Ws.d.ts +9 -1
- package/dist/tempo/session/server/Ws.js +1 -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
|
@@ -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 =
|
|
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 {
|
|
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
|
|
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:
|
|
399
|
-
settled:
|
|
400
|
-
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
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
+
* Commit a charge before performing billable work.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* charge is
|
|
12
|
-
*
|
|
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. */
|