mppx 0.13.6 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/cli/cli.js +1 -1
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/tempo/server/Methods.d.ts +7 -2
- package/dist/tempo/server/Methods.js +9 -2
- package/dist/tempo/session/precompile/Chain.d.ts +2 -2
- package/dist/tempo/session/precompile/Chain.js +3 -2
- package/dist/tempo/session/server/ChannelStore.d.ts +152 -3
- package/dist/tempo/session/server/ChannelStore.js +243 -3
- package/dist/tempo/session/server/CloseSupervisor.d.ts +110 -0
- package/dist/tempo/session/server/CloseSupervisor.js +310 -0
- package/dist/tempo/session/server/Session.d.ts +41 -2
- package/dist/tempo/session/server/Session.js +43 -4
- package/dist/tempo/session/server/Settlement.d.ts +9 -4
- package/dist/tempo/session/server/Settlement.js +107 -32
- package/dist/tempo/session/server/SponsorshipLimit.d.ts +52 -0
- package/dist/tempo/session/server/SponsorshipLimit.js +107 -0
- package/dist/tempo/session/server/index.d.ts +12 -2
- package/dist/tempo/session/server/index.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
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, maybeSettleScheduled, 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
|
+
settled: 0,
|
|
49
|
+
supervised: 0,
|
|
50
|
+
timestamp: pinned.timestamp,
|
|
51
|
+
unrecovered: 0,
|
|
52
|
+
};
|
|
53
|
+
const report = (channelId, outcome, details = {}) => {
|
|
54
|
+
supervision.supervised += 1;
|
|
55
|
+
if (outcome === 'cancelled' ||
|
|
56
|
+
outcome === 'closed' ||
|
|
57
|
+
outcome === 'failed' ||
|
|
58
|
+
outcome === 'settled' ||
|
|
59
|
+
outcome === 'unrecovered')
|
|
60
|
+
supervision[outcome] += 1;
|
|
61
|
+
supervision.channels.push({ channelId, outcome, ...details });
|
|
62
|
+
};
|
|
63
|
+
const superviseBatch = async (channelIds) => {
|
|
64
|
+
const live = [];
|
|
65
|
+
for (const channelId of channelIds) {
|
|
66
|
+
let channel;
|
|
67
|
+
try {
|
|
68
|
+
channel = await store.getChannel(channelId);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
report(channelId, 'failed', { error });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!channel || !ChannelStore.isPrecompileState(channel))
|
|
75
|
+
report(channelId, 'missing');
|
|
76
|
+
else if (channel.finalized)
|
|
77
|
+
report(channelId, 'finalized');
|
|
78
|
+
else if (client.chain && client.chain.id !== channel.chainId)
|
|
79
|
+
report(channelId, 'failed', {
|
|
80
|
+
error: new Error(`Channel is on chain ${channel.chainId}, not ${client.chain.id}.`),
|
|
81
|
+
});
|
|
82
|
+
else
|
|
83
|
+
live.push(channel);
|
|
84
|
+
}
|
|
85
|
+
const byEscrow = new Map();
|
|
86
|
+
for (const channel of live) {
|
|
87
|
+
const escrow = channel.escrowContract.toLowerCase();
|
|
88
|
+
byEscrow.set(escrow, [...(byEscrow.get(escrow) ?? []), channel]);
|
|
89
|
+
}
|
|
90
|
+
for (const members of byEscrow.values()) {
|
|
91
|
+
let states;
|
|
92
|
+
try {
|
|
93
|
+
states = await Chain.getChannelStatesBatch(client, members.map(({ channelId }) => channelId), members[0].escrowContract, pinned.number);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
for (const { channelId } of members)
|
|
97
|
+
report(channelId, 'failed', { error });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
for (const [index, channel] of members.entries()) {
|
|
101
|
+
const state = states[index];
|
|
102
|
+
if (!state) {
|
|
103
|
+
report(channel.channelId, 'failed', { error: new Error('Escrow state missing.') });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const { outcome, txHash } = await superviseChannel(parameters, store, channel, state, {
|
|
108
|
+
pinned,
|
|
109
|
+
previous: parameters.previousBlock,
|
|
110
|
+
});
|
|
111
|
+
report(channel.channelId, outcome, { txHash });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// A close that landed before this failure is found by its close event next time.
|
|
115
|
+
report(channel.channelId, 'failed', { error });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
let batch = [];
|
|
121
|
+
for await (const channelId of parameters.channelIds) {
|
|
122
|
+
batch.push(channelId);
|
|
123
|
+
if (batch.length < batchSize)
|
|
124
|
+
continue;
|
|
125
|
+
await superviseBatch(batch);
|
|
126
|
+
batch = [];
|
|
127
|
+
}
|
|
128
|
+
if (batch.length > 0)
|
|
129
|
+
await superviseBatch(batch);
|
|
130
|
+
supervision.complete = supervision.failed === 0;
|
|
131
|
+
return supervision;
|
|
132
|
+
}
|
|
133
|
+
/** Applies one pinned escrow read of a supervised channel. */
|
|
134
|
+
async function superviseChannel(parameters, store, channel, state, blocks) {
|
|
135
|
+
const { channelId } = channel;
|
|
136
|
+
if (state.deposit === 0n) {
|
|
137
|
+
// A complete supervision read every listed channel that is still supervised, so a channel read
|
|
138
|
+
// open lost its deposit after the later of that read and the previous complete supervision. A
|
|
139
|
+
// channel no supervision has read open was listed after the previous complete one.
|
|
140
|
+
const since = channel.closeSupervisedFromBlock;
|
|
141
|
+
const { previous } = blocks;
|
|
142
|
+
const margin = parameters.closeSearchMarginBlocks ?? defaultCloseSearchMarginBlocks;
|
|
143
|
+
const fromBlock = since === undefined
|
|
144
|
+
? (previous ?? blocks.pinned.number) - margin
|
|
145
|
+
: previous !== undefined && previous > since
|
|
146
|
+
? previous
|
|
147
|
+
: since;
|
|
148
|
+
const paid = await findSettledToPayee(parameters, channel, {
|
|
149
|
+
fromBlock: fromBlock > 0n ? fromBlock : 0n,
|
|
150
|
+
toBlock: blocks.pinned.number,
|
|
151
|
+
});
|
|
152
|
+
if (paid === undefined) {
|
|
153
|
+
// An open landing after the pinned block also reads as no deposit; a later run reads it.
|
|
154
|
+
if (since === undefined)
|
|
155
|
+
return { outcome: 'open' };
|
|
156
|
+
throw new VerificationFailedError({
|
|
157
|
+
reason: 'channel left the escrow without a ChannelClosed event in its search range',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const finalized = await store.updateChannel(channelId, (current) => current && !current.finalized
|
|
161
|
+
? {
|
|
162
|
+
...current,
|
|
163
|
+
closeRequestedAt: 0n,
|
|
164
|
+
deposit: 0n,
|
|
165
|
+
finalized: true,
|
|
166
|
+
settledOnChain: ChannelStore.keepGreater(current.settledOnChain, paid),
|
|
167
|
+
}
|
|
168
|
+
: current);
|
|
169
|
+
return { outcome: paid >= (finalized?.spent ?? channel.spent) ? 'closed' : 'unrecovered' };
|
|
170
|
+
}
|
|
171
|
+
if (channel.closeSupervisedFromBlock === undefined)
|
|
172
|
+
await store.updateChannel(channelId, (current) => current && current.closeSupervisedFromBlock === undefined
|
|
173
|
+
? { ...current, closeSupervisedFromBlock: blocks.pinned.number }
|
|
174
|
+
: current);
|
|
175
|
+
if (state.closeRequestedAt === 0) {
|
|
176
|
+
if (channel.closeRequestedAt === 0n)
|
|
177
|
+
return settleDue(parameters, store, channel);
|
|
178
|
+
// A pinned read without a request means the payer cancelled it, unless the record holds a
|
|
179
|
+
// server close that may still land: those expire before they are cleared.
|
|
180
|
+
const cleared = {};
|
|
181
|
+
await store.updateChannel(channelId, (current) => {
|
|
182
|
+
cleared.channel = ChannelStore.reconcileExpiredPendingClose({
|
|
183
|
+
current,
|
|
184
|
+
now: blocks.pinned.timestamp,
|
|
185
|
+
state,
|
|
186
|
+
});
|
|
187
|
+
return cleared.channel;
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
outcome: cleared.channel && cleared.channel.closeRequestedAt === 0n && !cleared.channel.finalized
|
|
191
|
+
? 'cancelled'
|
|
192
|
+
: 'open',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// A pending close stops charges and credits, so the spend this close captures stays fixed.
|
|
196
|
+
const closing = await store.updateChannel(channelId, (current) => current && !current.finalized
|
|
197
|
+
? { ...current, ...ChannelStore.mergeActiveOnChainState(current, state) }
|
|
198
|
+
: current);
|
|
199
|
+
if (!closing || closing.finalized || !ChannelStore.isPrecompileState(closing))
|
|
200
|
+
return { outcome: 'finalized' };
|
|
201
|
+
return closeAtSpend(parameters, store, closing, state);
|
|
202
|
+
}
|
|
203
|
+
/** Settles an open channel when its spend crossed the settlement schedule. */
|
|
204
|
+
async function settleDue(parameters, store, channel) {
|
|
205
|
+
const { client, settlementSchedule } = parameters;
|
|
206
|
+
if (!settlementSchedule)
|
|
207
|
+
return { outcome: 'open' };
|
|
208
|
+
const txHash = await maybeSettleScheduled({
|
|
209
|
+
account: parameters.account ?? getClientAccount(client),
|
|
210
|
+
channel,
|
|
211
|
+
client,
|
|
212
|
+
...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
|
|
213
|
+
...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
|
|
214
|
+
...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
|
|
215
|
+
onSessionSettlement: parameters.onSessionSettlement,
|
|
216
|
+
onSessionSettlementFailure: parameters.onSessionSettlementFailure,
|
|
217
|
+
schedule: settlementSchedule,
|
|
218
|
+
store,
|
|
219
|
+
});
|
|
220
|
+
return txHash ? { outcome: 'settled', txHash } : { outcome: 'open' };
|
|
221
|
+
}
|
|
222
|
+
/** Sends a payee close capturing the channel's spend and finalizes the channel. */
|
|
223
|
+
async function closeAtSpend(parameters, store, channel, state) {
|
|
224
|
+
const { client } = parameters;
|
|
225
|
+
const { channelId } = channel;
|
|
226
|
+
try {
|
|
227
|
+
const voucher = channel.highestVoucher;
|
|
228
|
+
if (!voucher)
|
|
229
|
+
throw new VerificationFailedError({ reason: 'no voucher to close with' });
|
|
230
|
+
const capture = ChannelStore.resolveCloseCaptureAmount({
|
|
231
|
+
cumulativeAmount: voucher.cumulativeAmount,
|
|
232
|
+
onChainDeposit: state.deposit,
|
|
233
|
+
onChainSettled: state.settled,
|
|
234
|
+
spent: channel.spent,
|
|
235
|
+
});
|
|
236
|
+
if (capture <= state.settled)
|
|
237
|
+
return { outcome: 'closing' };
|
|
238
|
+
const account = parameters.account ?? getClientAccount(client);
|
|
239
|
+
assertSettlementSender({
|
|
240
|
+
operation: 'close',
|
|
241
|
+
channelId,
|
|
242
|
+
operator: channel.operator,
|
|
243
|
+
payee: channel.payee,
|
|
244
|
+
sender: account?.address,
|
|
245
|
+
});
|
|
246
|
+
const txHash = await Chain.closeOnChain(client, channel.descriptor, voucher.cumulativeAmount, capture, voucher.signature, channel.escrowContract, account
|
|
247
|
+
? {
|
|
248
|
+
account,
|
|
249
|
+
candidateFeeTokens: parameters.candidateFeeTokens ?? [channel.token],
|
|
250
|
+
...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
|
|
251
|
+
...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
|
|
252
|
+
...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
|
|
253
|
+
}
|
|
254
|
+
: undefined);
|
|
255
|
+
const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
|
|
256
|
+
const { refundedToPayer, settledToPayee } = Chain.readChannelClosedReceiptFields(Chain.getChannelEvent(receipt, 'ChannelClosed', channelId));
|
|
257
|
+
if (settledToPayee > capture || settledToPayee + refundedToPayer > state.deposit)
|
|
258
|
+
throw new VerificationFailedError({ reason: 'ChannelClosed amounts do not match state' });
|
|
259
|
+
await store.updateChannel(channelId, (current) => ChannelStore.finalizeClosedChannelState({
|
|
260
|
+
captureAmount: settledToPayee,
|
|
261
|
+
channelId,
|
|
262
|
+
cumulativeAmount: voucher.cumulativeAmount,
|
|
263
|
+
current,
|
|
264
|
+
signature: voucher.signature,
|
|
265
|
+
}));
|
|
266
|
+
if (parameters.onSessionSettlement) {
|
|
267
|
+
try {
|
|
268
|
+
await parameters.onSessionSettlement(Object.freeze({
|
|
269
|
+
amount: settledToPayee,
|
|
270
|
+
channelId,
|
|
271
|
+
delta: settledToPayee - state.settled,
|
|
272
|
+
trigger: 'close',
|
|
273
|
+
txHash,
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Errors are isolated: observers cannot break the close.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { outcome: 'closed', txHash };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
await reportSessionSettlementFailure(parameters.onSessionSettlementFailure, {
|
|
284
|
+
chainId: channel.chainId,
|
|
285
|
+
channelId,
|
|
286
|
+
error,
|
|
287
|
+
trigger: 'close',
|
|
288
|
+
});
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Amount paid to the payee when the channel closed within the blocks, if it did. */
|
|
293
|
+
async function findSettledToPayee(parameters, channel, range) {
|
|
294
|
+
const step = parameters.logBlockRange ?? defaultLogBlockRange;
|
|
295
|
+
for (let from = range.fromBlock; from <= range.toBlock; from += step) {
|
|
296
|
+
const to = from + step - 1n < range.toBlock ? from + step - 1n : range.toBlock;
|
|
297
|
+
const logs = await getLogs(parameters.client, {
|
|
298
|
+
address: channel.escrowContract,
|
|
299
|
+
args: { channelId: channel.channelId },
|
|
300
|
+
event: channelClosedEvent,
|
|
301
|
+
fromBlock: from,
|
|
302
|
+
toBlock: to,
|
|
303
|
+
});
|
|
304
|
+
const paid = logs.find((log) => !log.removed)?.args.settledToPayee;
|
|
305
|
+
if (paid !== undefined)
|
|
306
|
+
return paid;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=CloseSupervisor.js.map
|
|
@@ -18,8 +18,9 @@ import * as Transport from '../../server/internal/transport.js';
|
|
|
18
18
|
import { deserializeSnapshot as deserializeSessionSnapshot, serializeSnapshot as serializeSessionSnapshot } from '../Snapshot.js';
|
|
19
19
|
import * as ChannelStore from './ChannelStore.js';
|
|
20
20
|
import { type ResolveSessionChannelId } from './RequestState.js';
|
|
21
|
-
import { type SettleChargedSessionChannel } from './Settlement.js';
|
|
21
|
+
import { type SessionStoreInput, type SettleChargedSessionChannel } from './Settlement.js';
|
|
22
22
|
import { type OnSessionSettlement, type OnSessionSettlementFailure, type SettlementSchedule } from './Settlement.js';
|
|
23
|
+
import * as SponsorshipLimit from './SponsorshipLimit.js';
|
|
23
24
|
import * as Ws from './Ws.js';
|
|
24
25
|
/** @internal Reuses a method's escrow verifier when composing shared currency helpers. */
|
|
25
26
|
export declare function verifyStream(method: object, channelId: Hex): Promise<void>;
|
|
@@ -208,6 +209,14 @@ export declare namespace session {
|
|
|
208
209
|
settlementSchedule?: SettlementSchedule | undefined;
|
|
209
210
|
/** Optional fee token for management and server-driven settle/close transactions. */
|
|
210
211
|
feeToken?: Address | undefined;
|
|
212
|
+
/**
|
|
213
|
+
* Limits fee-payer-sponsored channel opens and top-ups to `perDay` of each per payer per UTC
|
|
214
|
+
* day (default 5), counted in `store`. The payer is the verified signer of the transaction, never
|
|
215
|
+
* the credential's client-supplied `source`. A transaction over the limit, or one whose
|
|
216
|
+
* signatures prove no payer, is broadcast unsponsored, so it lands only if the payer pays its
|
|
217
|
+
* own fees.
|
|
218
|
+
*/
|
|
219
|
+
sponsorshipLimit?: SponsorshipLimit.Parameters | undefined;
|
|
211
220
|
} & Account.resolve.Parameters & Client.getResolver.Parameters & Defaults & Method.ComposableHooks<typeof Methods.session>;
|
|
212
221
|
/** Defaults derived from `session()` parameters for handler type inference. */
|
|
213
222
|
type DeriveDefaults<parameters extends Parameters> = types.DeriveDefaults<parameters, Defaults, {
|
|
@@ -223,6 +232,36 @@ export declare namespace session {
|
|
|
223
232
|
*
|
|
224
233
|
* Delegates to the shared `deductFromChannel` atomic helper and translates
|
|
225
234
|
* failure modes into typed errors (`InsufficientBalanceError`, `ChannelClosedError`).
|
|
235
|
+
*
|
|
236
|
+
* With `options.idempotencyKey` the channel is charged at most once per key: a retry with the same
|
|
237
|
+
* key and amount returns the channel without charging again, and the same key with a different
|
|
238
|
+
* amount throws `IdempotencyKeyConflictError`.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* // One durable job per voucher, retried under its operation ID.
|
|
243
|
+
* const channel = await Session.Server.charge(store, channelId, jobCost, {
|
|
244
|
+
* idempotencyKey: operationId,
|
|
245
|
+
* })
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export declare function charge(store: SessionStoreInput, channelId: Hex, amount: bigint, options?: ChannelStore.ChargeOptions): Promise<ChannelStore.State>;
|
|
249
|
+
/**
|
|
250
|
+
* Returns an unused amount to a channel's `spent` at most once per idempotency key.
|
|
251
|
+
*
|
|
252
|
+
* The first outcome for a key is final. A credit is `declined` when the channel is closing or
|
|
253
|
+
* finalized, or when it would take `spent` below zero or below what settled, or may still settle,
|
|
254
|
+
* on-chain, because a close captures `max(spent, settled)`; the caller then refunds another way.
|
|
255
|
+
* `unknown` reports a store write that failed and may have committed; retrying with the same key
|
|
256
|
+
* returns the outcome that was recorded.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* const result = await Session.Server.credit(store, channelId, unused, {
|
|
261
|
+
* idempotencyKey: `refund:${flowId}`,
|
|
262
|
+
* })
|
|
263
|
+
* if (result.status === 'declined') await refundAnotherWay()
|
|
264
|
+
* ```
|
|
226
265
|
*/
|
|
227
|
-
export declare function
|
|
266
|
+
export declare function credit(store: SessionStoreInput, channelId: Hex, amount: bigint, options: ChannelStore.CreditOptions): Promise<ChannelStore.CreditResult>;
|
|
228
267
|
//# sourceMappingURL=Session.d.ts.map
|
|
@@ -28,9 +28,10 @@ import { broadcastCredentialPayload, validateCredentialPayload } from './Credent
|
|
|
28
28
|
import { requireSessionCredentialPayload, validateChannelState } from './CredentialVerification.js';
|
|
29
29
|
import { resolveCredentialVerificationContext, resolveSessionChannelId, resolveSessionSnapshot, resolveSessionPaymentRequest, } from './RequestState.js';
|
|
30
30
|
import { respondToSessionCredential } from './RequestState.js';
|
|
31
|
-
import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, shouldChargeVoucherOnAcceptance, } from './Settlement.js';
|
|
31
|
+
import { applyVerifiedHttpAccounting, chargeSessionChannel, resolveChannelStore, shouldApplyVerifiedHttpAccounting, shouldChargeVoucherOnAcceptance, } from './Settlement.js';
|
|
32
32
|
import { ignoreRetryableSettlementFailure, isSettlementDue, maybeSettleScheduled, reportSessionSettlementFailure, } from './Settlement.js';
|
|
33
33
|
import { resolveSettlementSchedule, } from './Settlement.js';
|
|
34
|
+
import * as SponsorshipLimit from './SponsorshipLimit.js';
|
|
34
35
|
import * as Ws from './Ws.js';
|
|
35
36
|
const streamVerifiers = new WeakMap();
|
|
36
37
|
/** @internal Reuses a method's escrow verifier when composing shared currency helpers. */
|
|
@@ -218,6 +219,7 @@ export function session(p) {
|
|
|
218
219
|
const parameters = p;
|
|
219
220
|
const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = fallbackStore('tempo.session'), suggestedDeposit, unitType, } = parameters;
|
|
220
221
|
const settlementSchedule = resolveSettlementSchedule(parameters.settlementSchedule, decimals);
|
|
222
|
+
SponsorshipLimit.assertParameters(parameters.sponsorshipLimit);
|
|
221
223
|
const onSessionSettlement = parameters.onSessionSettlement;
|
|
222
224
|
const onSessionSettlementFailure = parameters.onSessionSettlementFailure;
|
|
223
225
|
const store = ChannelStore.fromStore(rawStore);
|
|
@@ -395,7 +397,12 @@ export function session(p) {
|
|
|
395
397
|
credentialSource: credential.source,
|
|
396
398
|
escrow: context.escrow,
|
|
397
399
|
expectedOperator: context.methodDetails.operator,
|
|
398
|
-
feePayer:
|
|
400
|
+
feePayer: await SponsorshipLimit.limitFeePayer({
|
|
401
|
+
chainId: context.chainId,
|
|
402
|
+
feePayer: context.feePayer,
|
|
403
|
+
limit: parameters.sponsorshipLimit,
|
|
404
|
+
payload,
|
|
405
|
+
}),
|
|
399
406
|
feePayerPolicy: parameters.feePayerPolicy,
|
|
400
407
|
feeToken: context.methodDetails.feeToken,
|
|
401
408
|
lastOnChainVerified,
|
|
@@ -525,8 +532,40 @@ export function session(p) {
|
|
|
525
532
|
*
|
|
526
533
|
* Delegates to the shared `deductFromChannel` atomic helper and translates
|
|
527
534
|
* failure modes into typed errors (`InsufficientBalanceError`, `ChannelClosedError`).
|
|
535
|
+
*
|
|
536
|
+
* With `options.idempotencyKey` the channel is charged at most once per key: a retry with the same
|
|
537
|
+
* key and amount returns the channel without charging again, and the same key with a different
|
|
538
|
+
* amount throws `IdempotencyKeyConflictError`.
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* ```ts
|
|
542
|
+
* // One durable job per voucher, retried under its operation ID.
|
|
543
|
+
* const channel = await Session.Server.charge(store, channelId, jobCost, {
|
|
544
|
+
* idempotencyKey: operationId,
|
|
545
|
+
* })
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
export async function charge(store, channelId, amount, options) {
|
|
549
|
+
return chargeSessionChannel({ ...options, store: resolveChannelStore(store), channelId, amount });
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Returns an unused amount to a channel's `spent` at most once per idempotency key.
|
|
553
|
+
*
|
|
554
|
+
* The first outcome for a key is final. A credit is `declined` when the channel is closing or
|
|
555
|
+
* finalized, or when it would take `spent` below zero or below what settled, or may still settle,
|
|
556
|
+
* on-chain, because a close captures `max(spent, settled)`; the caller then refunds another way.
|
|
557
|
+
* `unknown` reports a store write that failed and may have committed; retrying with the same key
|
|
558
|
+
* returns the outcome that was recorded.
|
|
559
|
+
*
|
|
560
|
+
* @example
|
|
561
|
+
* ```ts
|
|
562
|
+
* const result = await Session.Server.credit(store, channelId, unused, {
|
|
563
|
+
* idempotencyKey: `refund:${flowId}`,
|
|
564
|
+
* })
|
|
565
|
+
* if (result.status === 'declined') await refundAnotherWay()
|
|
566
|
+
* ```
|
|
528
567
|
*/
|
|
529
|
-
export async function
|
|
530
|
-
return
|
|
568
|
+
export async function credit(store, channelId, amount, options) {
|
|
569
|
+
return ChannelStore.creditChannel(resolveChannelStore(store), channelId, amount, options);
|
|
531
570
|
}
|
|
532
571
|
//# sourceMappingURL=Session.js.map
|
|
@@ -112,8 +112,8 @@ export declare function resolveSettlementSchedule(schedule: SettlementSchedule |
|
|
|
112
112
|
* Computes the schedule progress for an unsettled precompile-backed channel.
|
|
113
113
|
*
|
|
114
114
|
* Returns `undefined` for channels that cannot be scheduled: non-precompile
|
|
115
|
-
* records, channels without an accepted voucher
|
|
116
|
-
*
|
|
115
|
+
* records, or channels without an accepted voucher above the settled amount and
|
|
116
|
+
* within `spent` (see {@link ChannelStore.resolveSettleableVoucher}).
|
|
117
117
|
*/
|
|
118
118
|
export declare function resolveSettlementProgress(channel: ChannelStore.State): SettlementProgress | undefined;
|
|
119
119
|
/** Returns whether the precompile channel has crossed any configured settlement threshold. */
|
|
@@ -145,7 +145,7 @@ export type ChargeSessionChannel = (channelId: Hex, amount: bigint) => Promise<C
|
|
|
145
145
|
/** Callback used by post-verification accounting to run server-owned settlement policy. */
|
|
146
146
|
export type SettleChargedSessionChannel = (channel: ChannelStore.State) => Promise<Hex | undefined>;
|
|
147
147
|
/** Inputs for charging a precompile-backed session channel. */
|
|
148
|
-
export type ChargeParameters = {
|
|
148
|
+
export type ChargeParameters = ChannelStore.ChargeOptions & {
|
|
149
149
|
/** Server-side channel store. */
|
|
150
150
|
store: ChannelStore.ChannelStore;
|
|
151
151
|
/** Channel ID to deduct from. */
|
|
@@ -247,7 +247,12 @@ export declare function getClientAccount(client: {
|
|
|
247
247
|
export declare function assertSettlementSender(parameters: SettlementSenderParameters): void;
|
|
248
248
|
/** Applies automatic settlement when the server-owned schedule is due. */
|
|
249
249
|
export declare function maybeSettleScheduled(parameters: MaybeSettleScheduledParameters): Promise<Hex | undefined>;
|
|
250
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Settles the largest accepted voucher within the channel's `spent` for a precompile-backed session
|
|
252
|
+
* channel (see {@link ChannelStore.resolveSettleableVoucher}). The escrow captures the voucher's
|
|
253
|
+
* whole cumulative amount, so a voucher above `spent`, such as the highest one after a credit, is
|
|
254
|
+
* never settled. Until the transaction can no longer land, credits keep `spent` at or above it.
|
|
255
|
+
*/
|
|
251
256
|
export declare function settle(store_: SessionStoreInput, client: Chain.TransactionClient, channelId_: Hex, options?: SettlementTransactionOptions): Promise<Hex>;
|
|
252
257
|
/** Raised when a settlement transaction confirmed but the channel store could not record it. */
|
|
253
258
|
export declare class SettlementCheckpointError extends Error {
|
|
@@ -67,15 +67,14 @@ export function resolveSettlementSchedule(schedule, decimals) {
|
|
|
67
67
|
* Computes the schedule progress for an unsettled precompile-backed channel.
|
|
68
68
|
*
|
|
69
69
|
* Returns `undefined` for channels that cannot be scheduled: non-precompile
|
|
70
|
-
* records, channels without an accepted voucher
|
|
71
|
-
*
|
|
70
|
+
* records, or channels without an accepted voucher above the settled amount and
|
|
71
|
+
* within `spent` (see {@link ChannelStore.resolveSettleableVoucher}).
|
|
72
72
|
*/
|
|
73
73
|
export function resolveSettlementProgress(channel) {
|
|
74
74
|
if (!ChannelStore.isPrecompileState(channel))
|
|
75
75
|
return undefined;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (channel.highestVoucher.cumulativeAmount <= channel.settledOnChain)
|
|
76
|
+
// A settlement captures a voucher whole, so only a voucher within spend can be scheduled.
|
|
77
|
+
if (!ChannelStore.resolveSettleableVoucher(channel))
|
|
79
78
|
return undefined;
|
|
80
79
|
const amountBoundary = channel.lastSettlementSpent ?? channel.settledOnChain;
|
|
81
80
|
const timestampBoundary = Date.parse(channel.lastSettlementAt ?? channel.createdAt);
|
|
@@ -203,12 +202,18 @@ export async function applyVerifiedHttpAccounting(parameters) {
|
|
|
203
202
|
}
|
|
204
203
|
/** Atomically deducts spend from a channel and maps store failures to typed session errors. */
|
|
205
204
|
export async function chargeSessionChannel(parameters) {
|
|
206
|
-
const { store, channelId, amount } = parameters;
|
|
205
|
+
const { store, channelId, amount, idempotencyKey, retention } = parameters;
|
|
206
|
+
ChannelStore.assertIdempotencyOptions({ idempotencyKey, retention });
|
|
207
207
|
let result;
|
|
208
208
|
try {
|
|
209
|
-
result = await ChannelStore.deductFromChannel(store, channelId, amount
|
|
209
|
+
result = await ChannelStore.deductFromChannel(store, channelId, amount, {
|
|
210
|
+
idempotencyKey,
|
|
211
|
+
retention,
|
|
212
|
+
});
|
|
210
213
|
}
|
|
211
|
-
catch {
|
|
214
|
+
catch (error) {
|
|
215
|
+
if (error instanceof ChannelStore.IdempotencyKeyConflictError)
|
|
216
|
+
throw error;
|
|
212
217
|
throw new ChannelClosedError({ reason: 'channel not found' });
|
|
213
218
|
}
|
|
214
219
|
if (!result.ok) {
|
|
@@ -298,7 +303,12 @@ export async function maybeSettleScheduled(parameters) {
|
|
|
298
303
|
await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store }).catch(release);
|
|
299
304
|
return txHash;
|
|
300
305
|
}
|
|
301
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* Settles the largest accepted voucher within the channel's `spent` for a precompile-backed session
|
|
308
|
+
* channel (see {@link ChannelStore.resolveSettleableVoucher}). The escrow captures the voucher's
|
|
309
|
+
* whole cumulative amount, so a voucher above `spent`, such as the highest one after a credit, is
|
|
310
|
+
* never settled. Until the transaction can no longer land, credits keep `spent` at or above it.
|
|
311
|
+
*/
|
|
302
312
|
export async function settle(store_, client, channelId_, options) {
|
|
303
313
|
const store = resolveChannelStore(store_);
|
|
304
314
|
const channelId = ChannelStore.normalizeChannelId(channelId_);
|
|
@@ -318,20 +328,72 @@ export async function settle(store_, client, channelId_, options) {
|
|
|
318
328
|
payee: channel.payee,
|
|
319
329
|
sender: account?.address,
|
|
320
330
|
});
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
331
|
+
// Pin the transaction's expiry so the credit floor below can expire once it can no longer land.
|
|
332
|
+
const validBefore = Math.floor(Date.now() / 1000) + ChannelStore.pendingCloseValiditySeconds;
|
|
333
|
+
const pending = {
|
|
334
|
+
amount: 0n,
|
|
335
|
+
expiresAt: (validBefore + ChannelStore.pendingCloseClockSkewSeconds) * 1000,
|
|
336
|
+
};
|
|
337
|
+
const claim = {};
|
|
338
|
+
// Choosing the voucher and raising the credit floor in one update keeps a concurrent credit from
|
|
339
|
+
// lowering spent below what this transaction captures.
|
|
340
|
+
await store.updateChannel(channelId, (current) => {
|
|
341
|
+
claim.channel = current ?? undefined;
|
|
342
|
+
claim.voucher = (current && ChannelStore.resolveSettleableVoucher(current)) ?? undefined;
|
|
343
|
+
if (!current || !claim.voucher)
|
|
344
|
+
return current;
|
|
345
|
+
pending.amount = claim.voucher.cumulativeAmount;
|
|
346
|
+
const live = current.pendingSettlement && current.pendingSettlement.expiresAt > Date.now()
|
|
347
|
+
? current.pendingSettlement
|
|
348
|
+
: undefined;
|
|
349
|
+
return {
|
|
350
|
+
...current,
|
|
351
|
+
pendingSettlement: {
|
|
352
|
+
amount: ChannelStore.keepGreater(live?.amount ?? 0n, pending.amount),
|
|
353
|
+
expiresAt: Math.max(live?.expiresAt ?? 0, pending.expiresAt),
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
const { voucher } = claim;
|
|
358
|
+
if (!voucher || !claim.channel)
|
|
359
|
+
throw new VerificationFailedError({
|
|
360
|
+
reason: 'nothing to settle: no accepted voucher above the settled amount is within spent',
|
|
361
|
+
});
|
|
362
|
+
const claimed = claim.channel;
|
|
363
|
+
const amount = uint96(voucher.cumulativeAmount);
|
|
364
|
+
const { signature } = voucher;
|
|
365
|
+
// A revert proves the transaction will not land, so its credit floor can go at once.
|
|
366
|
+
const release = () => store
|
|
367
|
+
.updateChannel(channelId, (current) => {
|
|
368
|
+
if (current?.pendingSettlement?.amount !== pending.amount ||
|
|
369
|
+
current.pendingSettlement.expiresAt !== pending.expiresAt)
|
|
370
|
+
return current;
|
|
371
|
+
const { pendingSettlement: _, ...rest } = current;
|
|
372
|
+
return rest;
|
|
373
|
+
})
|
|
374
|
+
.catch(() => undefined);
|
|
375
|
+
let txHash;
|
|
376
|
+
let receipt;
|
|
377
|
+
try {
|
|
378
|
+
// A replica behind the open or top-up that admitted this voucher rejects the settle while
|
|
379
|
+
// preparing it (e.g. `ChannelNotFound`); nothing was broadcast, so retry until it catches up.
|
|
380
|
+
txHash = await Chain.readbackWithRetry(() => Chain.settleOnChain(client, channel.descriptor, amount, signature, escrow, account
|
|
381
|
+
? {
|
|
382
|
+
account,
|
|
383
|
+
...(options?.feePayer ? { feePayer: options.feePayer } : {}),
|
|
384
|
+
...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
|
|
385
|
+
...(options?.feeToken ? { feeToken: options.feeToken } : {}),
|
|
386
|
+
candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token],
|
|
387
|
+
validBefore,
|
|
388
|
+
}
|
|
389
|
+
: { validBefore }), { shouldRetry: isExecutionRevert });
|
|
390
|
+
receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (isExecutionRevert(error) || error instanceof VerificationFailedError)
|
|
394
|
+
await release();
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
335
397
|
const settled = readSettledReceiptFields(Chain.getChannelEvent(receipt, 'Settled', channelId));
|
|
336
398
|
const { deltaPaid, newSettled } = settled;
|
|
337
399
|
if (newSettled < amount)
|
|
@@ -344,16 +406,29 @@ export async function settle(store_, client, channelId_, options) {
|
|
|
344
406
|
});
|
|
345
407
|
let checkpointError;
|
|
346
408
|
await store
|
|
347
|
-
.updateChannel(channelId, (current) =>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
409
|
+
.updateChannel(channelId, (current) => {
|
|
410
|
+
if (!current)
|
|
411
|
+
return current;
|
|
412
|
+
const settledOnChain = ChannelStore.keepGreater(current.settledOnChain, newSettled);
|
|
413
|
+
const { pendingSettlement, ...rest } = current;
|
|
414
|
+
return {
|
|
415
|
+
...rest,
|
|
416
|
+
// The recorded settlement now keeps spent at or above what this transaction captured.
|
|
417
|
+
...(pendingSettlement && pendingSettlement.amount > settledOnChain
|
|
418
|
+
? { pendingSettlement }
|
|
419
|
+
: {}),
|
|
420
|
+
...(current.retainedVouchers
|
|
421
|
+
? {
|
|
422
|
+
retainedVouchers: current.retainedVouchers.filter((retained) => retained.cumulativeAmount > settledOnChain),
|
|
423
|
+
}
|
|
424
|
+
: {}),
|
|
425
|
+
settledOnChain,
|
|
351
426
|
lastSettlementAt: new Date().toISOString(),
|
|
352
|
-
// Charges accepted after the voucher was
|
|
353
|
-
lastSettlementSpent: ChannelStore.keepGreater(current.lastSettlementSpent ?? 0n,
|
|
354
|
-
lastSettlementUnits: Math.max(current.lastSettlementUnits ?? 0,
|
|
355
|
-
}
|
|
356
|
-
|
|
427
|
+
// Charges accepted after the voucher was chosen are not covered by this transaction.
|
|
428
|
+
lastSettlementSpent: ChannelStore.keepGreater(current.lastSettlementSpent ?? 0n, amount),
|
|
429
|
+
lastSettlementUnits: Math.max(current.lastSettlementUnits ?? 0, claimed.units),
|
|
430
|
+
};
|
|
431
|
+
})
|
|
357
432
|
.catch((cause) => {
|
|
358
433
|
checkpointError = { cause };
|
|
359
434
|
});
|