mppx 0.13.5 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/dist/Credential.d.ts +3 -0
- package/dist/Credential.js +9 -0
- package/dist/cli/cli.js +131 -131
- package/dist/cli/plugins/evm.js +2 -2
- package/dist/cli/plugins/index.js +11 -11
- package/dist/client/internal/MethodChallenge.d.ts +2 -0
- package/dist/client/internal/MethodChallenge.js +4 -0
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/server/Mppx.js +31 -5
- package/dist/stripe/server/Methods.js +4 -1
- package/dist/tempo/client/Charge.js +1 -0
- package/dist/tempo/internal/auto-swap.d.ts +10 -0
- package/dist/tempo/internal/auto-swap.js +35 -8
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.js +20 -12
- package/dist/tempo/legacy/session/Chain.js +8 -2
- package/dist/tempo/server/Methods.d.ts +7 -2
- package/dist/tempo/server/Methods.js +20 -2
- package/dist/tempo/server/Relay.js +6 -22
- package/dist/tempo/server/Subscription.js +3 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/transport.d.ts +3 -0
- package/dist/tempo/server/internal/transport.js +5 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/client/ChannelStore.js +47 -0
- package/dist/tempo/session/client/CredentialState.js +8 -3
- package/dist/tempo/session/client/Session.js +50 -33
- package/dist/tempo/session/client/SessionManager.js +186 -65
- package/dist/tempo/session/client/Transports.d.ts +6 -0
- package/dist/tempo/session/client/Transports.js +52 -7
- package/dist/tempo/session/precompile/Chain.d.ts +2 -2
- package/dist/tempo/session/precompile/Chain.js +8 -4
- package/dist/tempo/session/server/ChannelStore.d.ts +186 -3
- package/dist/tempo/session/server/ChannelStore.js +312 -12
- package/dist/tempo/session/server/CloseSupervisor.d.ts +98 -0
- package/dist/tempo/session/server/CloseSupervisor.js +289 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
- package/dist/tempo/session/server/CredentialVerification.js +220 -48
- package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
- package/dist/tempo/session/server/MeteredStream.js +10 -26
- package/dist/tempo/session/server/Session.d.ts +70 -4
- package/dist/tempo/session/server/Session.js +156 -36
- package/dist/tempo/session/server/Settlement.d.ts +18 -4
- package/dist/tempo/session/server/Settlement.js +119 -32
- package/dist/tempo/session/server/SponsorshipLimit.d.ts +52 -0
- package/dist/tempo/session/server/SponsorshipLimit.js +107 -0
- package/dist/tempo/session/server/Sse.d.ts +4 -3
- package/dist/tempo/session/server/Sse.js +3 -3
- package/dist/tempo/session/server/Ws.d.ts +9 -1
- package/dist/tempo/session/server/Ws.js +1 -0
- package/dist/tempo/session/server/index.d.ts +9 -1
- package/dist/tempo/session/server/index.js +5 -1
- package/package.json +1 -1
|
@@ -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';
|
|
@@ -82,6 +82,11 @@ export function openChannelState(parameters) {
|
|
|
82
82
|
settledOnChain: onChain.settledOnChain,
|
|
83
83
|
highestVoucherAmount: highestVoucher.highestVoucherAmount,
|
|
84
84
|
highestVoucher: highestVoucher.highestVoucher,
|
|
85
|
+
...(current && highestVoucher.highestVoucherAmount > current.highestVoucherAmount
|
|
86
|
+
? {
|
|
87
|
+
retainedVouchers: retainVouchers(current, highestVoucher.highestVoucherAmount, onChain.settledOnChain),
|
|
88
|
+
}
|
|
89
|
+
: {}),
|
|
85
90
|
spent: keepGreater(current?.spent ?? 0n, state.settled),
|
|
86
91
|
units: current?.units ?? 0,
|
|
87
92
|
finalized: current?.finalized ?? false,
|
|
@@ -98,16 +103,91 @@ export function topUpChannelState(parameters) {
|
|
|
98
103
|
...mergeActiveOnChainState(current, state),
|
|
99
104
|
};
|
|
100
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Number of accepted vouchers kept below the highest one. Clients sign every voucher above their
|
|
108
|
+
* last one, so once a credit lowers `spent` the highest voucher stays above it for good and only
|
|
109
|
+
* earlier vouchers can be settled; those fall at or below `spent` as later charges accumulate.
|
|
110
|
+
* Each costs about 200 bytes of the channel record. In a simulated 20,000-request channel crediting
|
|
111
|
+
* up to 80% of each request back, which left the highest voucher 80,000 units above `spent`,
|
|
112
|
+
* settling every 50 requests left on average 1,290 units unsettled with 16 retained vouchers and
|
|
113
|
+
* 784 with 32.
|
|
114
|
+
*/
|
|
115
|
+
export const maxRetainedVouchers = 16;
|
|
116
|
+
/**
|
|
117
|
+
* Returns the vouchers to keep once `highestVoucherAmount` replaces the highest voucher: the
|
|
118
|
+
* retained ones and the superseded highest voucher, above `settledOnChain`. Beyond
|
|
119
|
+
* {@link maxRetainedVouchers} it repeatedly drops the voucher whose neighbours are closest, so the
|
|
120
|
+
* kept ones stay spread over the unsettled range and some keep falling at or below `spent` however
|
|
121
|
+
* far credits have moved it below the highest voucher.
|
|
122
|
+
*/
|
|
123
|
+
export function retainVouchers(current, highestVoucherAmount, settledOnChain = current.settledOnChain) {
|
|
124
|
+
const candidates = [...(current.retainedVouchers ?? [])];
|
|
125
|
+
if (current.highestVoucher)
|
|
126
|
+
candidates.push({
|
|
127
|
+
cumulativeAmount: current.highestVoucher.cumulativeAmount,
|
|
128
|
+
signature: current.highestVoucher.signature,
|
|
129
|
+
});
|
|
130
|
+
const kept = candidates
|
|
131
|
+
.filter(({ cumulativeAmount }) => cumulativeAmount > settledOnChain && cumulativeAmount < highestVoucherAmount)
|
|
132
|
+
.sort((a, b) => a.cumulativeAmount < b.cumulativeAmount
|
|
133
|
+
? -1
|
|
134
|
+
: a.cumulativeAmount > b.cumulativeAmount
|
|
135
|
+
? 1
|
|
136
|
+
: 0);
|
|
137
|
+
while (kept.length > maxRetainedVouchers) {
|
|
138
|
+
let drop = 0;
|
|
139
|
+
let narrowest;
|
|
140
|
+
for (let index = 0; index < kept.length; index++) {
|
|
141
|
+
const below = index === 0 ? settledOnChain : kept[index - 1].cumulativeAmount;
|
|
142
|
+
const above = index === kept.length - 1 ? highestVoucherAmount : kept[index + 1].cumulativeAmount;
|
|
143
|
+
if (narrowest === undefined || above - below < narrowest) {
|
|
144
|
+
narrowest = above - below;
|
|
145
|
+
drop = index;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
kept.splice(drop, 1);
|
|
149
|
+
}
|
|
150
|
+
return kept;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Returns the largest accepted voucher a settlement may capture. The escrow's `settle` captures a
|
|
154
|
+
* voucher's whole cumulative amount, and a credit may leave the highest voucher above `spent`, so
|
|
155
|
+
* the voucher must be at most `spent`; one at or below `settledOnChain` captures nothing.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveSettleableVoucher(state) {
|
|
158
|
+
let settleable;
|
|
159
|
+
for (const voucher of [
|
|
160
|
+
...(state.retainedVouchers ?? []),
|
|
161
|
+
...(state.highestVoucher ? [state.highestVoucher] : []),
|
|
162
|
+
]) {
|
|
163
|
+
if (voucher.cumulativeAmount > state.spent || voucher.cumulativeAmount <= state.settledOnChain)
|
|
164
|
+
continue;
|
|
165
|
+
if (!settleable || voucher.cumulativeAmount > settleable.cumulativeAmount)
|
|
166
|
+
settleable = voucher;
|
|
167
|
+
}
|
|
168
|
+
if (!settleable)
|
|
169
|
+
return null;
|
|
170
|
+
return {
|
|
171
|
+
channelId: state.channelId,
|
|
172
|
+
cumulativeAmount: settleable.cumulativeAmount,
|
|
173
|
+
signature: settleable.signature,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
101
176
|
/** Merges an accepted voucher into persisted channel state after signature and on-chain checks pass. */
|
|
102
177
|
export function acceptVoucherStateUpdate(parameters) {
|
|
103
|
-
const { channelState, current, voucher } = parameters;
|
|
178
|
+
const { channelState, current, verifiedAt, voucher } = parameters;
|
|
104
179
|
if (!current)
|
|
105
180
|
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
106
181
|
if (current.finalized)
|
|
107
182
|
throw new ChannelClosedError({ reason: 'channel is finalized' });
|
|
108
183
|
if (current.closeRequestedAt !== 0n)
|
|
109
184
|
throw new ChannelClosedError({ reason: 'channel has a pending close request' });
|
|
110
|
-
const onChain =
|
|
185
|
+
const onChain = {
|
|
186
|
+
...mergeActiveOnChainState(current, channelState),
|
|
187
|
+
...(verifiedAt === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: { onChainVerifiedAt: Math.max(verifiedAt, current.onChainVerifiedAt ?? 0) }),
|
|
190
|
+
};
|
|
111
191
|
if (voucher.cumulativeAmount <= current.highestVoucherAmount) {
|
|
112
192
|
return { ...current, ...onChain };
|
|
113
193
|
}
|
|
@@ -116,6 +196,7 @@ export function acceptVoucherStateUpdate(parameters) {
|
|
|
116
196
|
...onChain,
|
|
117
197
|
highestVoucherAmount: voucher.cumulativeAmount,
|
|
118
198
|
highestVoucher: voucher,
|
|
199
|
+
retainedVouchers: retainVouchers(current, voucher.cumulativeAmount, onChain.settledOnChain),
|
|
119
200
|
};
|
|
120
201
|
}
|
|
121
202
|
/**
|
|
@@ -145,7 +226,7 @@ export function resolveCloseCaptureAmount(parameters) {
|
|
|
145
226
|
/** Seconds a server close transaction stays valid after its pending-close marker is written. */
|
|
146
227
|
export const pendingCloseValiditySeconds = 25;
|
|
147
228
|
/** Extra seconds allowed for clock differences between the server and the chain. */
|
|
148
|
-
const pendingCloseClockSkewSeconds = 30;
|
|
229
|
+
export const pendingCloseClockSkewSeconds = 30;
|
|
149
230
|
/** Clears a server pending-close marker whose close transaction expired without closing the channel. */
|
|
150
231
|
export function reconcileExpiredPendingClose(parameters) {
|
|
151
232
|
const { current, now, state } = parameters;
|
|
@@ -239,13 +320,58 @@ export async function validateVoucher(parameters) {
|
|
|
239
320
|
});
|
|
240
321
|
return { alreadyAccepted: false };
|
|
241
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Checks a cumulative voucher's amounts against channel state, without its signature, so the
|
|
325
|
+
* check can run inside the store update that accepts it.
|
|
326
|
+
*/
|
|
327
|
+
export function assertVoucherAmounts(parameters) {
|
|
328
|
+
const { channel, channelState, minVoucherDelta, voucher } = parameters;
|
|
329
|
+
validateChannelState(channelState);
|
|
330
|
+
if (voucher.cumulativeAmount > channelState.deposit)
|
|
331
|
+
throw new AmountExceedsDepositError({ reason: 'voucher amount exceeds on-chain deposit' });
|
|
332
|
+
if (voucher.cumulativeAmount < channel.highestVoucherAmount)
|
|
333
|
+
throw new VerificationFailedError({
|
|
334
|
+
reason: 'voucher cumulativeAmount must be strictly greater than highest accepted voucher',
|
|
335
|
+
});
|
|
336
|
+
if (voucher.cumulativeAmount <= channelState.settled)
|
|
337
|
+
throw new VerificationFailedError({
|
|
338
|
+
reason: 'voucher cumulativeAmount is below on-chain settled amount',
|
|
339
|
+
});
|
|
340
|
+
if (voucher.cumulativeAmount === channel.highestVoucherAmount)
|
|
341
|
+
return { alreadyAccepted: true };
|
|
342
|
+
const delta = voucher.cumulativeAmount - channel.highestVoucherAmount;
|
|
343
|
+
if (delta < minVoucherDelta)
|
|
344
|
+
throw new DeltaTooSmallError({
|
|
345
|
+
reason: `voucher delta ${delta} below minimum ${minVoucherDelta}`,
|
|
346
|
+
});
|
|
347
|
+
return { alreadyAccepted: false };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Charges `chargeAmount` to a channel state that just accepted a voucher, for the same store
|
|
351
|
+
* update. A charge the available balance cannot cover leaves the accepted state uncharged.
|
|
352
|
+
*/
|
|
353
|
+
export function planVoucherAcceptance(parameters) {
|
|
354
|
+
const { accepted, chargeAmount } = parameters;
|
|
355
|
+
if (chargeAmount === undefined)
|
|
356
|
+
return { channel: accepted, charged: false };
|
|
357
|
+
const deduction = planDeduction(accepted, chargeAmount);
|
|
358
|
+
if (deduction.op !== 'set')
|
|
359
|
+
return { channel: accepted, charged: false };
|
|
360
|
+
return { channel: deduction.value, charged: true };
|
|
361
|
+
}
|
|
362
|
+
/** Reports a request charge that the channel's available balance could not cover. */
|
|
363
|
+
export function insufficientBalanceForCharge(channel, chargeAmount) {
|
|
364
|
+
return new InsufficientBalanceError({
|
|
365
|
+
reason: `requested ${chargeAmount}, available ${channel.highestVoucherAmount - channel.spent - reservedStreamAmount(channel)}`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
242
368
|
/** Verifies a cumulative voucher and returns a session receipt after store reconciliation. */
|
|
243
369
|
export async function verifyAndAcceptVoucher(parameters) {
|
|
244
|
-
const { store, challenge, channel, voucher, channelState, requireAdvance } = parameters;
|
|
370
|
+
const { store, challenge, channel, voucher, channelState, requireAdvance, verifiedAt, chargeAmount, } = parameters;
|
|
245
371
|
const validation = await validateVoucher(parameters);
|
|
246
|
-
if (validation.alreadyAccepted)
|
|
247
|
-
|
|
248
|
-
|
|
372
|
+
if (validation.alreadyAccepted && requireAdvance)
|
|
373
|
+
throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
|
|
374
|
+
if (validation.alreadyAccepted && chargeAmount === undefined)
|
|
249
375
|
return createSessionReceipt({
|
|
250
376
|
challengeId: challenge.id,
|
|
251
377
|
channelId: voucher.channelId,
|
|
@@ -253,16 +379,26 @@ export async function verifyAndAcceptVoucher(parameters) {
|
|
|
253
379
|
spent: channel.spent,
|
|
254
380
|
units: channel.units,
|
|
255
381
|
});
|
|
256
|
-
|
|
382
|
+
let charged = false;
|
|
257
383
|
const updated = await store.updateChannel(voucher.channelId, (current) => {
|
|
258
384
|
if (requireAdvance &&
|
|
259
385
|
current !== null &&
|
|
260
386
|
voucher.cumulativeAmount <= current.highestVoucherAmount)
|
|
261
387
|
throw new DeltaTooSmallError({ reason: 'voucher does not add new funds for this request' });
|
|
262
|
-
|
|
388
|
+
const acceptance = planVoucherAcceptance({
|
|
389
|
+
accepted: acceptVoucherStateUpdate({ channelState, current, verifiedAt, voucher }),
|
|
390
|
+
chargeAmount,
|
|
391
|
+
});
|
|
392
|
+
charged = acceptance.charged;
|
|
393
|
+
return acceptance.channel;
|
|
263
394
|
});
|
|
264
395
|
if (!updated)
|
|
265
396
|
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
397
|
+
if (chargeAmount !== undefined) {
|
|
398
|
+
if (!charged)
|
|
399
|
+
throw insufficientBalanceForCharge(updated, chargeAmount);
|
|
400
|
+
parameters.onCharged?.(updated);
|
|
401
|
+
}
|
|
266
402
|
return createSessionReceipt({
|
|
267
403
|
challengeId: challenge.id,
|
|
268
404
|
channelId: voucher.channelId,
|
|
@@ -413,14 +549,76 @@ export function fromStore(store) {
|
|
|
413
549
|
storeCache.set(store, cs);
|
|
414
550
|
return cs;
|
|
415
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Default time a keyed charge or credit outcome is kept: a day, longer than any retry within one
|
|
554
|
+
* request or durable job step. Callers whose retries span longer pass their own `retention`.
|
|
555
|
+
*/
|
|
556
|
+
export const defaultIdempotencyRetention = 24 * 60 * 60 * 1000;
|
|
557
|
+
/** Raised when an idempotency key is reused with terms other than those it recorded. */
|
|
558
|
+
export class IdempotencyKeyConflictError extends Error {
|
|
559
|
+
name = 'IdempotencyKeyConflictError';
|
|
560
|
+
/** The reused key. */
|
|
561
|
+
idempotencyKey;
|
|
562
|
+
constructor(options) {
|
|
563
|
+
super(`Session ${options.operation} "${options.idempotencyKey}" was recorded with different terms.`);
|
|
564
|
+
this.idempotencyKey = options.idempotencyKey;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Atomic read-modify-write through `updateChannelResult`, or `updateChannel` when absent. */
|
|
568
|
+
async function applyChange(store, channelId, fn) {
|
|
569
|
+
if (store.updateChannelResult)
|
|
570
|
+
return store.updateChannelResult(channelId, fn);
|
|
571
|
+
// The callback can rerun on a contended write; the result of its last run is the stored one.
|
|
572
|
+
let result;
|
|
573
|
+
await store.updateChannel(channelId, (current) => {
|
|
574
|
+
const change = fn(current);
|
|
575
|
+
result = change.result;
|
|
576
|
+
if (change.op === 'set')
|
|
577
|
+
return change.value;
|
|
578
|
+
if (change.op === 'delete')
|
|
579
|
+
return null;
|
|
580
|
+
return current;
|
|
581
|
+
});
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
/** Validates the idempotency options of a keyed charge or credit. */
|
|
585
|
+
export function assertIdempotencyOptions(options) {
|
|
586
|
+
const { idempotencyKey, retention } = options;
|
|
587
|
+
if (idempotencyKey !== undefined && (typeof idempotencyKey !== 'string' || !idempotencyKey))
|
|
588
|
+
throw new Error('`idempotencyKey` must be a non-empty string.');
|
|
589
|
+
if (retention !== undefined && (!Number.isFinite(retention) || retention <= 0))
|
|
590
|
+
throw new Error('`retention` must be a positive number.');
|
|
591
|
+
}
|
|
592
|
+
/** Markers of a channel that have not expired. */
|
|
593
|
+
function liveMarkers(state, now) {
|
|
594
|
+
const markers = {};
|
|
595
|
+
for (const [key, marker] of Object.entries(state.idempotencyMarkers ?? {}))
|
|
596
|
+
if (marker.expiresAt > now)
|
|
597
|
+
markers[key] = marker;
|
|
598
|
+
return markers;
|
|
599
|
+
}
|
|
416
600
|
/**
|
|
417
601
|
* Atomically deducts `amount` from a channel's available voucher balance.
|
|
418
602
|
*
|
|
419
603
|
* Returns `{ ok: true, channel }` with updated spend/unit counters when the
|
|
420
604
|
* deduction succeeds. Returns `{ ok: false, channel }` without mutating state
|
|
421
|
-
* when the channel exists but cannot currently be charged.
|
|
605
|
+
* when the channel exists but cannot currently be charged. With an
|
|
606
|
+
* `idempotencyKey`, a charge recorded under the key returns
|
|
607
|
+
* `{ ok: true, channel, replayed: true }` without charging again.
|
|
422
608
|
*/
|
|
423
|
-
export async function deductFromChannel(store, channelId, amount) {
|
|
609
|
+
export async function deductFromChannel(store, channelId, amount, options) {
|
|
610
|
+
const idempotencyKey = options?.idempotencyKey;
|
|
611
|
+
if (idempotencyKey !== undefined) {
|
|
612
|
+
assertIdempotencyOptions(options);
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
const expiresAt = now + (options?.retention ?? defaultIdempotencyRetention);
|
|
615
|
+
const result = await applyChange(store, channelId, (current) => planKeyedDeduction(current, amount, idempotencyKey, expiresAt, now));
|
|
616
|
+
if (!result)
|
|
617
|
+
throw new Error('channel not found');
|
|
618
|
+
if (result === 'conflict')
|
|
619
|
+
throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'charge' });
|
|
620
|
+
return result;
|
|
621
|
+
}
|
|
424
622
|
if (store.updateChannelResult) {
|
|
425
623
|
const result = await store.updateChannelResult(channelId, (current) => planDeduction(current, amount));
|
|
426
624
|
if (!result)
|
|
@@ -439,6 +637,26 @@ export async function deductFromChannel(store, channelId, amount) {
|
|
|
439
637
|
throw new Error('channel not found');
|
|
440
638
|
return result ?? { ok: false, channel };
|
|
441
639
|
}
|
|
640
|
+
function planKeyedDeduction(current, amount, idempotencyKey, expiresAt, now) {
|
|
641
|
+
if (!current)
|
|
642
|
+
return { op: 'noop', result: null };
|
|
643
|
+
const markers = liveMarkers(current, now);
|
|
644
|
+
const markerKey = `charge:${idempotencyKey}`;
|
|
645
|
+
const recorded = markers[markerKey];
|
|
646
|
+
if (recorded) {
|
|
647
|
+
if (recorded.outcome !== 'charged' || recorded.amount !== amount)
|
|
648
|
+
return { op: 'noop', result: 'conflict' };
|
|
649
|
+
return { op: 'noop', result: { ok: true, channel: current, replayed: true } };
|
|
650
|
+
}
|
|
651
|
+
const deduction = planDeduction(current, amount);
|
|
652
|
+
if (deduction.op !== 'set')
|
|
653
|
+
return deduction;
|
|
654
|
+
const channel = {
|
|
655
|
+
...deduction.value,
|
|
656
|
+
idempotencyMarkers: { ...markers, [markerKey]: { amount, expiresAt, outcome: 'charged' } },
|
|
657
|
+
};
|
|
658
|
+
return { op: 'set', value: channel, result: { ok: true, channel } };
|
|
659
|
+
}
|
|
442
660
|
function planDeduction(current, amount) {
|
|
443
661
|
if (!current)
|
|
444
662
|
return { op: 'noop', result: null };
|
|
@@ -451,6 +669,88 @@ function planDeduction(current, amount) {
|
|
|
451
669
|
const next = { ...current, spent: current.spent + amount, units: current.units + 1 };
|
|
452
670
|
return { op: 'set', value: next, result: { ok: true, channel: next } };
|
|
453
671
|
}
|
|
672
|
+
/**
|
|
673
|
+
* Returns `amount` to a channel's `spent` at most once per idempotency key. The credit is declined
|
|
674
|
+
* when the channel is closing or finalized, or when it would take `spent` below zero or below
|
|
675
|
+
* what settled, or may still settle, on-chain, because a close captures `max(spent, settled)`.
|
|
676
|
+
* The first outcome for a key is final, so a declined credit is never applied later.
|
|
677
|
+
*/
|
|
678
|
+
export async function creditChannel(store, channelId, amount, options) {
|
|
679
|
+
const { idempotencyKey, units = 0 } = options;
|
|
680
|
+
assertIdempotencyOptions(options);
|
|
681
|
+
if (typeof idempotencyKey !== 'string')
|
|
682
|
+
throw new Error('`idempotencyKey` is required.');
|
|
683
|
+
if (amount <= 0n)
|
|
684
|
+
throw new Error('Credit amount must be positive.');
|
|
685
|
+
if (!Number.isSafeInteger(units) || units < 0)
|
|
686
|
+
throw new Error('`units` must be a non-negative integer.');
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
const expiresAt = now + (options.retention ?? defaultIdempotencyRetention);
|
|
689
|
+
let result;
|
|
690
|
+
try {
|
|
691
|
+
result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units }));
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
return { status: 'unknown', error };
|
|
695
|
+
}
|
|
696
|
+
if (!result)
|
|
697
|
+
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
698
|
+
if (result === 'conflict')
|
|
699
|
+
throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'credit' });
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
function planCredit(current, terms) {
|
|
703
|
+
const { amount, expiresAt, idempotencyKey, now, units } = terms;
|
|
704
|
+
if (!current)
|
|
705
|
+
return { op: 'noop', result: null };
|
|
706
|
+
const markers = liveMarkers(current, now);
|
|
707
|
+
const markerKey = `credit:${idempotencyKey}`;
|
|
708
|
+
const recorded = markers[markerKey];
|
|
709
|
+
if (recorded) {
|
|
710
|
+
if (recorded.outcome === 'charged' ||
|
|
711
|
+
recorded.amount !== amount ||
|
|
712
|
+
(recorded.units ?? 0) !== units)
|
|
713
|
+
return { op: 'noop', result: 'conflict' };
|
|
714
|
+
return {
|
|
715
|
+
op: 'noop',
|
|
716
|
+
result: recorded.outcome === 'credited'
|
|
717
|
+
? { status: 'credited', channel: current, replayed: true }
|
|
718
|
+
: { status: 'declined', reason: recorded.outcome, channel: current, replayed: true },
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const reason = creditDeclineReason(current, amount, now);
|
|
722
|
+
const marker = {
|
|
723
|
+
amount,
|
|
724
|
+
expiresAt,
|
|
725
|
+
outcome: reason ?? 'credited',
|
|
726
|
+
...(units > 0 ? { units } : {}),
|
|
727
|
+
};
|
|
728
|
+
const channel = {
|
|
729
|
+
...current,
|
|
730
|
+
...(reason ? {} : { spent: current.spent - amount, units: Math.max(0, current.units - units) }),
|
|
731
|
+
idempotencyMarkers: { ...markers, [markerKey]: marker },
|
|
732
|
+
};
|
|
733
|
+
return {
|
|
734
|
+
op: 'set',
|
|
735
|
+
value: channel,
|
|
736
|
+
result: reason
|
|
737
|
+
? { status: 'declined', reason, channel, replayed: false }
|
|
738
|
+
: { status: 'credited', channel, replayed: false },
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function creditDeclineReason(current, amount, now) {
|
|
742
|
+
if (current.finalized)
|
|
743
|
+
return 'finalized';
|
|
744
|
+
if (current.closeRequestedAt !== 0n)
|
|
745
|
+
return 'closing';
|
|
746
|
+
if (amount > current.spent)
|
|
747
|
+
return 'exceedsSpent';
|
|
748
|
+
const pending = current.pendingSettlement;
|
|
749
|
+
const settling = pending && pending.expiresAt > now ? pending.amount : 0n;
|
|
750
|
+
if (current.spent - amount < keepGreater(current.settledOnChain, settling))
|
|
751
|
+
return 'belowSettled';
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
454
754
|
/** Returns unexpired stream reservations that ordinary deductions must respect. */
|
|
455
755
|
export function reservedStreamAmount(state) {
|
|
456
756
|
const now = Date.now();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type Hex } from 'viem';
|
|
2
|
+
import * as Chain from '../precompile/Chain.js';
|
|
3
|
+
import { type OnSessionSettlementFailure, type SessionStoreInput, type SettlementTransactionOptions } from './Settlement.js';
|
|
4
|
+
/** Channels whose escrow states one call reads, bounding its calldata and response size. */
|
|
5
|
+
export declare const defaultBatchSize = 50;
|
|
6
|
+
/** Widest log query `rpc.tempo.xyz` accepts; wider ones fail with "query exceeds max block range". */
|
|
7
|
+
export declare const defaultLogBlockRange = 100000n;
|
|
8
|
+
/**
|
|
9
|
+
* Blocks searched before the previous complete supervision for the close event of a channel no
|
|
10
|
+
* supervision has read open yet: about 18 minutes of 0.55-second Tempo blocks, longer than the
|
|
11
|
+
* escrow's 900-second close grace period and the interval between supervisions.
|
|
12
|
+
*/
|
|
13
|
+
export declare const defaultCloseSearchMarginBlocks = 2000n;
|
|
14
|
+
/** What one supervision did with a channel. */
|
|
15
|
+
export type CloseSupervisionOutcome =
|
|
16
|
+
/** Open with no close request, or not yet open at the pinned block. */
|
|
17
|
+
'open'
|
|
18
|
+
/** Close requested, with nothing above the settled amount to capture; the payer's withdrawal finalizes it. */
|
|
19
|
+
| 'closing'
|
|
20
|
+
/** A close request recorded on the channel was cancelled on-chain, so the channel accepts charges again. */
|
|
21
|
+
| 'cancelled'
|
|
22
|
+
/** A payee close captured the channel's spend, or its deposit left the escrow with it paid. */
|
|
23
|
+
| 'closed'
|
|
24
|
+
/** The deposit left the escrow paying the payee less than the channel's spend. */
|
|
25
|
+
| 'unrecovered'
|
|
26
|
+
/** Already finalized in the store. */
|
|
27
|
+
| 'finalized'
|
|
28
|
+
/** No precompile channel record in the store. */
|
|
29
|
+
| 'missing'
|
|
30
|
+
/** The channel could not be read or answered; a later supervision retries it. */
|
|
31
|
+
| 'failed';
|
|
32
|
+
/** Result of one {@link superviseCloses} run. */
|
|
33
|
+
export type CloseSupervision = {
|
|
34
|
+
/** Block every escrow read was pinned to. Pass it as `previousBlock` once `complete`. */
|
|
35
|
+
block: bigint;
|
|
36
|
+
/** Timestamp of `block` in Unix seconds, e.g. to alert when the last complete run grows old. */
|
|
37
|
+
timestamp: number;
|
|
38
|
+
/** Whether every channel was read and handled, so `block` bounds later close-event searches. */
|
|
39
|
+
complete: boolean;
|
|
40
|
+
/** Channels supervised. */
|
|
41
|
+
supervised: number;
|
|
42
|
+
/** Close requests found cancelled. */
|
|
43
|
+
cancelled: number;
|
|
44
|
+
/** Channels closed or found closed with their spend paid. */
|
|
45
|
+
closed: number;
|
|
46
|
+
/** Channels that failed. */
|
|
47
|
+
failed: number;
|
|
48
|
+
/** Channels whose deposit left the escrow before their spend was captured. */
|
|
49
|
+
unrecovered: number;
|
|
50
|
+
/**
|
|
51
|
+
* Per-channel outcomes, in supervision order. Channels whose outcome is `closed`, `unrecovered`,
|
|
52
|
+
* `finalized` or `missing` need no further supervision.
|
|
53
|
+
*/
|
|
54
|
+
channels: {
|
|
55
|
+
channelId: Hex;
|
|
56
|
+
outcome: CloseSupervisionOutcome;
|
|
57
|
+
error?: unknown;
|
|
58
|
+
txHash?: Hex | undefined;
|
|
59
|
+
}[];
|
|
60
|
+
};
|
|
61
|
+
/** Inputs for {@link superviseCloses}. */
|
|
62
|
+
export type SuperviseClosesParameters = Pick<SettlementTransactionOptions, 'account' | 'candidateFeeTokens' | 'feePayer' | 'feePayerPolicy' | 'feeToken' | 'onSessionSettlement'> & {
|
|
63
|
+
/** Channels per escrow state read. @default 50 */
|
|
64
|
+
batchSize?: number | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Channels to supervise. A channel store cannot list its channels, so the caller keeps their IDs:
|
|
67
|
+
* add a channel before its first charge and drop it once a supervision reports it `closed`,
|
|
68
|
+
* `unrecovered`, `finalized` or `missing`.
|
|
69
|
+
*/
|
|
70
|
+
channelIds: Iterable<Hex> | AsyncIterable<Hex>;
|
|
71
|
+
/** viem client for reads and payee closes on the channels' chain. */
|
|
72
|
+
client: Chain.TransactionClient;
|
|
73
|
+
/** Blocks searched before `previousBlock` for a channel no supervision has read open. @default 2_000n */
|
|
74
|
+
closeSearchMarginBlocks?: bigint | undefined;
|
|
75
|
+
/** Widest block range of one log query. @default 100_000n */
|
|
76
|
+
logBlockRange?: bigint | undefined;
|
|
77
|
+
/** Callback invoked when a payee close fails. Observer errors are ignored. */
|
|
78
|
+
onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
|
|
79
|
+
/** `block` of the last `complete` supervision, which bounds close-event searches. */
|
|
80
|
+
previousBlock?: bigint | undefined;
|
|
81
|
+
/** Server channel store. */
|
|
82
|
+
store: SessionStoreInput;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Answers payer close requests on channels the server is not otherwise touching. After a payer
|
|
86
|
+
* requests a close on-chain, the escrow lets it withdraw the whole deposit once its close grace
|
|
87
|
+
* period (900 seconds on Tempo) ends, so call this from a scheduled job well within that period,
|
|
88
|
+
* for example every minute.
|
|
89
|
+
*
|
|
90
|
+
* Each run reads the escrow state of every channel at one pinned block, in batches. A close request
|
|
91
|
+
* is answered with a payee close capturing `max(spent, settled)`, bounded by the highest voucher and
|
|
92
|
+
* the deposit, which pays the accepted charges and refunds the rest; the close pays fees as
|
|
93
|
+
* configured, e.g. `feeToken` when the channel token cannot pay them. A deposit that already left
|
|
94
|
+
* the escrow is finalized from its `ChannelClosed` event, searched in pages of `logBlockRange`
|
|
95
|
+
* blocks. A close request cancelled on-chain reopens the channel.
|
|
96
|
+
*/
|
|
97
|
+
export declare function superviseCloses(parameters: SuperviseClosesParameters): Promise<CloseSupervision>;
|
|
98
|
+
//# sourceMappingURL=CloseSupervisor.d.ts.map
|