mppx 0.13.5 → 0.13.6
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 +27 -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.js +12 -1
- 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.js +5 -2
- package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
- package/dist/tempo/session/server/ChannelStore.js +69 -9
- 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 +29 -2
- package/dist/tempo/session/server/Session.js +114 -33
- package/dist/tempo/session/server/Settlement.d.ts +9 -0
- package/dist/tempo/session/server/Settlement.js +12 -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/package.json +1 -1
|
@@ -298,12 +298,22 @@ export async function readProblemDetail(response) {
|
|
|
298
298
|
return body;
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
|
+
/** A dispatched deposit may have executed despite a failed or cancelled response. */
|
|
302
|
+
export class UncertainFundingError extends Error {
|
|
303
|
+
constructor(reason) {
|
|
304
|
+
const detail = reason instanceof Error || reason instanceof DOMException ? reason.message : String(reason);
|
|
305
|
+
super(`${detail}; payment outcome unknown`);
|
|
306
|
+
this.name = 'UncertainFundingError';
|
|
307
|
+
}
|
|
308
|
+
}
|
|
301
309
|
/** Posts a top-up management credential and returns its receipt, when present. */
|
|
302
310
|
export async function postTopUp(parameters) {
|
|
303
311
|
const { channel, channelId } = parameters;
|
|
304
312
|
if (!channel?.descriptor || channel.channelId !== channelId) {
|
|
305
313
|
throw new Error('Cannot top up session: no local channel descriptor available.');
|
|
306
314
|
}
|
|
315
|
+
if (channel.uncertainTopUp)
|
|
316
|
+
throw new Error('Session funding outcome is unknown; reconcile the pending payment before another top-up.');
|
|
307
317
|
const response = await postManagementCredential({
|
|
308
318
|
challenge: parameters.challenge,
|
|
309
319
|
context: {
|
|
@@ -316,6 +326,12 @@ export async function postTopUp(parameters) {
|
|
|
316
326
|
fetch: parameters.fetch,
|
|
317
327
|
input: parameters.input,
|
|
318
328
|
signal: parameters.signal,
|
|
329
|
+
}).catch(async (error) => {
|
|
330
|
+
if (error instanceof UncertainFundingError) {
|
|
331
|
+
channel.uncertainTopUp = true;
|
|
332
|
+
await parameters.onUncertainFunding?.(channel);
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
319
335
|
});
|
|
320
336
|
if (!response.ok)
|
|
321
337
|
throw new Error(`Top-up POST failed with status ${response.status}`);
|
|
@@ -324,13 +340,42 @@ export async function postTopUp(parameters) {
|
|
|
324
340
|
}
|
|
325
341
|
/** Posts a management credential and retries once with a refreshed TIP-1034 challenge. */
|
|
326
342
|
async function postManagementCredential(parameters) {
|
|
327
|
-
const post = async (challenge) =>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
343
|
+
const post = async (challenge) => {
|
|
344
|
+
parameters.signal?.throwIfAborted();
|
|
345
|
+
const authorization = await parameters.createSessionCredential(challenge, parameters.context);
|
|
346
|
+
parameters.signal?.throwIfAborted();
|
|
347
|
+
const controller = new AbortController();
|
|
348
|
+
const abort = () => controller.abort(parameters.signal?.reason);
|
|
349
|
+
parameters.signal?.addEventListener('abort', abort, { once: true });
|
|
350
|
+
const timeout = setTimeout(() => controller.abort(new DOMException('Session management request timed out; payment outcome unknown', 'TimeoutError')), 30_000);
|
|
351
|
+
let onAbort;
|
|
352
|
+
const aborted = new Promise((_, reject) => {
|
|
353
|
+
onAbort = () => reject(controller.signal.reason);
|
|
354
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
// Custom fetch implementations may ignore abort. Do not let their late
|
|
358
|
+
// response update funding state or trigger a refreshed-challenge retry.
|
|
359
|
+
return await Promise.race([
|
|
360
|
+
parameters.fetch(managementInput(parameters.input), {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
signal: controller.signal,
|
|
363
|
+
headers: { [Constants.Headers.authorization]: authorization },
|
|
364
|
+
}),
|
|
365
|
+
aborted,
|
|
366
|
+
]);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (parameters.context.action === 'topUp')
|
|
370
|
+
throw new UncertainFundingError(error);
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
clearTimeout(timeout);
|
|
375
|
+
parameters.signal?.removeEventListener('abort', abort);
|
|
376
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
334
379
|
const response = await post(parameters.challenge);
|
|
335
380
|
if (response.status !== 402)
|
|
336
381
|
return response;
|
|
@@ -263,8 +263,8 @@ function validateAutoSwapPrefix(parameters) {
|
|
|
263
263
|
fail('approval amount does not match swap maximum input');
|
|
264
264
|
if (!isAddressEqual(tokenOut, currency))
|
|
265
265
|
fail('output token does not match channel currency');
|
|
266
|
-
if (swapAmountOut
|
|
267
|
-
fail('output amount
|
|
266
|
+
if (swapAmountOut <= 0n || swapAmountOut > amountOut)
|
|
267
|
+
fail('output amount must be positive and not exceed channel deposit');
|
|
268
268
|
if (isAddressEqual(tokenIn, tokenOut))
|
|
269
269
|
fail('input and output tokens must differ');
|
|
270
270
|
const canonicalApprove = encodeFunctionData({
|
|
@@ -444,6 +444,7 @@ export async function sendCredentialTransaction(parameters) {
|
|
|
444
444
|
// execution as the sender. It intentionally omits fee fields here; the
|
|
445
445
|
// transport is responsible for validating its final sponsored envelope.
|
|
446
446
|
await simulateTempoTransaction(client, FeePayer.simulationTransaction(transaction, { feePayer: true }));
|
|
447
|
+
FeePayer.assertRemainingValidity({ transaction, details });
|
|
447
448
|
const txHash = await sendTransaction(client, serializedTransaction);
|
|
448
449
|
return waitForSuccessfulReceipt(client, txHash);
|
|
449
450
|
}
|
|
@@ -467,7 +468,9 @@ export async function sendCredentialTransaction(parameters) {
|
|
|
467
468
|
return { feePayer: feePayer.address, transaction: sponsored };
|
|
468
469
|
},
|
|
469
470
|
});
|
|
471
|
+
FeePayer.assertRemainingValidity({ transaction: completed.transaction, details });
|
|
470
472
|
const serialized = await signTempoTransaction(client, completed.transaction);
|
|
473
|
+
FeePayer.assertRemainingValidity({ transaction: completed.transaction, details });
|
|
471
474
|
const receipt = await sendRawTransactionSync(client, {
|
|
472
475
|
serializedTransaction: serialized,
|
|
473
476
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Address, Hex } from 'viem';
|
|
2
2
|
import type * as Challenge from '../../../Challenge.js';
|
|
3
|
+
import { InsufficientBalanceError } from '../../../Errors.js';
|
|
3
4
|
import type * as Store from '../../../Store.js';
|
|
4
5
|
import type * as Chain from '../precompile/Chain.js';
|
|
5
6
|
import type * as PrecompileChannel from '../precompile/Channel.js';
|
|
@@ -109,6 +110,8 @@ export type AcceptVoucherStateUpdateParameters = {
|
|
|
109
110
|
channelState: Chain.ChannelState;
|
|
110
111
|
/** Existing channel state being updated. */
|
|
111
112
|
current: State | null;
|
|
113
|
+
/** When `channelState` was read from chain; omitted when it came from the store. */
|
|
114
|
+
verifiedAt?: number | undefined;
|
|
112
115
|
/** Verified signed voucher. */
|
|
113
116
|
voucher: SessionSignedVoucher;
|
|
114
117
|
};
|
|
@@ -193,8 +196,14 @@ export type VerifyAndAcceptVoucherParameters = {
|
|
|
193
196
|
requireAdvance?: boolean | undefined;
|
|
194
197
|
/** Server channel store. */
|
|
195
198
|
store: ChannelStore;
|
|
199
|
+
/** When `channelState` was read from chain; omitted when it came from the store. */
|
|
200
|
+
verifiedAt?: number | undefined;
|
|
196
201
|
/** Signed cumulative voucher to verify and accept. */
|
|
197
202
|
voucher: SignedVoucher;
|
|
203
|
+
/** Request price to charge in the same store update that accepts the voucher. */
|
|
204
|
+
chargeAmount?: bigint | undefined;
|
|
205
|
+
/** Called with the stored channel after `chargeAmount` was charged. */
|
|
206
|
+
onCharged?: ((channel: State) => void) | undefined;
|
|
198
207
|
};
|
|
199
208
|
/** Inputs for validating a cumulative voucher without recording it. */
|
|
200
209
|
export type ValidateVoucherParameters = Omit<VerifyAndAcceptVoucherParameters, 'challenge' | 'store'>;
|
|
@@ -257,6 +266,8 @@ export interface BaseState {
|
|
|
257
266
|
token: Address;
|
|
258
267
|
/** Number of charge operations (API requests) fulfilled in the current session. */
|
|
259
268
|
units: number;
|
|
269
|
+
/** Unix milliseconds when the channel's on-chain deposit, settlement and close state were last read. */
|
|
270
|
+
onChainVerifiedAt?: number | undefined;
|
|
260
271
|
/** Uncommitted stream charges shared across workers. */
|
|
261
272
|
streamReservations?: Record<string, {
|
|
262
273
|
amount: bigint;
|
|
@@ -316,6 +327,29 @@ export declare function finalizeClosedChannelState(parameters: FinalizeClosedCha
|
|
|
316
327
|
export declare function loadPrecompileChannel(parameters: LoadPrecompileChannelParameters): Promise<StoredPrecompileChannel>;
|
|
317
328
|
/** Verifies a cumulative voucher without changing persisted channel state. */
|
|
318
329
|
export declare function validateVoucher(parameters: ValidateVoucherParameters): Promise<VoucherValidation>;
|
|
330
|
+
/**
|
|
331
|
+
* Checks a cumulative voucher's amounts against channel state, without its signature, so the
|
|
332
|
+
* check can run inside the store update that accepts it.
|
|
333
|
+
*/
|
|
334
|
+
export declare function assertVoucherAmounts(parameters: {
|
|
335
|
+
channel: Pick<State, 'highestVoucherAmount'>;
|
|
336
|
+
channelState: Chain.ChannelState;
|
|
337
|
+
minVoucherDelta: bigint;
|
|
338
|
+
voucher: Pick<SignedVoucher, 'cumulativeAmount'>;
|
|
339
|
+
}): VoucherValidation;
|
|
340
|
+
/**
|
|
341
|
+
* Charges `chargeAmount` to a channel state that just accepted a voucher, for the same store
|
|
342
|
+
* update. A charge the available balance cannot cover leaves the accepted state uncharged.
|
|
343
|
+
*/
|
|
344
|
+
export declare function planVoucherAcceptance(parameters: {
|
|
345
|
+
accepted: State;
|
|
346
|
+
chargeAmount?: bigint | undefined;
|
|
347
|
+
}): {
|
|
348
|
+
channel: State;
|
|
349
|
+
charged: boolean;
|
|
350
|
+
};
|
|
351
|
+
/** Reports a request charge that the channel's available balance could not cover. */
|
|
352
|
+
export declare function insufficientBalanceForCharge(channel: State, chargeAmount: bigint): InsufficientBalanceError;
|
|
319
353
|
/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
|
|
320
354
|
export declare function verifyAndAcceptVoucher(parameters: VerifyAndAcceptVoucherParameters): Promise<SessionReceipt>;
|
|
321
355
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AmountExceedsDepositError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js';
|
|
1
|
+
import { AmountExceedsDepositError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InsufficientBalanceError, InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js';
|
|
2
2
|
import { createSessionReceipt } from '../precompile/Protocol.js';
|
|
3
3
|
import { uint96 } from '../precompile/Protocol.js';
|
|
4
4
|
import * as Voucher from '../precompile/Voucher.js';
|
|
@@ -100,14 +100,19 @@ export function topUpChannelState(parameters) {
|
|
|
100
100
|
}
|
|
101
101
|
/** Merges an accepted voucher into persisted channel state after signature and on-chain checks pass. */
|
|
102
102
|
export function acceptVoucherStateUpdate(parameters) {
|
|
103
|
-
const { channelState, current, voucher } = parameters;
|
|
103
|
+
const { channelState, current, verifiedAt, voucher } = parameters;
|
|
104
104
|
if (!current)
|
|
105
105
|
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
106
106
|
if (current.finalized)
|
|
107
107
|
throw new ChannelClosedError({ reason: 'channel is finalized' });
|
|
108
108
|
if (current.closeRequestedAt !== 0n)
|
|
109
109
|
throw new ChannelClosedError({ reason: 'channel has a pending close request' });
|
|
110
|
-
const onChain =
|
|
110
|
+
const onChain = {
|
|
111
|
+
...mergeActiveOnChainState(current, channelState),
|
|
112
|
+
...(verifiedAt === undefined
|
|
113
|
+
? {}
|
|
114
|
+
: { onChainVerifiedAt: Math.max(verifiedAt, current.onChainVerifiedAt ?? 0) }),
|
|
115
|
+
};
|
|
111
116
|
if (voucher.cumulativeAmount <= current.highestVoucherAmount) {
|
|
112
117
|
return { ...current, ...onChain };
|
|
113
118
|
}
|
|
@@ -239,13 +244,58 @@ export async function validateVoucher(parameters) {
|
|
|
239
244
|
});
|
|
240
245
|
return { alreadyAccepted: false };
|
|
241
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Checks a cumulative voucher's amounts against channel state, without its signature, so the
|
|
249
|
+
* check can run inside the store update that accepts it.
|
|
250
|
+
*/
|
|
251
|
+
export function assertVoucherAmounts(parameters) {
|
|
252
|
+
const { channel, channelState, minVoucherDelta, voucher } = parameters;
|
|
253
|
+
validateChannelState(channelState);
|
|
254
|
+
if (voucher.cumulativeAmount > channelState.deposit)
|
|
255
|
+
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' });
|
|
256
|
+
if (voucher.cumulativeAmount < channel.highestVoucherAmount)
|
|
257
|
+
throw new VerificationFailedError({
|
|
258
|
+
reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
|
|
259
|
+
});
|
|
260
|
+
if (voucher.cumulativeAmount <= channelState.settled)
|
|
261
|
+
throw new VerificationFailedError({
|
|
262
|
+
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
263
|
+
});
|
|
264
|
+
if (voucher.cumulativeAmount === channel.highestVoucherAmount)
|
|
265
|
+
return { alreadyAccepted: true };
|
|
266
|
+
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount;
|
|
267
|
+
if (delta < minVoucherDelta)
|
|
268
|
+
throw new DeltaTooSmallError({
|
|
269
|
+
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
|
|
270
|
+
});
|
|
271
|
+
return { alreadyAccepted: false };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Charges `chargeAmount` to a channel state that just accepted a voucher, for the same store
|
|
275
|
+
* update. A charge the available balance cannot cover leaves the accepted state uncharged.
|
|
276
|
+
*/
|
|
277
|
+
export function planVoucherAcceptance(parameters) {
|
|
278
|
+
const { accepted, chargeAmount } = parameters;
|
|
279
|
+
if (chargeAmount === undefined)
|
|
280
|
+
return { channel: accepted, charged: false };
|
|
281
|
+
const deduction = planDeduction(accepted, chargeAmount);
|
|
282
|
+
if (deduction.op !== 'set')
|
|
283
|
+
return { channel: accepted, charged: false };
|
|
284
|
+
return { channel: deduction.value, charged: true };
|
|
285
|
+
}
|
|
286
|
+
/** Reports a request charge that the channel's available balance could not cover. */
|
|
287
|
+
export function insufficientBalanceForCharge(channel, chargeAmount) {
|
|
288
|
+
return new InsufficientBalanceError({
|
|
289
|
+
reason: `requested ${chargeAmount}, available ${channel.highestVoucherAmount - channel.spent - reservedStreamAmount(channel)}`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
242
292
|
/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
|
|
243
293
|
export async function verifyAndAcceptVoucher(parameters) {
|
|
244
|
-
const { store, challenge, channel, voucher, channelState, requireAdvance } = parameters;
|
|
294
|
+
const { store, challenge, channel, voucher, channelState, requireAdvance, verifiedAt, chargeAmount, } = parameters;
|
|
245
295
|
const validation = await validateVoucher(parameters);
|
|
246
|
-
if (validation.alreadyAccepted)
|
|
247
|
-
|
|
248
|
-
|
|
296
|
+
if (validation.alreadyAccepted && requireAdvance)
|
|
297
|
+
throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
|
|
298
|
+
if (validation.alreadyAccepted && chargeAmount === undefined)
|
|
249
299
|
return createSessionReceipt({
|
|
250
300
|
challengeId: challenge.id,
|
|
251
301
|
channelId: voucher.channelId,
|
|
@@ -253,16 +303,26 @@ export async function verifyAndAcceptVoucher(parameters) {
|
|
|
253
303
|
spent: channel.spent,
|
|
254
304
|
units: channel.units,
|
|
255
305
|
});
|
|
256
|
-
|
|
306
|
+
let charged = false;
|
|
257
307
|
const updated = await store.updateChannel(voucher.channelId, (current) => {
|
|
258
308
|
if (requireAdvance &&
|
|
259
309
|
current !== null &&
|
|
260
310
|
voucher.cumulativeAmount <= current.highestVoucherAmount)
|
|
261
311
|
throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
|
|
262
|
-
|
|
312
|
+
const acceptance = planVoucherAcceptance({
|
|
313
|
+
accepted: acceptVoucherStateUpdate({ channelState, current, verifiedAt, voucher }),
|
|
314
|
+
chargeAmount,
|
|
315
|
+
});
|
|
316
|
+
charged = acceptance.charged;
|
|
317
|
+
return acceptance.channel;
|
|
263
318
|
});
|
|
264
319
|
if (!updated)
|
|
265
320
|
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
321
|
+
if (chargeAmount !== undefined) {
|
|
322
|
+
if (!charged)
|
|
323
|
+
throw insufficientBalanceForCharge(updated, chargeAmount);
|
|
324
|
+
parameters.onCharged?.(updated);
|
|
325
|
+
}
|
|
266
326
|
return createSessionReceipt({
|
|
267
327
|
challengeId: challenge.id,
|
|
268
328
|
channelId: voucher.channelId,
|
|
@@ -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',
|