mppx 0.13.3 → 0.13.5

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/Errors.d.ts +63 -2
  3. package/dist/Errors.js +52 -2
  4. package/dist/Method.d.ts +10 -0
  5. package/dist/cli/cli.js +133 -133
  6. package/dist/cli/plugins/index.js +12 -12
  7. package/dist/cli/validate/helpers.d.ts +1 -0
  8. package/dist/cli/validate/helpers.js +2 -1
  9. package/dist/client/Mppx.d.ts +2 -0
  10. package/dist/client/Mppx.js +8 -10
  11. package/dist/client/internal/Fetch.js +107 -8
  12. package/dist/client/internal/MethodResponse.d.ts +6 -0
  13. package/dist/client/internal/MethodResponse.js +16 -0
  14. package/dist/client/internal/protocols/Mcp.js +36 -5
  15. package/dist/evm/server/Charge.js +3 -1
  16. package/dist/internal/version.d.ts +2 -2
  17. package/dist/internal/version.js +1 -1
  18. package/dist/internal/warnOnce.d.ts +3 -0
  19. package/dist/internal/warnOnce.js +9 -0
  20. package/dist/server/Mppx.d.ts +6 -1
  21. package/dist/server/Mppx.js +20 -16
  22. package/dist/server/Transport.js +10 -0
  23. package/dist/server/internal/challengeMeta.d.ts +5 -0
  24. package/dist/server/internal/challengeMeta.js +14 -0
  25. package/dist/tempo/Attribution.d.ts +24 -9
  26. package/dist/tempo/Attribution.js +25 -5
  27. package/dist/tempo/client/Charge.d.ts +116 -10
  28. package/dist/tempo/client/Charge.js +116 -22
  29. package/dist/tempo/client/Methods.d.ts +88 -9
  30. package/dist/tempo/index.d.ts +1 -0
  31. package/dist/tempo/index.js +1 -0
  32. package/dist/tempo/internal/fee-payer.js +4 -0
  33. package/dist/tempo/server/Charge.d.ts +11 -0
  34. package/dist/tempo/server/Charge.js +160 -29
  35. package/dist/tempo/server/Methods.d.ts +5 -0
  36. package/dist/tempo/server/Methods.js +18 -4
  37. package/dist/tempo/server/Subscription.js +2 -1
  38. package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
  39. package/dist/tempo/server/internal/fallback-store.js +13 -0
  40. package/dist/tempo/server/internal/html.gen.js +1 -1
  41. package/dist/tempo/session/client/Transports.js +39 -12
  42. package/dist/tempo/session/precompile/Chain.d.ts +4 -0
  43. package/dist/tempo/session/precompile/Chain.js +4 -1
  44. package/dist/tempo/session/server/Session.js +12 -11
  45. package/dist/tempo/session/server/Settlement.d.ts +7 -0
  46. package/dist/tempo/session/server/Settlement.js +33 -8
  47. package/dist/x402/server/EvmCharge.js +10 -3
  48. package/package.json +1 -1
@@ -679,11 +679,12 @@ export async function probeWebSocketSession(parameters) {
679
679
  }
680
680
  /** Probes a WebSocket endpoint over HTTP and creates the opening credential. */
681
681
  export async function prepareWebSocketSession(parameters) {
682
+ parameters.signal?.throwIfAborted();
682
683
  const probed = await probeWebSocketSession(parameters);
683
- return {
684
- ...probed,
685
- credential: await parameters.createSessionCredential(probed.challenge, {}),
686
- };
684
+ parameters.signal?.throwIfAborted();
685
+ const credential = await parameters.createSessionCredential(probed.challenge, {});
686
+ parameters.signal?.throwIfAborted();
687
+ return { ...probed, credential };
687
688
  }
688
689
  /** Creates the initial runtime state for a paid WebSocket from its opening credential. */
689
690
  export function createActiveSocketSession(parameters) {
@@ -741,6 +742,8 @@ export function validateSocketPaymentReceipt(parameters) {
741
742
  */
742
743
  export async function openWebSocketSession(parameters) {
743
744
  const { challenge, credential, options, WebSocket: WebSocketImpl, wsUrl } = parameters;
745
+ const signal = options?.signal;
746
+ signal?.throwIfAborted();
744
747
  const rawSocket = new WebSocketImpl(wsUrl, options?.protocols);
745
748
  const socketState = createActiveSocketSession({
746
749
  challenge,
@@ -757,7 +760,13 @@ export async function openWebSocketSession(parameters) {
757
760
  rawSocket.close(ClientWebSocketProtocolErrorCloseCode, message);
758
761
  }
759
762
  };
763
+ const onAbort = () => {
764
+ parameters.rejectReceipt(new Error('WebSocket payment flow aborted.'));
765
+ parameters.rejectCloseReady(new Error('WebSocket payment flow aborted.'));
766
+ rawSocket.close();
767
+ };
760
768
  rawSocket.addEventListener('close', (event) => {
769
+ signal?.removeEventListener('abort', onAbort);
761
770
  socketState.socket = null;
762
771
  socketState.expectedCloseAmount = null;
763
772
  parameters.rejectReceipt(new Error('WebSocket closed before the payment flow completed.'));
@@ -785,29 +794,47 @@ export async function openWebSocketSession(parameters) {
785
794
  socketState,
786
795
  });
787
796
  });
788
- options?.signal?.addEventListener('abort', () => {
789
- parameters.rejectReceipt(new Error('WebSocket payment flow aborted.'));
790
- parameters.rejectCloseReady(new Error('WebSocket payment flow aborted.'));
797
+ signal?.addEventListener('abort', onAbort, { once: true });
798
+ try {
799
+ await waitForSocketOpen(rawSocket, managedSocket.emit, wsUrl, signal);
800
+ signal?.throwIfAborted();
801
+ }
802
+ catch (error) {
803
+ signal?.removeEventListener('abort', onAbort);
791
804
  rawSocket.close();
792
- }, { once: true });
793
- await waitForSocketOpen(rawSocket, managedSocket.emit, wsUrl);
805
+ throw error;
806
+ }
794
807
  rawSocket.send(Ws.formatAuthorizationMessage(credential));
795
808
  await parameters.waitForReceipt();
796
809
  return managedSocket.socket;
797
810
  }
798
- function waitForSocketOpen(rawSocket, managedEmit, wsUrl) {
811
+ function waitForSocketOpen(rawSocket, managedEmit, wsUrl, signal) {
799
812
  return new Promise((resolve, reject) => {
800
- const onOpen = () => {
813
+ const cleanup = () => {
814
+ rawSocket.removeEventListener('open', onOpen);
801
815
  rawSocket.removeEventListener('error', onError);
816
+ rawSocket.removeEventListener('close', onError);
817
+ signal?.removeEventListener('abort', onAbort);
818
+ };
819
+ const onOpen = () => {
820
+ cleanup();
802
821
  managedEmit('open', { type: 'open' });
803
822
  resolve();
804
823
  };
805
824
  const onError = () => {
806
- rawSocket.removeEventListener('open', onOpen);
825
+ cleanup();
807
826
  reject(new Error(`WebSocket connection to ${wsUrl} failed to open.`));
808
827
  };
828
+ const onAbort = () => {
829
+ cleanup();
830
+ reject(signal?.reason);
831
+ };
809
832
  rawSocket.addEventListener('open', onOpen, { once: true });
810
833
  rawSocket.addEventListener('error', onError, { once: true });
834
+ rawSocket.addEventListener('close', onError, { once: true });
835
+ signal?.addEventListener('abort', onAbort, { once: true });
836
+ if (signal?.aborted)
837
+ onAbort();
811
838
  });
812
839
  }
813
840
  async function handleSocketMessage(parameters) {
@@ -71,6 +71,8 @@ export type TopUpReceiptFields = {
71
71
  };
72
72
  /** Typed fields decoded from a Settled receipt event. */
73
73
  export type SettledReceiptFields = {
74
+ /** Amount this settlement paid to the payee. */
75
+ deltaPaid: bigint;
74
76
  /** New cumulative amount settled on-chain. */
75
77
  newSettled: bigint;
76
78
  };
@@ -153,6 +155,8 @@ export type ReadbackRetryOptions = {
153
155
  retries?: number | undefined;
154
156
  /** Delay between attempts, in milliseconds. @default 250 */
155
157
  delayMs?: number | undefined;
158
+ /** Whether a failed attempt may be retried. @default every failure */
159
+ shouldRetry?: ((error: unknown) => boolean) | undefined;
156
160
  };
157
161
  /**
158
162
  * Retry an on-chain readback that is pinned to the block containing a just-sent
@@ -39,6 +39,7 @@ export function readTopUpReceiptFields(event) {
39
39
  /** Reads and validates typed fields from a Settled receipt event. */
40
40
  export function readSettledReceiptFields(event) {
41
41
  return {
42
+ deltaPaid: readUint96(event.args.deltaPaid, 'Settled deltaPaid'),
42
43
  newSettled: readUint96(event.args.newSettled, 'Settled newSettled'),
43
44
  };
44
45
  }
@@ -168,7 +169,7 @@ export async function getChannelStatesBatch(client, channelIds, escrow = tip20Ch
168
169
  * verification even though the open transaction succeeded.
169
170
  */
170
171
  export async function readbackWithRetry(read, options = {}) {
171
- const { retries = 5, delayMs = 250 } = options;
172
+ const { retries = 5, delayMs = 250, shouldRetry = () => true } = options;
172
173
  let lastError;
173
174
  for (let attempt = 0; attempt <= retries; attempt++) {
174
175
  try {
@@ -176,6 +177,8 @@ export async function readbackWithRetry(read, options = {}) {
176
177
  }
177
178
  catch (error) {
178
179
  lastError = error;
180
+ if (!shouldRetry(error))
181
+ break;
179
182
  if (attempt < retries)
180
183
  await new Promise((resolve) => setTimeout(resolve, delayMs));
181
184
  }
@@ -12,12 +12,13 @@ import * as Constants from '../../../Constants.js';
12
12
  import * as Errors from '../../../Errors.js';
13
13
  import * as Expires from '../../../Expires.js';
14
14
  import * as Method from '../../../Method.js';
15
- import * as Store from '../../../Store.js';
15
+ import * as ChallengeMeta from '../../../server/internal/challengeMeta.js';
16
16
  import * as Client from '../../../viem/Client.js';
17
17
  import * as Account from '../../internal/account.js';
18
18
  import * as defaults from '../../internal/defaults.js';
19
19
  import * as Methods from '../../Methods.js';
20
20
  import * as ChargeServer from '../../server/Charge.js';
21
+ import { fallbackStore } from '../../server/internal/fallback-store.js';
21
22
  import * as Transport from '../../server/internal/transport.js';
22
23
  import { tip20ChannelEscrow } from '../precompile/Protocol.js';
23
24
  import { deserializeSnapshot as deserializeSessionSnapshot, serializeSnapshot as serializeSessionSnapshot, } from '../Snapshot.js';
@@ -58,23 +59,21 @@ function isBootstrapChargeCredential(credential) {
58
59
  function createBootstrapChallenge(parameters) {
59
60
  return Challenge.fromMethod(Methods.charge, {
60
61
  expires: parameters.expires,
62
+ meta: ChallengeMeta.withNonce(undefined),
61
63
  realm: parameters.realm,
62
64
  request: parameters.request,
63
65
  secretKey: parameters.secretKey,
64
66
  });
65
67
  }
66
68
  function respondBootstrapChallenge(challenge, error) {
67
- const headers = new Headers({
68
- [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
69
- 'Cache-Control': 'no-store',
70
- });
69
+ const headers = new Headers({ 'Cache-Control': 'no-store' });
70
+ // A 403 is final: omit the challenge, as `Transport.http()` does.
71
+ if (error?.status !== 403)
72
+ headers.set(Constants.Headers.wwwAuthenticate, Challenge.serialize(challenge));
71
73
  if (!error)
72
74
  return new Response(null, { status: 402, headers });
73
75
  headers.set('Content-Type', 'application/problem+json');
74
- return new Response(JSON.stringify(error.toProblemDetails(challenge.id)), {
75
- status: error.status,
76
- headers,
77
- });
76
+ return new Response(JSON.stringify(error.toProblemDetails(error.status === 403 ? undefined : challenge.id)), { status: error.status, headers });
78
77
  }
79
78
  function assertBootstrapChallengeMatches(expected, actual) {
80
79
  if (actual.method !== Constants.Methods.tempo || actual.intent !== Constants.Intents.charge)
@@ -162,7 +161,9 @@ async function handleBootstrapPreflight(parameters) {
162
161
  });
163
162
  }
164
163
  catch (error) {
165
- return respondBootstrapChallenge(challenge, error instanceof Errors.PaymentError ? error : new Errors.InternalPaymentError());
164
+ return respondBootstrapChallenge(challenge, error instanceof Errors.PaymentError
165
+ ? error
166
+ : new Errors.InternalPaymentError({ cause: error }));
166
167
  }
167
168
  const channelId = await resolveSessionChannelId({
168
169
  capturedRequest: parameters.capturedRequest,
@@ -193,7 +194,7 @@ async function handleBootstrapPreflight(parameters) {
193
194
  /** Creates a server-side TIP20EscrowChannel precompile session payment method. */
194
195
  export function session(p) {
195
196
  const parameters = p;
196
- const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = Store.memory(), suggestedDeposit, unitType, } = parameters;
197
+ const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = fallbackStore('tempo.session'), suggestedDeposit, unitType, } = parameters;
197
198
  const settlementSchedule = resolveSettlementSchedule(parameters.settlementSchedule, decimals);
198
199
  const onSessionSettlement = parameters.onSessionSettlement;
199
200
  const onSessionSettlementFailure = parameters.onSessionSettlementFailure;
@@ -250,6 +250,13 @@ export declare class SettlementCheckpointError extends Error {
250
250
  txHash: Hex;
251
251
  });
252
252
  }
253
+ /** Raised when a scheduled settlement could not claim its lease; no transaction was submitted. */
254
+ export declare class SettlementLeaseError extends Error {
255
+ readonly name = "SettlementLeaseError";
256
+ constructor(options: {
257
+ cause: unknown;
258
+ });
259
+ }
253
260
  /** Settles multiple precompile-backed session channels with the same validation as {@link settle}. */
254
261
  export declare function settleBatch(store: SessionStoreInput, client: Chain.TransactionClient, channelIds: readonly Hex[], options?: SettlementTransactionOptions): Promise<Hex[]>;
255
262
  /**
@@ -1,4 +1,4 @@
1
- import { BaseError as viem_BaseError, HttpRequestError, isAddress, isAddressEqual, LimitExceededRpcError, parseUnits, ResourceUnavailableRpcError, SocketClosedError, TimeoutError, WebSocketRequestError, zeroAddress, } from 'viem';
1
+ import { BaseError as viem_BaseError, ExecutionRevertedError, 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';
@@ -251,7 +251,8 @@ export async function maybeSettleScheduled(parameters) {
251
251
  store,
252
252
  }).catch(async (error) => {
253
253
  await report(error);
254
- throw error;
254
+ // Nothing was submitted, so the next settlement can still collect this charge.
255
+ throw new SettlementLeaseError({ cause: error });
255
256
  });
256
257
  if (!leaseOwner)
257
258
  return undefined;
@@ -274,10 +275,10 @@ export async function maybeSettleScheduled(parameters) {
274
275
  })
275
276
  .catch(async (error) => {
276
277
  await release();
278
+ await report(error);
277
279
  // The transaction confirmed and collected the charge; only the local record failed.
278
280
  if (error instanceof SettlementCheckpointError)
279
281
  return error.txHash;
280
- await report(error);
281
282
  throw error;
282
283
  })
283
284
  .finally(() => clearInterval(renewal));
@@ -306,7 +307,10 @@ export async function settle(store_, client, channelId_, options) {
306
307
  sender: account?.address,
307
308
  });
308
309
  const amount = uint96(channel.highestVoucher.cumulativeAmount);
309
- const txHash = await Chain.settleOnChain(client, channel.descriptor, amount, channel.highestVoucher.signature, escrow, account
310
+ const { signature } = channel.highestVoucher;
311
+ // A replica behind the open or top-up that admitted this voucher rejects the settle while
312
+ // preparing it (e.g. `ChannelNotFound`); nothing was broadcast, so retry until it catches up.
313
+ const txHash = await Chain.readbackWithRetry(() => Chain.settleOnChain(client, channel.descriptor, amount, signature, escrow, account
310
314
  ? {
311
315
  account,
312
316
  ...(options?.feePayer ? { feePayer: options.feePayer } : {}),
@@ -314,13 +318,14 @@ export async function settle(store_, client, channelId_, options) {
314
318
  ...(options?.feeToken ? { feeToken: options.feeToken } : {}),
315
319
  candidateFeeTokens: options?.candidateFeeTokens ?? [channel.token],
316
320
  }
317
- : undefined);
321
+ : undefined), { shouldRetry: isExecutionRevert });
318
322
  const receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
319
323
  const settled = readSettledReceiptFields(Chain.getChannelEvent(receipt, 'Settled', channelId));
320
- const { newSettled } = settled;
324
+ const { deltaPaid, newSettled } = settled;
321
325
  if (newSettled < amount)
322
326
  throw new VerificationFailedError({ reason: 'Settled event is below voucher amount' });
323
- const state = await Chain.getChannelState(client, channelId, escrow);
327
+ // A replica behind the receipt's block would report the pre-settlement state.
328
+ const state = await Chain.readbackWithRetry(() => Chain.getChannelState(client, channelId, escrow, receipt.blockNumber));
324
329
  if (state.settled !== newSettled)
325
330
  throw new VerificationFailedError({
326
331
  reason: 'on-chain channel state does not match settle receipt',
@@ -346,7 +351,8 @@ export async function settle(store_, client, channelId_, options) {
346
351
  channelId,
347
352
  trigger: 'settle',
348
353
  amount: newSettled,
349
- delta: newSettled - channel.settledOnChain,
354
+ // The stored checkpoint can be stale after a failed write; the receipt is authoritative.
355
+ delta: deltaPaid,
350
356
  });
351
357
  }
352
358
  if (checkpointError)
@@ -363,6 +369,13 @@ export class SettlementCheckpointError extends Error {
363
369
  this.txHash = options.txHash;
364
370
  }
365
371
  }
372
+ /** Raised when a scheduled settlement could not claim its lease; no transaction was submitted. */
373
+ export class SettlementLeaseError extends Error {
374
+ name = 'SettlementLeaseError';
375
+ constructor(options) {
376
+ super('Scheduled settlement lease could not be claimed.', { cause: options.cause });
377
+ }
378
+ }
366
379
  /** Settles multiple precompile-backed session channels with the same validation as {@link settle}. */
367
380
  export async function settleBatch(store, client, channelIds, options) {
368
381
  const hashes = [];
@@ -379,16 +392,28 @@ export function ignoreRetryableSettlementFailure(error) {
379
392
  return undefined;
380
393
  throw error;
381
394
  }
395
+ /** Whether a settlement failed because the node's execution reverted it. */
396
+ function isExecutionRevert(error) {
397
+ return (error instanceof viem_BaseError &&
398
+ Boolean(error.walk((cause) => cause instanceof ExecutionRevertedError)));
399
+ }
382
400
  function isRetryableSettlementFailure(error) {
401
+ if (error instanceof SettlementLeaseError)
402
+ return true;
383
403
  if (!(error instanceof viem_BaseError))
384
404
  return false;
385
405
  // Only unavailable or overloaded upstreams clear on retry; node-rejected transactions repeat.
386
406
  return Boolean(error.walk((cause) => (cause instanceof HttpRequestError &&
387
407
  (cause.status === undefined || cause.status === 429 || cause.status >= 500)) ||
408
+ // Rate limits some providers return in a JSON-RPC body rather than as HTTP 429.
409
+ (cause instanceof RpcRequestError && cause.code === 429) ||
410
+ cause instanceof InternalRpcError ||
388
411
  cause instanceof LimitExceededRpcError ||
389
412
  cause instanceof ResourceUnavailableRpcError ||
390
413
  cause instanceof SocketClosedError ||
391
414
  cause instanceof TimeoutError ||
415
+ // The settlement was broadcast; a later settlement collects the charge if it never lands.
416
+ cause instanceof WaitForTransactionReceiptTimeoutError ||
392
417
  cause instanceof WebSocketRequestError));
393
418
  }
394
419
  /** @internal Reports a settlement failure without letting observer errors replace it. */
@@ -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.3",
4
+ "version": "0.13.5",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",