mppx 0.13.2 → 0.13.4

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.
@@ -1,4 +1,4 @@
1
- import { isAddress, isAddressEqual, parseUnits, zeroAddress, } from 'viem';
1
+ import { BaseError as viem_BaseError, HttpRequestError, InternalRpcError, isAddress, isAddressEqual, LimitExceededRpcError, parseUnits, ResourceUnavailableRpcError, RpcRequestError, SocketClosedError, TimeoutError, WaitForTransactionReceiptTimeoutError, WebSocketRequestError, zeroAddress, } from 'viem';
2
2
  import { BadRequestError, ChannelClosedError, ChannelNotFoundError, InsufficientBalanceError, VerificationFailedError, } from '../../../Errors.js';
3
3
  import { isSessionContentRequest } from '../../server/internal/request-body.js';
4
4
  import * as Chain from '../precompile/Chain.js';
@@ -105,7 +105,7 @@ export function isSettlementDue(channel, schedule) {
105
105
  return true;
106
106
  return false;
107
107
  }
108
- /** Records the channel spend/unit counters that a scheduled settlement captured. */
108
+ /** Releases a completed scheduled settlement lease; {@link settle} records the settled counters. */
109
109
  export async function markSettlementComplete(parameters) {
110
110
  const { channelId, leaseOwner, store, settledAt = new Date().toISOString() } = parameters;
111
111
  await store.updateChannel(channelId, (current) => {
@@ -114,12 +114,7 @@ export async function markSettlementComplete(parameters) {
114
114
  if (current.scheduledSettlementLease?.owner !== leaseOwner)
115
115
  return current;
116
116
  const { scheduledSettlementLease: _, ...channel } = current;
117
- return {
118
- ...channel,
119
- lastSettlementAt: settledAt,
120
- lastSettlementSpent: current.spent,
121
- lastSettlementUnits: current.units,
122
- };
117
+ return { ...channel, lastSettlementAt: settledAt };
123
118
  });
124
119
  }
125
120
  /** Atomically claims one due scheduled settlement across server workers. */
@@ -244,10 +239,20 @@ export async function maybeSettleScheduled(parameters) {
244
239
  const { channel, schedule, store } = parameters;
245
240
  if (!schedule || !isSettlementDue(channel, schedule))
246
241
  return undefined;
242
+ const report = (error) => reportSessionSettlementFailure(parameters.onSessionSettlementFailure, {
243
+ chainId: channel.chainId,
244
+ channelId: channel.channelId,
245
+ error,
246
+ trigger: 'scheduled',
247
+ });
247
248
  const leaseOwner = await claimScheduledSettlement({
248
249
  channelId: channel.channelId,
249
250
  schedule,
250
251
  store,
252
+ }).catch(async (error) => {
253
+ await report(error);
254
+ // Nothing was submitted, so the next settlement can still collect this charge.
255
+ throw new SettlementLeaseError({ cause: error });
251
256
  });
252
257
  if (!leaseOwner)
253
258
  return undefined;
@@ -258,26 +263,28 @@ export async function maybeSettleScheduled(parameters) {
258
263
  store,
259
264
  }).catch(() => undefined);
260
265
  }, scheduledSettlementLeaseMs / 2);
261
- try {
262
- const txHash = await settle(store, parameters.client, channel.channelId, {
263
- account: parameters.account,
264
- ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
265
- ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
266
- ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
267
- onSessionSettlement: parameters.onSessionSettlement
268
- ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
269
- : undefined,
270
- });
271
- await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store });
272
- return txHash;
273
- }
274
- catch (error) {
275
- await releaseScheduledSettlement({ channelId: channel.channelId, leaseOwner, store }).catch(() => undefined);
266
+ const release = () => releaseScheduledSettlement({ channelId: channel.channelId, leaseOwner, store }).catch(() => undefined);
267
+ const txHash = await settle(store, parameters.client, channel.channelId, {
268
+ account: parameters.account,
269
+ ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
270
+ ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
271
+ ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
272
+ onSessionSettlement: parameters.onSessionSettlement
273
+ ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
274
+ : undefined,
275
+ })
276
+ .catch(async (error) => {
277
+ await release();
278
+ await report(error);
279
+ // The transaction confirmed and collected the charge; only the local record failed.
280
+ if (error instanceof SettlementCheckpointError)
281
+ return error.txHash;
276
282
  throw error;
277
- }
278
- finally {
279
- clearInterval(renewal);
280
- }
283
+ })
284
+ .finally(() => clearInterval(renewal));
285
+ // Bookkeeping after a confirmed settlement must not fail the charged request.
286
+ await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store }).catch(release);
287
+ return txHash;
281
288
  }
282
289
  /** Settles the highest accepted voucher for a precompile-backed session channel. */
283
290
  export async function settle(store_, client, channelId_, options) {
@@ -311,34 +318,61 @@ export async function settle(store_, client, channelId_, options) {
311
318
  : undefined);
312
319
  const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
313
320
  const settled = readSettledReceiptFields(Chain.getChannelEvent(receipt, 'Settled', channelId));
314
- const { newSettled } = settled;
321
+ const { deltaPaid, newSettled } = settled;
315
322
  if (newSettled < amount)
316
323
  throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' });
317
- const state = await Chain.getChannelState(client, channelId, escrow);
324
+ // A replica behind the receipt's block would report the pre-settlement state.
325
+ const state = await Chain.readbackWithRetry(() => Chain.getChannelState(client, channelId, escrow, receipt.blockNumber));
318
326
  if (state.settled !== newSettled)
319
327
  throw new VerificationFailedError({
320
328
  reason: 'on-chain channel state does not match settle receipt',
321
329
  });
322
- await store.updateChannel(channelId, (current) => current
330
+ let checkpointError;
331
+ await store
332
+ .updateChannel(channelId, (current) => current
323
333
  ? {
324
334
  ...current,
325
335
  settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain,
326
336
  lastSettlementAt: new Date().toISOString(),
327
- lastSettlementSpent: current.spent,
328
- lastSettlementUnits: current.units,
337
+ // Charges accepted after the voucher was read are not covered by this transaction.
338
+ lastSettlementSpent: ChannelStore.keepGreater(current.lastSettlementSpent ?? 0n, channel.spent),
339
+ lastSettlementUnits: Math.max(current.lastSettlementUnits ?? 0, channel.units),
329
340
  }
330
- : current);
341
+ : current)
342
+ .catch((cause) => {
343
+ checkpointError = { cause };
344
+ });
331
345
  if (options?.onSessionSettlement) {
332
346
  await emitSessionSettlement(options.onSessionSettlement, {
333
347
  txHash,
334
348
  channelId,
335
349
  trigger: 'settle',
336
350
  amount: newSettled,
337
- delta: newSettled - channel.settledOnChain,
351
+ // The stored checkpoint can be stale after a failed write; the receipt is authoritative.
352
+ delta: deltaPaid,
338
353
  });
339
354
  }
355
+ if (checkpointError)
356
+ throw new SettlementCheckpointError({ cause: checkpointError.cause, txHash });
340
357
  return txHash;
341
358
  }
359
+ /** Raised when a settlement transaction confirmed but the channel store could not record it. */
360
+ export class SettlementCheckpointError extends Error {
361
+ name = 'SettlementCheckpointError';
362
+ /** Hash of the confirmed settlement transaction. */
363
+ txHash;
364
+ constructor(options) {
365
+ super(`Settlement ${options.txHash} confirmed but was not recorded.`, { cause: options.cause });
366
+ this.txHash = options.txHash;
367
+ }
368
+ }
369
+ /** Raised when a scheduled settlement could not claim its lease; no transaction was submitted. */
370
+ export class SettlementLeaseError extends Error {
371
+ name = 'SettlementLeaseError';
372
+ constructor(options) {
373
+ super('Scheduled settlement lease could not be claimed.', { cause: options.cause });
374
+ }
375
+ }
342
376
  /** Settles multiple precompile-backed session channels with the same validation as {@link settle}. */
343
377
  export async function settleBatch(store, client, channelIds, options) {
344
378
  const hashes = [];
@@ -346,6 +380,43 @@ export async function settleBatch(store, client, channelIds, options) {
346
380
  hashes.push(await settle(store, client, channelId, options));
347
381
  return hashes;
348
382
  }
383
+ /**
384
+ * @internal Keeps a charged request served when its scheduled settlement hit a transport or RPC
385
+ * failure; the next settlement retries it. Reverts and configuration errors fail the request.
386
+ */
387
+ export function ignoreRetryableSettlementFailure(error) {
388
+ if (isRetryableSettlementFailure(error))
389
+ return undefined;
390
+ throw error;
391
+ }
392
+ function isRetryableSettlementFailure(error) {
393
+ if (error instanceof SettlementLeaseError)
394
+ return true;
395
+ if (!(error instanceof viem_BaseError))
396
+ return false;
397
+ // Only unavailable or overloaded upstreams clear on retry; node-rejected transactions repeat.
398
+ return Boolean(error.walk((cause) => (cause instanceof HttpRequestError &&
399
+ (cause.status === undefined || cause.status === 429 || cause.status >= 500)) ||
400
+ // Rate limits some providers return in a JSON-RPC body rather than as HTTP 429.
401
+ (cause instanceof RpcRequestError && cause.code === 429) ||
402
+ cause instanceof InternalRpcError ||
403
+ cause instanceof LimitExceededRpcError ||
404
+ cause instanceof ResourceUnavailableRpcError ||
405
+ cause instanceof SocketClosedError ||
406
+ cause instanceof TimeoutError ||
407
+ // The settlement was broadcast; a later settlement collects the charge if it never lands.
408
+ cause instanceof WaitForTransactionReceiptTimeoutError ||
409
+ cause instanceof WebSocketRequestError));
410
+ }
411
+ /** @internal Reports a settlement failure without letting observer errors replace it. */
412
+ export async function reportSessionSettlementFailure(onSessionSettlementFailure, context) {
413
+ try {
414
+ await onSessionSettlementFailure?.(Object.freeze(context));
415
+ }
416
+ catch {
417
+ // Errors are isolated: observers cannot replace the settlement failure.
418
+ }
419
+ }
349
420
  async function emitSessionSettlement(onSessionSettlement, context) {
350
421
  try {
351
422
  await onSessionSettlement(Object.freeze(context));
@@ -2,5 +2,5 @@ export { charge, session, settle, settleBatch } from './Session.js';
2
2
  /** SSE helpers and types for Tempo session streams. */
3
3
  export * as Sse from './Sse.js';
4
4
  /** Server-side automatic settlement schedule. */
5
- export type { OnSessionSettlement, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, SessionSettlementContext, SettlementSchedule, } from './Session.js';
5
+ export type { OnSessionSettlement, OnSessionSettlementFailure, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, SessionSettlementContext, SessionSettlementFailureContext, SettlementSchedule, } from './Session.js';
6
6
  //# sourceMappingURL=index.d.ts.map
@@ -7,6 +7,7 @@ import * as Credential_ from '../../Credential.js';
7
7
  import { VerificationFailedError } from '../../Errors.js';
8
8
  import * as Types from '../../evm/Types.js';
9
9
  import * as PaymentRequest from '../../PaymentRequest.js';
10
+ import * as ChallengeMeta from '../../server/internal/challengeMeta.js';
10
11
  import * as Scope from '../../server/internal/scope.js';
11
12
  import * as x402_Header from '../Header.js';
12
13
  import * as x402_RouteBinding from '../internal/RouteBinding.js';
@@ -83,8 +84,9 @@ export function createPath(config) {
83
84
  const clientNonce = paymentPayload.extensions?.[mppxExtensionKey]?.info.nonce;
84
85
  const isRouteBound = clientNonce !== undefined;
85
86
  const routeRequiresBinding = challenge.digest !== undefined ||
86
- challenge.opaque !== undefined ||
87
- challenge.meta !== undefined;
87
+ (challenge.meta === undefined
88
+ ? challenge.opaque !== undefined
89
+ : ChallengeMeta.routeMeta(challenge.meta) !== undefined);
88
90
  // `extensions.mppx` binding is not part of the x402 spec, so a client mppx
89
91
  // did not write cannot produce it. Requiring it makes every scoped route —
90
92
  // and everything behind `Proxy`, which scopes what it serves — unpayable by
@@ -296,7 +298,12 @@ function routeExtensions(challenge, input) {
296
298
  binding[Scope.reservedMetaKey] = scope;
297
299
  if (challenge.digest !== undefined)
298
300
  binding.digest = challenge.digest;
299
- const opaque = challenge.opaque ?? (challenge.meta ? PaymentRequest.serialize(challenge.meta) : undefined);
301
+ const meta = ChallengeMeta.routeMeta(challenge.meta);
302
+ const opaque = challenge.meta === undefined
303
+ ? challenge.opaque
304
+ : meta
305
+ ? PaymentRequest.serialize(meta)
306
+ : undefined;
300
307
  if (opaque !== undefined)
301
308
  binding.opaque = opaque;
302
309
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.13.2",
4
+ "version": "0.13.4",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",