mppx 0.13.6 → 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.
@@ -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
  });
@@ -0,0 +1,52 @@
1
+ import { Address } from 'ox';
2
+ import type * as Store from '../../../Store.js';
3
+ import type { SessionCredentialPayload } from '../precompile/Protocol.js';
4
+ /**
5
+ * Sponsored opens, and separately sponsored top-ups, each verified payer may make per UTC day. It is
6
+ * tempoxyz/mpp-proxy's `DAILY_CHANNEL_OPEN_LIMIT`, the limit its proxy applied to sponsored channel
7
+ * opens before the limit moved into mppx.
8
+ */
9
+ export declare const defaultPerDay = 5;
10
+ /** Limits on fee-payer-sponsored channel opens and top-ups. */
11
+ export type Parameters = {
12
+ /** Atomic store holding one usage record per payer and chain. */
13
+ store: Store.AtomicStore;
14
+ /** Sponsored opens, and separately sponsored top-ups, per payer per UTC day. @default 5 */
15
+ perDay?: number | undefined;
16
+ };
17
+ /** Validates a configured limit. */
18
+ export declare function assertParameters(parameters: Parameters | undefined): void;
19
+ /**
20
+ * Returns the verified signer of a serialized Tempo transaction, lowercased, or null. A
21
+ * credential's `source` is client-supplied and unverified at this point, and so are the 0x78
22
+ * explicit sender slot and a keychain signature's account, so limits key on what the signatures
23
+ * prove:
24
+ * - a direct signature: its signer;
25
+ * - an access key authorized in the same transaction: the account that signed the authorization,
26
+ * so fresh keys do not get a fresh quota;
27
+ * - an access key authorized earlier: the key itself (its account link is only checked on chain).
28
+ */
29
+ export declare function verifiedPayer(serializedTransaction: string): Address.Address | null;
30
+ /**
31
+ * Records one sponsored `action` for `payer` on the current UTC day. Returns false, recording
32
+ * nothing, once the payer has used `perDay` of them.
33
+ */
34
+ export declare function reserve(store: Store.AtomicStore, parameters: {
35
+ action: 'open' | 'topUp';
36
+ chainId: number;
37
+ payer: string;
38
+ perDay?: number | undefined;
39
+ now?: Date | undefined;
40
+ }): Promise<boolean>;
41
+ /**
42
+ * Returns `feePayer` when the credential's open or top-up may be sponsored, and `undefined` once
43
+ * its verified payer used the day's sponsored transactions, or when no signature proves a payer.
44
+ * The transaction is then broadcast as the payer signed it, paying its own fees.
45
+ */
46
+ export declare function limitFeePayer<feePayer>(parameters: {
47
+ chainId: number;
48
+ feePayer: feePayer | undefined;
49
+ limit: Parameters | undefined;
50
+ payload: SessionCredentialPayload;
51
+ }): Promise<feePayer | undefined>;
52
+ //# sourceMappingURL=SponsorshipLimit.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { Address } from 'ox';
2
+ import { KeyAuthorization, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo';
3
+ /**
4
+ * Sponsored opens, and separately sponsored top-ups, each verified payer may make per UTC day. It is
5
+ * tempoxyz/mpp-proxy's `DAILY_CHANNEL_OPEN_LIMIT`, the limit its proxy applied to sponsored channel
6
+ * opens before the limit moved into mppx.
7
+ */
8
+ export const defaultPerDay = 5;
9
+ /** Validates a configured limit. */
10
+ export function assertParameters(parameters) {
11
+ const perDay = parameters?.perDay;
12
+ if (perDay !== undefined && (!Number.isSafeInteger(perDay) || perDay < 0))
13
+ throw new Error('`sponsorshipLimit.perDay` must be a non-negative integer.');
14
+ }
15
+ /**
16
+ * The address that provably produced `signature` over `payload`, or null. secp256k1 recovery is
17
+ * inherently verified; P256 and WebAuthn envelopes carry a public key, so their signature is
18
+ * checked against it. Multisig has no single signer.
19
+ */
20
+ function verifiedSigner(payload, signature) {
21
+ if (signature.type === 'secp256k1')
22
+ return SignatureEnvelope.extractAddress({ payload, signature });
23
+ if (signature.type === 'p256' || signature.type === 'webAuthn') {
24
+ const { publicKey } = signature;
25
+ return SignatureEnvelope.verify(signature, { payload, publicKey })
26
+ ? Address.fromPublicKey(publicKey)
27
+ : null;
28
+ }
29
+ return null;
30
+ }
31
+ /**
32
+ * Returns the verified signer of a serialized Tempo transaction, lowercased, or null. A
33
+ * credential's `source` is client-supplied and unverified at this point, and so are the 0x78
34
+ * explicit sender slot and a keychain signature's account, so limits key on what the signatures
35
+ * prove:
36
+ * - a direct signature: its signer;
37
+ * - an access key authorized in the same transaction: the account that signed the authorization,
38
+ * so fresh keys do not get a fresh quota;
39
+ * - an access key authorized earlier: the key itself (its account link is only checked on chain).
40
+ */
41
+ export function verifiedPayer(serializedTransaction) {
42
+ if (!/^0x7[68][0-9a-fA-F]+$/.test(serializedTransaction))
43
+ return null;
44
+ try {
45
+ const { from: _claimedSender, ...fields } = TxEnvelopeTempo.deserialize(serializedTransaction);
46
+ const { signature } = fields;
47
+ if (!signature)
48
+ return null;
49
+ const envelope = TxEnvelopeTempo.from(fields);
50
+ if (signature.type !== 'keychain')
51
+ return lower(verifiedSigner(TxEnvelopeTempo.getSignPayload(envelope), signature));
52
+ // Keychain V2 signs over the account too: keccak256(0x04 || sigHash || userAddress).
53
+ const accessKey = verifiedSigner(TxEnvelopeTempo.getSignPayload(envelope, signature.version === 'v2' ? { from: signature.userAddress } : {}), signature.inner);
54
+ if (!accessKey)
55
+ return null;
56
+ const authorization = fields.keyAuthorization;
57
+ if (!authorization)
58
+ return lower(accessKey);
59
+ if (!authorization.signature || !Address.isEqual(authorization.address, accessKey))
60
+ return null;
61
+ return lower(verifiedSigner(KeyAuthorization.getSignPayload(authorization), authorization.signature));
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function lower(address) {
68
+ return address ? address.toLowerCase() : null;
69
+ }
70
+ /**
71
+ * Records one sponsored `action` for `payer` on the current UTC day. Returns false, recording
72
+ * nothing, once the payer has used `perDay` of them.
73
+ */
74
+ export async function reserve(store, parameters) {
75
+ const { action, chainId, payer, perDay = defaultPerDay } = parameters;
76
+ const day = (parameters.now ?? new Date()).toISOString().slice(0, 10);
77
+ const key = `mppx:session:sponsorship:${chainId}:${payer.toLowerCase()}`;
78
+ return store.update(key, (current) => {
79
+ const usage = current?.day === day ? current : { day, open: 0, topUp: 0 };
80
+ if (usage[action] >= perDay)
81
+ return { op: 'noop', result: false };
82
+ return { op: 'set', value: { ...usage, [action]: usage[action] + 1 }, result: true };
83
+ });
84
+ }
85
+ /**
86
+ * Returns `feePayer` when the credential's open or top-up may be sponsored, and `undefined` once
87
+ * its verified payer used the day's sponsored transactions, or when no signature proves a payer.
88
+ * The transaction is then broadcast as the payer signed it, paying its own fees.
89
+ */
90
+ export async function limitFeePayer(parameters) {
91
+ const { chainId, feePayer, limit, payload } = parameters;
92
+ if (!feePayer || !limit)
93
+ return feePayer;
94
+ if (payload.action !== 'open' && payload.action !== 'topUp')
95
+ return feePayer;
96
+ const payer = verifiedPayer(payload.transaction);
97
+ if (!payer)
98
+ return undefined;
99
+ const allowed = await reserve(limit.store, {
100
+ action: payload.action,
101
+ chainId,
102
+ payer,
103
+ perDay: limit.perDay,
104
+ });
105
+ return allowed ? feePayer : undefined;
106
+ }
107
+ //# sourceMappingURL=SponsorshipLimit.js.map
@@ -1,4 +1,12 @@
1
- export { charge, session, settle, settleBatch } from './Session.js';
1
+ export { charge, credit, session, settle, settleBatch } from './Session.js';
2
+ /** Close supervision for payer close requests on idle channels. */
3
+ export { superviseCloses } from './CloseSupervisor.js';
4
+ export type { CloseSupervision, CloseSupervisionOutcome, SuperviseClosesParameters, } from './CloseSupervisor.js';
5
+ /** Idempotent charge and credit options and outcomes, and the voucher a settlement captures. */
6
+ export { IdempotencyKeyConflictError, resolveSettleableVoucher } from './ChannelStore.js';
7
+ export type { ChargeOptions, CreditDeclineReason, CreditOptions, CreditResult, State as ChannelState, } from './ChannelStore.js';
8
+ /** Limits on fee-payer-sponsored channel opens and top-ups. */
9
+ export type { Parameters as SponsorshipLimitParameters } from './SponsorshipLimit.js';
2
10
  /** SSE helpers and types for Tempo session streams. */
3
11
  export * as Sse from './Sse.js';
4
12
  /** Server-side automatic settlement schedule. */
@@ -1,4 +1,8 @@
1
- export { charge, session, settle, settleBatch } from './Session.js';
1
+ export { charge, credit, session, settle, settleBatch } from './Session.js';
2
+ /** Close supervision for payer close requests on idle channels. */
3
+ export { superviseCloses } from './CloseSupervisor.js';
4
+ /** Idempotent charge and credit options and outcomes, and the voucher a settlement captures. */
5
+ export { IdempotencyKeyConflictError, resolveSettleableVoucher } from './ChannelStore.js';
2
6
  /** SSE helpers and types for Tempo session streams. */
3
7
  export * as Sse from './Sse.js';
4
8
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.13.6",
4
+ "version": "0.14.0",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",