mppx 0.13.4 → 0.13.6

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 (73) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/Errors.d.ts +63 -2
  5. package/dist/Errors.js +52 -2
  6. package/dist/Method.d.ts +10 -0
  7. package/dist/cli/cli.js +92 -92
  8. package/dist/cli/plugins/evm.js +2 -2
  9. package/dist/cli/plugins/index.js +12 -12
  10. package/dist/client/Mppx.d.ts +2 -0
  11. package/dist/client/Mppx.js +8 -10
  12. package/dist/client/internal/Fetch.js +107 -8
  13. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  14. package/dist/client/internal/MethodChallenge.js +4 -0
  15. package/dist/client/internal/MethodResponse.d.ts +6 -0
  16. package/dist/client/internal/MethodResponse.js +16 -0
  17. package/dist/client/internal/protocols/Mcp.js +36 -5
  18. package/dist/evm/server/Charge.js +3 -1
  19. package/dist/internal/version.d.ts +2 -2
  20. package/dist/internal/version.js +1 -1
  21. package/dist/internal/warnOnce.d.ts +3 -0
  22. package/dist/internal/warnOnce.js +9 -0
  23. package/dist/server/Mppx.d.ts +5 -0
  24. package/dist/server/Mppx.js +45 -18
  25. package/dist/server/Transport.js +10 -0
  26. package/dist/stripe/server/Methods.js +4 -1
  27. package/dist/tempo/Attribution.d.ts +24 -9
  28. package/dist/tempo/Attribution.js +25 -5
  29. package/dist/tempo/client/Charge.d.ts +116 -10
  30. package/dist/tempo/client/Charge.js +117 -22
  31. package/dist/tempo/client/Methods.d.ts +88 -9
  32. package/dist/tempo/index.d.ts +1 -0
  33. package/dist/tempo/index.js +1 -0
  34. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  35. package/dist/tempo/internal/auto-swap.js +35 -8
  36. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  37. package/dist/tempo/internal/fee-payer.js +20 -8
  38. package/dist/tempo/legacy/session/Chain.js +8 -2
  39. package/dist/tempo/server/Charge.d.ts +11 -0
  40. package/dist/tempo/server/Charge.js +160 -29
  41. package/dist/tempo/server/Methods.js +28 -5
  42. package/dist/tempo/server/Relay.js +6 -22
  43. package/dist/tempo/server/Subscription.js +5 -1
  44. package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
  45. package/dist/tempo/server/internal/fallback-store.js +13 -0
  46. package/dist/tempo/server/internal/html.gen.js +1 -1
  47. package/dist/tempo/server/internal/transport.d.ts +3 -0
  48. package/dist/tempo/server/internal/transport.js +5 -1
  49. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  50. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  51. package/dist/tempo/session/client/ChannelStore.js +47 -0
  52. package/dist/tempo/session/client/CredentialState.js +8 -3
  53. package/dist/tempo/session/client/Session.js +50 -33
  54. package/dist/tempo/session/client/SessionManager.js +186 -65
  55. package/dist/tempo/session/client/Transports.d.ts +6 -0
  56. package/dist/tempo/session/client/Transports.js +52 -7
  57. package/dist/tempo/session/precompile/Chain.d.ts +2 -0
  58. package/dist/tempo/session/precompile/Chain.js +8 -3
  59. package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
  60. package/dist/tempo/session/server/ChannelStore.js +69 -9
  61. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  62. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  63. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  64. package/dist/tempo/session/server/MeteredStream.js +10 -26
  65. package/dist/tempo/session/server/Session.d.ts +29 -2
  66. package/dist/tempo/session/server/Session.js +124 -44
  67. package/dist/tempo/session/server/Settlement.d.ts +9 -0
  68. package/dist/tempo/session/server/Settlement.js +23 -3
  69. package/dist/tempo/session/server/Sse.d.ts +4 -3
  70. package/dist/tempo/session/server/Sse.js +3 -3
  71. package/dist/tempo/session/server/Ws.d.ts +9 -1
  72. package/dist/tempo/session/server/Ws.js +1 -0
  73. package/package.json +1 -1
@@ -62,6 +62,8 @@ export type Mppx<methods extends Methods = Methods, transport extends Transport.
62
62
  transport: transport;
63
63
  /**
64
64
  * Selects a payment challenge without creating its credential.
65
+ * Native HTTP responses in payment events preserve status and headers only;
66
+ * the caller retains ownership of the original response body.
65
67
  *
66
68
  * @example
67
69
  * ```ts
@@ -405,17 +405,15 @@ function snapshotMethod(method) {
405
405
  }
406
406
  function snapshotResponse(response) {
407
407
  if (response instanceof Response) {
408
- try {
408
+ if (response.body === null)
409
409
  return response.clone();
410
- }
411
- catch {
412
- // A consumed body cannot be cloned, but status and headers are sufficient for event context.
413
- return new Response(null, {
414
- headers: response.headers,
415
- status: response.status,
416
- statusText: response.statusText,
417
- });
418
- }
410
+ // Event context can outlive an SSE response. Retaining a clone would queue
411
+ // every chunk consumed by the caller on an unread body branch.
412
+ return new Response(null, {
413
+ headers: response.headers,
414
+ status: response.status,
415
+ statusText: response.statusText,
416
+ });
419
417
  }
420
418
  return snapshotValue(response);
421
419
  }
@@ -1,5 +1,6 @@
1
1
  import * as Challenge from '../../Challenge.js';
2
2
  import * as Constants from '../../Constants.js';
3
+ import { PaymentOutcomeUnknownError } from '../../Errors.js';
3
4
  import * as Expires from '../../Expires.js';
4
5
  import * as AcceptPayment from '../../internal/AcceptPayment.js';
5
6
  import * as Transport from '../Transport.js';
@@ -10,8 +11,64 @@ import * as MethodResponse from './MethodResponse.js';
10
11
  // an unrelated fetch installed by user code or another library.
11
12
  const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper');
12
13
  const defaultMaxPaymentRetries = 3;
14
+ const maxErrorBodyBytes = 64 * 1024;
15
+ const errorBodyTimeoutMs = 2_000;
16
+ /** Response header set when a paid-retry error body was cut off by {@link bufferErrorBody}. */
17
+ const bodyTruncatedHeader = 'Mppx-Body-Truncated';
13
18
  let originalFetch;
14
19
  const eventObserverChecks = new WeakMap();
20
+ /**
21
+ * Buffers a paid-retry error body within a size and time bound so its connection is
22
+ * released. A complete body stays in the original response. The body is diagnostic
23
+ * only: one larger than 64 KiB, or still arriving after 2 seconds, is cancelled and
24
+ * replaced with what arrived, marked with an `Mppx-Body-Truncated: true` header.
25
+ */
26
+ async function bufferErrorBody(response) {
27
+ if (!response.body)
28
+ return response;
29
+ // Reading a clone pulls the shared source, buffering it in the original response.
30
+ const reader = response.clone().body.getReader();
31
+ const chunks = [];
32
+ let received = 0;
33
+ let truncated = false;
34
+ const timeout = setTimeout(() => {
35
+ truncated = true;
36
+ void reader.cancel().catch(() => { });
37
+ }, errorBodyTimeoutMs);
38
+ try {
39
+ for (;;) {
40
+ const { done, value } = await reader.read();
41
+ if (done)
42
+ break;
43
+ chunks.push(value);
44
+ received += value.byteLength;
45
+ if (received > maxErrorBodyBytes) {
46
+ truncated = true;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ finally {
52
+ clearTimeout(timeout);
53
+ // A tee's cancellation can wait for the other branch; do not await it.
54
+ void reader.cancel().catch(() => { });
55
+ }
56
+ if (!truncated)
57
+ return response;
58
+ // Cancelling both branches cancels the source and releases the connection.
59
+ void response.body.cancel().catch(() => { });
60
+ const headers = new Headers(response.headers);
61
+ headers.delete('content-length');
62
+ headers.set(bodyTruncatedHeader, 'true');
63
+ const truncatedResponse = new Response(new Blob(chunks).slice(0, maxErrorBodyBytes), {
64
+ headers,
65
+ status: response.status,
66
+ statusText: response.statusText,
67
+ });
68
+ for (const key of ['redirected', 'type', 'url'])
69
+ Object.defineProperty(truncatedResponse, key, { value: response[key] });
70
+ return truncatedResponse;
71
+ }
15
72
  async function settleAttempt(prepared, outcome) {
16
73
  const settle = prepared?.attempt.settle;
17
74
  if (!settle)
@@ -67,6 +124,8 @@ export function from(config) {
67
124
  let challenges;
68
125
  let mi;
69
126
  let preparedCredential;
127
+ // Set when the paid request throws after the credential was sent.
128
+ let outcomeUnknown;
70
129
  try {
71
130
  for (let retry = 0; retry < maxPaymentRetries; retry++) {
72
131
  challenges = await transport.getChallenges(response, transportRequest);
@@ -166,14 +225,26 @@ export function from(config) {
166
225
  method: selected.method,
167
226
  response,
168
227
  }));
169
- response = await baseFetch(paymentInput, {
170
- ...transport.setCredential({
171
- ...fetchInit,
172
- headers: initialRequest.headers,
173
- }, credential, { challenge: selectedChallenge }),
174
- // Runtimes do not strip custom payment headers on cross-origin redirects.
175
- redirect: 'manual',
228
+ const unknownOutcome = (cause) => new PaymentOutcomeUnknownError({
229
+ cause,
230
+ challenge: selectedChallenge,
231
+ credential,
232
+ transactionHash: MethodResponse.getTransactionHash(selected.method, credential),
176
233
  });
234
+ try {
235
+ response = await baseFetch(paymentInput, {
236
+ ...transport.setCredential({
237
+ ...fetchInit,
238
+ headers: initialRequest.headers,
239
+ }, credential, { challenge: selectedChallenge }),
240
+ // Runtimes do not strip custom payment headers on cross-origin redirects.
241
+ redirect: 'manual',
242
+ });
243
+ }
244
+ catch (cause) {
245
+ outcomeUnknown = unknownOutcome(cause);
246
+ throw cause;
247
+ }
177
248
  const paymentRequired = await transport.isPaymentRequired(response, transportRequest);
178
249
  if (!paymentRequired) {
179
250
  await settleAttempt(prepared, {
@@ -198,6 +269,33 @@ export function from(config) {
198
269
  response,
199
270
  }));
200
271
  if (!paymentRequired) {
272
+ // The server may have settled before failing: report the credential for
273
+ // reconciliation and return the response. Retrying could pay twice.
274
+ if (response.status >= 500 && hasEventObservers(events, 'payment.failed')) {
275
+ try {
276
+ // Release the connection before observers can make requests through it.
277
+ response = await bufferErrorBody(response);
278
+ }
279
+ catch (cause) {
280
+ outcomeUnknown = unknownOutcome(cause);
281
+ // The failed body cannot be cloned for observers; keep its status and headers.
282
+ response = new Response(null, {
283
+ headers: response.headers,
284
+ status: response.status,
285
+ statusText: response.statusText,
286
+ });
287
+ throw cause;
288
+ }
289
+ await events.emit('payment.failed', createPaymentFailedPayload({
290
+ challenge: selectedChallenge,
291
+ challenges,
292
+ error: unknownOutcome(),
293
+ init,
294
+ input,
295
+ method: selected.method,
296
+ response,
297
+ }));
298
+ }
201
299
  if (!response.ok)
202
300
  return response;
203
301
  return MethodResponse.handle(selected.method, {
@@ -237,7 +335,8 @@ export function from(config) {
237
335
  await events.emit('payment.failed', createPaymentFailedPayload({
238
336
  challenge,
239
337
  challenges,
240
- error,
338
+ // Observers get the credential to reconcile; callers keep the original error.
339
+ error: outcomeUnknown ?? error,
241
340
  init,
242
341
  input,
243
342
  method: mi,
@@ -17,4 +17,6 @@ export declare function register<const method extends Method.AnyClient>(method:
17
17
  export declare function has(method: Method.AnyClient): boolean;
18
18
  /** Runs method-specific work before creating a challenge credential. */
19
19
  export declare function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void>;
20
+ /** Returns the registered internal hook so a manager can coordinate its work. */
21
+ export declare function get(method: Method.AnyClient): Handler | undefined;
20
22
  //# sourceMappingURL=MethodChallenge.d.ts.map
@@ -12,4 +12,8 @@ export function has(method) {
12
12
  export function handle(method, parameters) {
13
13
  return Promise.resolve(handlers.get(method)?.(parameters));
14
14
  }
15
+ /** Returns the registered internal hook so a manager can coordinate its work. */
16
+ export function get(method) {
17
+ return handlers.get(method);
18
+ }
15
19
  //# sourceMappingURL=MethodChallenge.js.map
@@ -23,6 +23,8 @@ export type HandlerParameters = {
23
23
  response: Response;
24
24
  signal?: AbortSignal | undefined;
25
25
  };
26
+ /** Derives the on-chain hash a credential's payment will have, when it is final before settlement. */
27
+ export type TransactionHash = (credential: string) => string | undefined;
26
28
  /** Internal client-method response adapter. */
27
29
  export type Handler = (parameters: HandlerParameters) => MaybePromise<Response>;
28
30
  /** Registers an internal response adapter without changing the public method shape. */
@@ -35,4 +37,8 @@ export declare function attachAttempt(method: Method.AnyClient, parameters: obje
35
37
  export declare function getAttempt(parameters: object): Attempt | undefined;
36
38
  /** Lets the selected client method handle a successful paid response. */
37
39
  export declare function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response>;
40
+ /** Registers credential hash derivation without changing the public method shape. */
41
+ export declare function registerTransactionHash<const method extends Method.AnyClient>(method: method, derive: TransactionHash): method;
42
+ /** Returns the credential's on-chain hash when its method can derive it. */
43
+ export declare function getTransactionHash(method: Method.AnyClient, credential: string): string | undefined;
38
44
  //# sourceMappingURL=MethodResponse.d.ts.map
@@ -1,5 +1,6 @@
1
1
  const handlers = new WeakMap();
2
2
  const attempts = new WeakMap();
3
+ const transactionHashes = new WeakMap();
3
4
  /** Registers an internal response adapter without changing the public method shape. */
4
5
  export function register(method, handler) {
5
6
  handlers.set(method, handler);
@@ -22,4 +23,19 @@ export function getAttempt(parameters) {
22
23
  export function handle(method, parameters) {
23
24
  return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response);
24
25
  }
26
+ /** Registers credential hash derivation without changing the public method shape. */
27
+ export function registerTransactionHash(method, derive) {
28
+ transactionHashes.set(method, derive);
29
+ return method;
30
+ }
31
+ /** Returns the credential's on-chain hash when its method can derive it. */
32
+ export function getTransactionHash(method, credential) {
33
+ try {
34
+ return transactionHashes.get(method)?.(credential);
35
+ }
36
+ catch {
37
+ // Reconciliation metadata must never mask the payment outcome itself.
38
+ return undefined;
39
+ }
40
+ }
25
41
  //# sourceMappingURL=MethodResponse.js.map
@@ -18,6 +18,8 @@ function jsonRpcRequestId(body) {
18
18
  return undefined;
19
19
  }
20
20
  }
21
+ // Bound automatic inspection; the caller retains the original response stream.
22
+ const maxInspectionBytes = 1024 * 1024;
21
23
  const responseCache = new WeakMap();
22
24
  function mcpHttpRequestId(request) {
23
25
  const id = jsonRpcRequestId(request?.body);
@@ -94,6 +96,7 @@ async function parseSseJsonRpcResponse(response) {
94
96
  if (!reader)
95
97
  return undefined;
96
98
  const decoder = new TextDecoder();
99
+ let received = 0;
97
100
  let dataEventSeen = false;
98
101
  let message;
99
102
  const parser = createParser({
@@ -119,6 +122,9 @@ async function parseSseJsonRpcResponse(response) {
119
122
  parser.reset({ consume: true });
120
123
  return message;
121
124
  }
125
+ received += value.byteLength;
126
+ if (received > maxInspectionBytes)
127
+ return undefined;
122
128
  parser.feed(decoder.decode(value, { stream: true }));
123
129
  }
124
130
  }
@@ -126,6 +132,33 @@ async function parseSseJsonRpcResponse(response) {
126
132
  void reader.cancel().catch(() => { });
127
133
  }
128
134
  }
135
+ /** Reads only a bounded JSON response, leaving the caller's stream untouched. */
136
+ async function parseJsonResponse(response) {
137
+ const reader = response.clone().body?.getReader();
138
+ if (!reader)
139
+ return undefined;
140
+ const decoder = new TextDecoder();
141
+ let received = 0;
142
+ let text = '';
143
+ try {
144
+ for (;;) {
145
+ const { done, value } = await reader.read();
146
+ if (done)
147
+ return parseMessage(JSON.parse(text + decoder.decode()));
148
+ received += value.byteLength;
149
+ if (received > maxInspectionBytes)
150
+ return undefined;
151
+ text += decoder.decode(value, { stream: true });
152
+ }
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ finally {
158
+ // A tee's cancellation can wait for the original stream; do not await it.
159
+ void reader.cancel().catch(() => { });
160
+ }
161
+ }
129
162
  /** Reads a cloned HTTP body into the first JSON-RPC response message, if any. */
130
163
  function parseJsonRpcResponse(response) {
131
164
  const cached = responseCache.get(response);
@@ -133,12 +166,10 @@ function parseJsonRpcResponse(response) {
133
166
  return cached;
134
167
  const promise = (async () => {
135
168
  const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
169
+ if (Number(response.headers.get('content-length')) > maxInspectionBytes)
170
+ return undefined;
136
171
  if (contentType.includes('application/json'))
137
- return response
138
- .clone()
139
- .json()
140
- .then(parseMessage)
141
- .catch(() => undefined);
172
+ return parseJsonResponse(response);
142
173
  if (contentType.includes('text/event-stream'))
143
174
  return parseSseJsonRpcResponse(response);
144
175
  return undefined;
@@ -77,12 +77,14 @@ export function charge(parameters) {
77
77
  source,
78
78
  };
79
79
  await config.validate?.(authorization);
80
+ const payer = getAddress(payload.from);
80
81
  return {
81
82
  challenge: credential.challenge,
82
83
  credential,
83
- details: { payer: getAddress(payload.from) },
84
+ details: { payer },
84
85
  intent: Methods.charge.intent,
85
86
  method: Methods.charge.name,
87
+ payer,
86
88
  request: credential.challenge.request,
87
89
  source,
88
90
  };
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.13.4";
2
+ export declare const version = "0.13.6";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.13.4";
4
+ export declare const sdkIdentifier = "mppx/0.13.6";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/sync-version.ts.
2
2
  /** Current mppx package version. */
3
- export const version = '0.13.4';
3
+ export const version = '0.13.6';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -0,0 +1,3 @@
1
+ /** Logs `message` with `console.warn` the first time `key` is seen in this process. */
2
+ export declare function warnOnce(key: string, message: string): void;
3
+ //# sourceMappingURL=warnOnce.d.ts.map
@@ -0,0 +1,9 @@
1
+ const warned = new Set();
2
+ /** Logs `message` with `console.warn` the first time `key` is seen in this process. */
3
+ export function warnOnce(key, message) {
4
+ if (warned.has(key))
5
+ return;
6
+ warned.add(key);
7
+ console.warn(`[mppx] ${message}`);
8
+ }
9
+ //# sourceMappingURL=warnOnce.js.map
@@ -104,6 +104,11 @@ export type PaymentSuccessContext<method extends Method.Method = Method.Method,
104
104
  envelope?: Method.VerifiedChallengeEnvelope<z.output<method['schema']['request']>, z.output<method['schema']['credential']['payload']>, method['intent'], method['name']> | undefined;
105
105
  input?: Transport.InputOf<transport> | undefined;
106
106
  method: ServerMethodDescriptor<method>;
107
+ /**
108
+ * Payer identity verified by the method before settlement (for example, the
109
+ * Tempo sender address). Absent when the method does not report one.
110
+ */
111
+ payer?: string | undefined;
107
112
  receipt: Receipt.Receipt;
108
113
  /** Canonical request represented by the challenge. */
109
114
  request: z.output<method['schema']['request']>;
@@ -7,6 +7,7 @@ import * as Errors from '../Errors.js';
7
7
  import * as Expires from '../Expires.js';
8
8
  import * as AcceptPayment from '../internal/AcceptPayment.js';
9
9
  import * as Env from '../internal/env.js';
10
+ import { warnOnce } from '../internal/warnOnce.js';
10
11
  import * as Mcp from '../Mcp.js';
11
12
  import * as PaymentRequest from '../PaymentRequest.js';
12
13
  import * as x402_Header from '../x402/Header.js';
@@ -332,9 +333,10 @@ export function create(config) {
332
333
  }));
333
334
  };
334
335
  let receipt;
336
+ let validation;
335
337
  try {
336
338
  if (mi.broadcast && mi.validate)
337
- await mi.validate({
339
+ validation = await mi.validate({
338
340
  credential: parsedCredential,
339
341
  envelope,
340
342
  operation: 'broadcast',
@@ -344,7 +346,7 @@ export function create(config) {
344
346
  receipt = await broadcast({ credential: parsedCredential, envelope, request });
345
347
  }
346
348
  catch (e) {
347
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
349
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
348
350
  await emitStandalonePaymentFailed({
349
351
  challenge: prepared.credential.challenge,
350
352
  credential: parsedCredential,
@@ -360,6 +362,7 @@ export function create(config) {
360
362
  credential: parsedCredential,
361
363
  envelope,
362
364
  method: mi,
365
+ payer: validation?.payer,
363
366
  receipt,
364
367
  request: parsedRequest,
365
368
  ...(requestInput !== undefined && { requestInput }),
@@ -608,7 +611,7 @@ function createMethodFn(parameters) {
608
611
  ? credentialError
609
612
  : reason
610
613
  ? new Errors.MalformedCredentialError({ reason })
611
- : new Errors.InternalPaymentError();
614
+ : new Errors.InternalPaymentError({ cause: credentialError });
612
615
  await emitPaymentFailed({
613
616
  challenge,
614
617
  credential: null,
@@ -674,7 +677,7 @@ function createMethodFn(parameters) {
674
677
  catch (e) {
675
678
  if (!(e instanceof Errors.PaymentError))
676
679
  console.error('mppx: internal authorization error', e);
677
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
680
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
678
681
  await emitPaymentFailed({
679
682
  challenge,
680
683
  credential: null,
@@ -834,9 +837,10 @@ function createMethodFn(parameters) {
834
837
  // User-provided verification (e.g., check signature, submit tx, verify payment).
835
838
  // If verification fails, re-issue the challenge so the client can retry.
836
839
  let receiptData;
840
+ let validation;
837
841
  try {
838
842
  if (broadcast && validate)
839
- await validate({
843
+ validation = await validate({
840
844
  credential: parsedCredential,
841
845
  envelope,
842
846
  operation: 'broadcast',
@@ -852,7 +856,7 @@ function createMethodFn(parameters) {
852
856
  catch (e) {
853
857
  if (!(e instanceof Errors.PaymentError))
854
858
  console.error('mppx: internal verification error', e);
855
- const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError();
859
+ const error = e instanceof Errors.PaymentError ? e : new Errors.InternalPaymentError({ cause: e });
856
860
  await emitPaymentFailed({
857
861
  challenge,
858
862
  credential: parsedCredential,
@@ -890,6 +894,7 @@ function createMethodFn(parameters) {
890
894
  envelope,
891
895
  input,
892
896
  method,
897
+ payer: validation?.payer,
893
898
  receipt: receiptData,
894
899
  request: parsedRequest,
895
900
  requestInput: request,
@@ -986,6 +991,7 @@ function createServerEventDispatcher() {
986
991
  await method.onPaymentSuccess({
987
992
  challenge: success.challenge,
988
993
  input: success.input,
994
+ ...(success.payer !== undefined && { payer: success.payer }),
989
995
  receipt: success.receipt,
990
996
  request: success.request,
991
997
  ...(success.requestInput !== undefined && { requestInput: success.requestInput }),
@@ -1066,6 +1072,7 @@ function createPaymentSuccessContext(parameters) {
1066
1072
  ...(parameters.envelope ? { envelope: snapshotVerifiedEnvelope(parameters.envelope) } : {}),
1067
1073
  ...snapshotInputProperty(parameters.input),
1068
1074
  method: snapshotMethod(parameters.method),
1075
+ ...(parameters.payer !== undefined && { payer: parameters.payer }),
1069
1076
  receipt: snapshotValue(parameters.receipt),
1070
1077
  request: snapshotValue(parameters.request),
1071
1078
  ...(parameters.requestInput !== undefined && {
@@ -1084,6 +1091,7 @@ function snapshotError(error) {
1084
1091
  return error;
1085
1092
  const snapshot = Object.assign(Object.create(Object.getPrototypeOf(error)), error);
1086
1093
  Object.defineProperties(snapshot, {
1094
+ ...('cause' in error && { cause: { value: error.cause, enumerable: false } }),
1087
1095
  message: { value: error.message, enumerable: false },
1088
1096
  name: { value: error.name, enumerable: false },
1089
1097
  });
@@ -1261,13 +1269,6 @@ export function isMissingReceiptResponseError(error) {
1261
1269
  function normalizeExpires(expires) {
1262
1270
  return expires === undefined ? undefined : z.toDatetimeString(expires);
1263
1271
  }
1264
- const _warned = new Set();
1265
- function warnOnce(key, message) {
1266
- if (_warned.has(key))
1267
- return;
1268
- _warned.add(key);
1269
- console.warn(`[mppx] ${message}`);
1270
- }
1271
1272
  /** Extracts hostname from the captured request URL, falling back to a default. */
1272
1273
  function resolveRealmFromCapturedRequest(capturedRequest) {
1273
1274
  try {
@@ -1416,6 +1417,19 @@ function getPinnedRequestBindingMismatch(expectedRequest, actualRequest) {
1416
1417
  return (getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
1417
1418
  getMethodBindingMismatch(expected.methodBinding, actual.methodBinding));
1418
1419
  }
1420
+ /**
1421
+ * Compares a credential with a composed handler's route request when dispatching. The route
1422
+ * request lacks method details that the handler's `request()` hook adds (for example a session's
1423
+ * `chainId` and `sessionProtocol`), so only the method fields it defines are compared. The
1424
+ * selected handler still checks every pinned field against its full challenge.
1425
+ */
1426
+ function getConfiguredRequestBindingMismatch(configuredRequest, actualRequest) {
1427
+ const expected = getPinnedRequestBinding(configuredRequest);
1428
+ const actual = getPinnedRequestBinding(actualRequest);
1429
+ return (getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
1430
+ methodBindingFields.find((field) => expected.methodBinding[field] !== undefined &&
1431
+ !isDeepStrictEqual(expected.methodBinding[field], actual.methodBinding[field])));
1432
+ }
1419
1433
  function getCoreBindingMismatch(expected, actual) {
1420
1434
  return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]));
1421
1435
  }
@@ -1447,6 +1461,15 @@ function getPinnedRequestBinding(request) {
1447
1461
  },
1448
1462
  };
1449
1463
  }
1464
+ /**
1465
+ * Compares a credential with a composed handler's stable route binding when dispatching. The route
1466
+ * request lacks fields its `request()` hook adds, so only the fields it defines are compared; the
1467
+ * selected handler still checks the full binding against its challenge.
1468
+ */
1469
+ function getConfiguredStableBindingMismatch(expected, actual) {
1470
+ return Object.keys(expected).find((field) => expected[field] !== undefined &&
1471
+ !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])));
1472
+ }
1450
1473
  function getRequestBindingMismatch(expected, actual) {
1451
1474
  const fields = [
1452
1475
  ...Object.keys(expected),
@@ -1601,9 +1624,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1601
1624
  if (internal.name !== credMethod || internal.intent !== credIntent)
1602
1625
  return false;
1603
1626
  const mismatch = internal._stableBinding
1604
- ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credReq, internal._stableBinding))
1605
- : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq);
1606
- return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta);
1627
+ ? getConfiguredStableBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credReq, internal._stableBinding))
1628
+ : getConfiguredRequestBindingMismatch(internal._canonicalRequest, credReq);
1629
+ // A handler without a static scope issues challenges scoped to the request's route.
1630
+ const effectiveMeta = internal.scope === undefined
1631
+ ? Scope.merge({ meta: internal.meta, scope: Scope.get(input) })
1632
+ : internal.meta;
1633
+ return !mismatch && opaqueValuesMatch(effectiveMeta, credential.challenge.meta);
1607
1634
  }
1608
1635
  catch {
1609
1636
  return false;
@@ -1805,8 +1832,8 @@ function composeMcpHandlers(handlers, transport) {
1805
1832
  const match = candidates.find((handler) => getConfiguredOffers(handler).some((internal) => {
1806
1833
  try {
1807
1834
  const mismatch = internal._stableBinding
1808
- ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credential.challenge.request, internal._stableBinding))
1809
- : getPinnedRequestBindingMismatch(internal._canonicalRequest, credential.challenge.request);
1835
+ ? getConfiguredStableBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credential.challenge.request, internal._stableBinding))
1836
+ : getConfiguredRequestBindingMismatch(internal._canonicalRequest, credential.challenge.request);
1810
1837
  return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta);
1811
1838
  }
1812
1839
  catch {
@@ -59,6 +59,16 @@ export function http(options = {}) {
59
59
  },
60
60
  async respondChallenge(options) {
61
61
  const { challenge, error, input } = options;
62
+ // A 403 means the payment was valid but cannot pay for this request.
63
+ // Omit the challenge so clients do not pay again.
64
+ if (error?.status === 403)
65
+ return new Response(JSON.stringify(error.toProblemDetails()), {
66
+ status: error.status,
67
+ headers: {
68
+ 'Cache-Control': 'no-store',
69
+ 'Content-Type': 'application/problem+json',
70
+ },
71
+ });
62
72
  if (options.html && new URL(input.url).searchParams.has(Html.params.serviceWorker))
63
73
  return new Response(serviceWorker, {
64
74
  status: 200,
@@ -133,7 +133,7 @@ export function stripe(parameters) {
133
133
  const suppliedStore = params.store;
134
134
  if (suppliedStore !== undefined && suppliedStore !== store)
135
135
  throw new Error('stripe.create() Tempo sessions must use the factory-level shared atomic `store`.');
136
- const { recipient, onSessionSettlement, ...rest } = params;
136
+ const { recipient, canOffer, onSessionSettlement, ...rest } = params;
137
137
  return tempoSession({
138
138
  currency: tempoCurrency,
139
139
  recipient,
@@ -141,6 +141,9 @@ export function stripe(parameters) {
141
141
  ...(hostedTempoFeePayer && { feePayer: hostedTempoFeePayer }),
142
142
  ...rest,
143
143
  store,
144
+ canOffer: canOffer
145
+ ? async (context) => cryptoCanOffer(context) && (await canOffer(context))
146
+ : cryptoCanOffer,
144
147
  async onSessionSettlement(context) {
145
148
  await tempoPaymentHandler({
146
149
  intent: 'session',