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.
@@ -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 charge(store: ChannelStore.ChannelStore, channelId: Hex, amount: bigint): Promise<ChannelStore.State>;
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: context.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 charge(store, channelId, amount) {
530
- return chargeSessionChannel({ store, channelId, amount });
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, or channels with no unsettled
116
- * voucher amount.
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
- /** Settles the highest accepted voucher for a precompile-backed session channel. */
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, or channels with no unsettled
71
- * voucher amount.
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
- if (!channel.highestVoucher)
77
- return undefined;
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
- /** Settles the highest accepted voucher for a precompile-backed session channel. */
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
- const amount = uint96(channel.highestVoucher.cumulativeAmount);
322
- const { signature } = channel.highestVoucher;
323
- // A replica behind the open or top-up that admitted this voucher rejects the settle while
324
- // preparing it (e.g. `ChannelNotFound`); nothing was broadcast, so retry until it catches up.
325
- const txHash = await Chain.readbackWithRetry(() => Chain.settleOnChain(client, channel.descriptor, amount, signature, escrow, account
326
- ? {
327
- account,
328
- ...(options?.feePayer ? { feePayer: options.feePayer } : {}),
329
- ...(options?.feePayerPolicy ? { feePayerPolicy: options.feePayerPolicy } : {}),
330
- ...(options?.feeToken ? { feeToken: options.feeToken } : {}),
331
- candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token],
332
- }
333
- : undefined), { shouldRetry: isExecutionRevert });
334
- const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
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) => current
348
- ? {
349
- ...current,
350
- settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain,
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 read are not covered by this transaction.
353
- lastSettlementSpent: ChannelStore.keepGreater(current.lastSettlementSpent ?? 0n, channel.spent),
354
- lastSettlementUnits: Math.max(current.lastSettlementUnits ?? 0, channel.units),
355
- }
356
- : current)
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
  });