mppx 0.16.0 → 0.17.1

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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Challenge.d.ts +6 -0
  4. package/dist/Challenge.js +12 -0
  5. package/dist/Method.d.ts +7 -0
  6. package/dist/cli/cli.js +140 -134
  7. package/dist/cli/plugins/index.js +11 -11
  8. package/dist/cli/sessions/store.js +7 -1
  9. package/dist/cli/validate/helpers.d.ts +1 -1
  10. package/dist/cli/validate/helpers.js +14 -2
  11. package/dist/cli/validate/payment.js +39 -15
  12. package/dist/client/Mppx.js +11 -3
  13. package/dist/client/internal/Fetch.d.ts +2 -0
  14. package/dist/client/internal/Fetch.js +59 -3
  15. package/dist/client/internal/protocols/Mpp.js +4 -1
  16. package/dist/identity/Client.js +3 -3
  17. package/dist/identity/Server.d.ts +13 -0
  18. package/dist/identity/Server.js +50 -1
  19. package/dist/identity/Types.d.ts +32 -10
  20. package/dist/internal/AcceptPayment.d.ts +12 -2
  21. package/dist/internal/AcceptPayment.js +12 -3
  22. package/dist/internal/version.d.ts +2 -2
  23. package/dist/internal/version.js +1 -1
  24. package/dist/mcp/client/McpClient.js +1 -1
  25. package/dist/proxy/Proxy.d.ts +15 -0
  26. package/dist/proxy/Proxy.js +89 -1
  27. package/dist/proxy/internal/Replay.d.ts +105 -0
  28. package/dist/proxy/internal/Replay.js +257 -0
  29. package/dist/server/Mppx.d.ts +4 -2
  30. package/dist/server/Mppx.js +50 -24
  31. package/dist/server/internal/challengeMeta.d.ts +6 -0
  32. package/dist/server/internal/challengeMeta.js +17 -1
  33. package/dist/tempo/client/Charge.d.ts +20 -0
  34. package/dist/tempo/client/Charge.js +43 -10
  35. package/dist/tempo/client/Methods.d.ts +2 -0
  36. package/dist/tempo/internal/defaults.d.ts +9 -0
  37. package/dist/tempo/internal/defaults.js +9 -0
  38. package/dist/tempo/internal/fee-payer.js +2 -2
  39. package/dist/tempo/server/Charge.js +16 -2
  40. package/dist/tempo/server/internal/html.gen.js +1 -1
  41. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  42. package/dist/tempo/session/client/ChannelStore.js +5 -0
  43. package/dist/tempo/session/client/Session.d.ts +26 -1
  44. package/dist/tempo/session/client/Session.js +72 -1
  45. package/dist/tempo/session/client/SessionManager.d.ts +1 -0
  46. package/dist/tempo/session/client/SessionManager.js +54 -7
  47. package/dist/tempo/session/client/Transports.d.ts +2 -0
  48. package/dist/tempo/session/client/Transports.js +31 -2
  49. package/dist/tempo/session/precompile/Voucher.js +20 -7
  50. package/dist/tempo/session/server/Session.d.ts +2 -1
  51. package/dist/tempo/session/server/Session.js +2 -1
  52. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  53. package/dist/tempo/session/server/Settlement.js +2 -2
  54. package/dist/tempo/session/server/Ws.js +29 -12
  55. package/package.json +1 -1
@@ -467,7 +467,8 @@ function createMethodFn(parameters) {
467
467
  ]),
468
468
  ],
469
469
  _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
470
- _paymentIdentity: paymentVerifiers.length > 0,
470
+ _paymentIdentity: paymentVerifiers.length > 0 ? paymentVerifiers : undefined,
471
+ _identity: identity.length > 0,
471
472
  _stableBinding: stableBinding,
472
473
  _transport: transport,
473
474
  };
@@ -500,6 +501,12 @@ function createMethodFn(parameters) {
500
501
  const effectiveMeta = scope === undefined && input instanceof globalThis.Request
501
502
  ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
502
503
  : staticMeta;
504
+ // Every offer of one 402 shares the identity nonce (composed routes pass each offer the
505
+ // same request), so a payment-stage proof is bound to the 402, whichever offer is paid.
506
+ const identityNonce = paymentVerifiers.length > 0 && input instanceof globalThis.Request
507
+ ? IdentityServer.nonceFor(input, ChallengeMeta.createNonce)
508
+ : undefined;
509
+ const challengeMeta = ChallengeMeta.withIdentity(effectiveMeta, identityNonce);
503
510
  // Extract credential once — getCredential may have side effects (e.g. SSE transports).
504
511
  let [credential, credentialError] = (() => {
505
512
  try {
@@ -540,17 +547,12 @@ function createMethodFn(parameters) {
540
547
  ...(parameters.html && { html: parameters.html }),
541
548
  });
542
549
  // Payment-stage verifiers add their own challenge headers to the 402.
543
- if (paymentVerifiers.length > 0 &&
550
+ if (identityNonce !== undefined &&
544
551
  input instanceof globalThis.Request &&
545
552
  response instanceof globalThis.Response) {
546
- const outcome = await IdentityServer.verify(input, paymentVerifiers, {
547
- challenge: parameters.challenge,
548
- });
549
- const identityHeaders = new Headers();
550
- if (outcome.status !== 'valid')
551
- for (const [name, value] of outcome.headers)
552
- identityHeaders.append(name, value);
553
+ const identityHeaders = await IdentityServer.challenge(input, paymentVerifiers, identityNonce);
553
554
  if ([...identityHeaders].length > 0) {
555
+ const paymentChallenge = response.headers.get(paymentAuthChallengeHeader);
554
556
  const headers = new Headers(response.headers);
555
557
  for (const [name, value] of identityHeaders)
556
558
  headers.append(name, value);
@@ -560,6 +562,7 @@ function createMethodFn(parameters) {
560
562
  statusText: response.statusText,
561
563
  });
562
564
  identityChallenges.set(response, identityHeaders);
565
+ paymentChallenges.set(response, paymentChallenge);
563
566
  }
564
567
  }
565
568
  if (isIssuedChallengeResponse(response))
@@ -594,7 +597,7 @@ function createMethodFn(parameters) {
594
597
  description,
595
598
  expires,
596
599
  header: credentialHeader,
597
- meta: effectiveMeta,
600
+ meta: challengeMeta,
598
601
  method,
599
602
  realm,
600
603
  request: parameters.request,
@@ -609,7 +612,7 @@ function createMethodFn(parameters) {
609
612
  description,
610
613
  expires,
611
614
  header: credentialHeader,
612
- meta: effectiveMeta,
615
+ meta: challengeMeta,
613
616
  method,
614
617
  realm,
615
618
  routeRequest: rest,
@@ -897,6 +900,7 @@ function createMethodFn(parameters) {
897
900
  const outcome = await IdentityServer.verify(input, paymentVerifiers, {
898
901
  challenge: credential.challenge,
899
902
  credential: parsedCredential,
903
+ nonce: ChallengeMeta.identity(credential.challenge.meta),
900
904
  });
901
905
  if (outcome.status !== 'valid') {
902
906
  const error = new Errors.VerificationFailedError({ reason: outcome.reason });
@@ -1348,6 +1352,7 @@ async function resolveRouteChallenge(parameters) {
1348
1352
  ? (await parameters.request({
1349
1353
  capturedRequest: parameters.capturedRequest,
1350
1354
  credential: parameters.credential,
1355
+ expires: parameters.credential?.challenge.expires ?? parameters.expires,
1351
1356
  request: merged,
1352
1357
  }))
1353
1358
  : merged;
@@ -1588,17 +1593,30 @@ const paymentAuthChallengeHeader = Constants.Headers.wwwAuthenticate;
1588
1593
  const identityChallenges = new WeakMap();
1589
1594
  /** Identity rejections (401/403) returned on a route's non-success branch. @internal */
1590
1595
  const identityRejections = new WeakSet();
1596
+ /**
1597
+ * A 402's Payment `WWW-Authenticate` value without its identity challenge, keyed by response.
1598
+ * Composers merge these and append the shared identity challenge once. @internal
1599
+ */
1600
+ const paymentChallenges = new WeakMap();
1591
1601
  /** Records a merged 402's identity challenge so an outer composer can forward it. @internal */
1592
- function withIdentityChallenge(response, headers) {
1593
- if (headers)
1602
+ function withIdentityChallenge(response, headers, paymentChallenge) {
1603
+ if (headers) {
1594
1604
  identityChallenges.set(response, headers);
1605
+ paymentChallenges.set(response, paymentChallenge);
1606
+ }
1595
1607
  return response;
1596
1608
  }
1597
1609
  const challengeHeaderMerges = [
1598
1610
  {
1599
1611
  name: paymentAuthChallengeHeader,
1600
1612
  values: (context) => context.challengeEntries
1601
- .map((entry) => entry.result.challenge.headers.get(paymentAuthChallengeHeader))
1613
+ .map((entry) => {
1614
+ const response = entry.result.challenge;
1615
+ // Identity challenges are appended once by the composer, not once per offer.
1616
+ return paymentChallenges.has(response)
1617
+ ? paymentChallenges.get(response)
1618
+ : response.headers.get(paymentAuthChallengeHeader);
1619
+ })
1602
1620
  .filter((value) => value !== null),
1603
1621
  merge: (values) => values,
1604
1622
  },
@@ -1639,9 +1657,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1639
1657
  });
1640
1658
  if (handlers.length === 0)
1641
1659
  throw new Error('compose() requires at least one handler');
1642
- // A 402 carries one identity challenge, bound to one payment challenge.
1643
- if (handlers.flatMap(getConfiguredOffers).filter((offer) => offer._paymentIdentity).length > 1)
1644
- throw new TypeError('A composed route can include at most one offer with payment-stage identity.');
1660
+ // A 402 carries one identity challenge, shared by its offers through the identity nonce, so
1661
+ // every offer that requires payment-stage identity must require the same verifiers.
1662
+ const identityOffers = handlers
1663
+ .flatMap(getConfiguredOffers)
1664
+ .flatMap((offer) => (offer._paymentIdentity ? [offer._paymentIdentity] : []));
1665
+ if (identityOffers.some((verifiers) => !sameVerifiers(verifiers, identityOffers[0])))
1666
+ throw new TypeError('Offers in a composed route must require the same payment-stage identity verifiers.');
1645
1667
  const offerSelector = selectOffers;
1646
1668
  const offerEntries = handlers.map((handler) => {
1647
1669
  const internal = handler._internal;
@@ -1809,8 +1831,8 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1809
1831
  ...unnegotiatedX402Responses,
1810
1832
  ]
1811
1833
  : challengeResponses;
1812
- // At most one offer carries an identity challenge (enforced when composing); forward it
1813
- // only if its payment challenge is still advertised after negotiation.
1834
+ // Offers share one identity challenge (enforced when composing); forward it only if an
1835
+ // offer that carries it is still advertised after negotiation.
1814
1836
  const identityHeaders = negotiatedChallengeResponses
1815
1837
  .map((response) => identityChallenges.get(response))
1816
1838
  .find((headers) => headers !== undefined);
@@ -1826,11 +1848,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1826
1848
  mergedHeaders.append(header.name, value);
1827
1849
  }
1828
1850
  }
1829
- // Identity WWW-Authenticate values already arrive in the merged offer value above.
1851
+ // The merged offer values above exclude identity challenges; append the shared one once.
1852
+ const paymentChallenge = mergedHeaders.get(paymentAuthChallengeHeader);
1830
1853
  if (identityHeaders)
1831
1854
  for (const [name, value] of identityHeaders)
1832
- if (name !== paymentAuthChallengeHeader.toLowerCase())
1833
- mergedHeaders.append(name, value);
1855
+ mergedHeaders.append(name, value);
1834
1856
  // Collect html-enabled handlers and their challenges
1835
1857
  const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
1836
1858
  const wantsHtml = input.headers.get('Accept')?.includes('text/html');
@@ -1867,7 +1889,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1867
1889
  });
1868
1890
  return {
1869
1891
  status: 402,
1870
- challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
1892
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
1871
1893
  };
1872
1894
  }
1873
1895
  // Non-HTML fallback: prefer the first Payment-auth body, otherwise use
@@ -1887,7 +1909,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1887
1909
  }
1888
1910
  return {
1889
1911
  status: 402,
1890
- challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
1912
+ challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
1891
1913
  };
1892
1914
  };
1893
1915
  const offers = handlers.flatMap(getConfiguredOffers);
@@ -1895,6 +1917,10 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1895
1917
  composed._internal = { offers, handlers: handlers, selectOffers };
1896
1918
  return composed;
1897
1919
  }
1920
+ /** Whether two verifier lists hold the same verifiers in the same order. */
1921
+ function sameVerifiers(a, b) {
1922
+ return a.length === b.length && a.every((verifier, i) => verifier === b[i]);
1923
+ }
1898
1924
  /** Returns the flattened configured offers accepted by a handler. */
1899
1925
  function getConfiguredOffers(handler) {
1900
1926
  const internal = handler._internal;
@@ -1,5 +1,11 @@
1
1
  /** Adds fresh issuance randomness while preserving application and route metadata. */
2
2
  export declare function withNonce(meta: Record<string, string> | undefined): Record<string, string>;
3
+ /** Returns fresh randomness for an identity nonce. */
4
+ export declare function createNonce(): string;
5
+ /** Adds an identity nonce to challenge metadata. */
6
+ export declare function withIdentity(meta: Record<string, string> | undefined, nonce: string | undefined): Record<string, string> | undefined;
7
+ /** Reads the identity nonce from challenge metadata. */
8
+ export declare function identity(meta: Record<string, string> | undefined): string | undefined;
3
9
  /** Removes issuance-only metadata when comparing stable route requirements. */
4
10
  export declare function routeMeta(meta: Record<string, string> | undefined): Record<string, string> | undefined;
5
11
  //# sourceMappingURL=challengeMeta.d.ts.map
@@ -6,9 +6,25 @@ export function withNonce(meta) {
6
6
  // Ox uses runtime-specific secure randomness, including Node without global Web Crypto.
7
7
  return { ...meta, [nonceKey]: Secp256k1.randomPrivateKey() };
8
8
  }
9
+ /** Reserved metadata key for the HMAC-bound identity nonce shared by every offer in one 402. */
10
+ const identityKey = '_mppx_identity';
11
+ /** Returns fresh randomness for an identity nonce. */
12
+ export function createNonce() {
13
+ return Secp256k1.randomPrivateKey();
14
+ }
15
+ /** Adds an identity nonce to challenge metadata. */
16
+ export function withIdentity(meta, nonce) {
17
+ if (nonce === undefined)
18
+ return meta;
19
+ return { ...meta, [identityKey]: nonce };
20
+ }
21
+ /** Reads the identity nonce from challenge metadata. */
22
+ export function identity(meta) {
23
+ return meta?.[identityKey];
24
+ }
9
25
  /** Removes issuance-only metadata when comparing stable route requirements. */
10
26
  export function routeMeta(meta) {
11
- const { [nonceKey]: _, ...rest } = meta ?? {};
27
+ const { [nonceKey]: _, [identityKey]: __, ...rest } = meta ?? {};
12
28
  return Object.keys(rest).length ? rest : undefined;
13
29
  }
14
30
  //# sourceMappingURL=challengeMeta.js.map
@@ -6,10 +6,22 @@ import * as z from '../../zod.js';
6
6
  import * as AutoSwap from '../internal/auto-swap.js';
7
7
  import * as Methods from '../Methods.js';
8
8
  import type * as AccountResolution from './ResolveAccount.js';
9
+ /**
10
+ * Seconds from now the client sets as the expiring-nonce `validBefore`,
11
+ * capped at the challenge expiry.
12
+ *
13
+ * Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
14
+ * remaining validity when they co-sign, so a 25s window leaves about 10s for
15
+ * clock skew between client and server, signing and round trips. It stays inside
16
+ * the server's default 30s confirmation timeout and within sponsor policies whose
17
+ * `maxValidityWindowSeconds` is 25 or more.
18
+ */
19
+ export declare const validityWindowSeconds = 25;
9
20
  /** Runtime context accepted by the Tempo charge client method. */
10
21
  export type ChargeContext = {
11
22
  account?: Account.getResolver.Parameters['account'] | undefined;
12
23
  autoSwap?: AutoSwap.resolve.Value | undefined;
24
+ feeToken?: Address | undefined;
13
25
  mode?: Methods.ChargeMode | undefined;
14
26
  };
15
27
  /**
@@ -30,6 +42,7 @@ export declare function charge(parameters?: charge.Parameters): {
30
42
  readonly context?: z.ZodMiniObject<{
31
43
  account: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | Account.Account | undefined, `0x${string}` | Account.Account | undefined>>;
32
44
  autoSwap: z.ZodMiniOptional<z.ZodMiniCustom<AutoSwap.resolve.Value | undefined, AutoSwap.resolve.Value | undefined>>;
45
+ feeToken: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
33
46
  mode: z.ZodMiniOptional<z.ZodMiniEnum<{
34
47
  pull: "pull";
35
48
  push: "push";
@@ -181,6 +194,7 @@ export declare function charge(parameters?: charge.Parameters): {
181
194
  }, {
182
195
  account?: `0x${string}` | Account.Account | undefined;
183
196
  autoSwap?: AutoSwap.resolve.Value | undefined;
197
+ feeToken?: `0x${string}` | undefined;
184
198
  mode?: "pull" | "push" | undefined;
185
199
  }>;
186
200
  };
@@ -199,6 +213,12 @@ export declare namespace charge {
199
213
  autoSwap?: AutoSwap | undefined;
200
214
  /** Client identifier used to derive the client fingerprint in attribution memos. */
201
215
  clientId?: string | undefined;
216
+ /**
217
+ * Token the payer pays its transaction fee in, when the challenge is not fee-sponsored.
218
+ * Set it for an access key whose spending limits do not cover the default fee token,
219
+ * such as a key limited to OUSD paying a USDC.e charge.
220
+ */
221
+ feeToken?: Address | undefined;
202
222
  /**
203
223
  * Chain ID this client is willing to pay on. When set, the client rejects
204
224
  * any challenge whose `methodDetails.chainId` differs, and signs on this
@@ -17,9 +17,21 @@ import { defaultFeeTokens, resolveFeeToken } from '../internal/fee-token.js';
17
17
  import * as MachineTokenCharge from '../internal/machine-token-charge.js';
18
18
  import * as Proof from '../internal/proof.js';
19
19
  import * as Methods from '../Methods.js';
20
+ /**
21
+ * Seconds from now the client sets as the expiring-nonce `validBefore`,
22
+ * capped at the challenge expiry.
23
+ *
24
+ * Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
25
+ * remaining validity when they co-sign, so a 25s window leaves about 10s for
26
+ * clock skew between client and server, signing and round trips. It stays inside
27
+ * the server's default 30s confirmation timeout and within sponsor policies whose
28
+ * `maxValidityWindowSeconds` is 25 or more.
29
+ */
30
+ export const validityWindowSeconds = 25;
20
31
  const chargeContextSchema = z.object({
21
32
  account: z.optional(z.custom()),
22
33
  autoSwap: z.optional(z.custom()),
34
+ feeToken: z.optional(z.custom()),
23
35
  mode: z.optional(z.enum(Methods.chargeModes)),
24
36
  });
25
37
  /**
@@ -177,15 +189,18 @@ export function charge(parameters = {}) {
177
189
  }
178
190
  const calls = machineTokenRoute?.calls ?? [...(swapCalls ?? []), ...transferCalls];
179
191
  const allowedFeeTokens = defaultFeeTokens(chainId);
180
- const feeToken = machineTokenRoute || isMachineTokenCurrency
181
- ? await resolveFeeToken({
182
- account: account.address,
183
- allowedTokens: allowedFeeTokens,
184
- candidateTokens: allowedFeeTokens,
185
- client,
186
- prioritizeCandidates: true,
187
- })
188
- : undefined;
192
+ const explicitFeeToken = context?.feeToken ?? parameters.feeToken;
193
+ const feeToken = explicitFeeToken
194
+ ? explicitFeeToken
195
+ : machineTokenRoute || isMachineTokenCurrency
196
+ ? await resolveFeeToken({
197
+ account: account.address,
198
+ allowedTokens: allowedFeeTokens,
199
+ candidateTokens: allowedFeeTokens,
200
+ client,
201
+ prioritizeCandidates: true,
202
+ })
203
+ : undefined;
189
204
  const mode = (() => {
190
205
  const explicitMode = context?.mode ?? parameters.mode;
191
206
  if (explicitMode) {
@@ -199,7 +214,7 @@ export function charge(parameters = {}) {
199
214
  return supportedModes[0];
200
215
  })();
201
216
  const validBefore = (() => {
202
- const defaultExpiry = Math.floor(Date.now() / 1000) + 25;
217
+ const defaultExpiry = Math.floor(Date.now() / 1000) + validityWindowSeconds;
203
218
  if (!challenge.expires)
204
219
  return defaultExpiry;
205
220
  // `validBefore` is an exclusive whole-second bound, so cap at the second
@@ -236,6 +251,22 @@ export function charge(parameters = {}) {
236
251
  source: Proof.proofSource({ address: account.address, chainId }),
237
252
  });
238
253
  }
254
+ // The fee payer rejects a sponsored transaction with under the minimum remaining validity,
255
+ // so refuse before signing rather than produce a credential the server must reject.
256
+ // The fee payer checks the signed `validBefore`, which is fixed before preparation, so the
257
+ // re-check measures both it and the challenge expiry against the current time.
258
+ const assertSponsorable = () => {
259
+ if (methodDetails?.feePayer !== true)
260
+ return;
261
+ const minimumMs = defaults.minRemainingValiditySeconds * 1000;
262
+ const expiresMs = challenge.expires ? new Date(challenge.expires).getTime() : Infinity;
263
+ const now = Date.now();
264
+ if (!(expiresMs - now >= minimumMs))
265
+ throw new Error(`Sponsored challenge expires in less than ${defaults.minRemainingValiditySeconds} seconds; the fee payer would reject the transaction. Request a new challenge.`);
266
+ if (!(validBefore * 1000 - now >= minimumMs))
267
+ throw new Error(`Sponsored transaction preparation took too long: less than ${defaults.minRemainingValiditySeconds} seconds of its validity window remain, so the fee payer would reject it.`);
268
+ };
269
+ assertSponsorable();
239
270
  const prepared = await prepareTransactionRequest(client, {
240
271
  account,
241
272
  calls,
@@ -252,6 +283,8 @@ export function charge(parameters = {}) {
252
283
  delete sponsored.feeToken;
253
284
  sponsored.feePayer = true;
254
285
  }
286
+ // Preparation is asynchronous: check again immediately before signing.
287
+ assertSponsorable();
255
288
  const signature = await signTransaction(client, prepared);
256
289
  return Credential.serialize({
257
290
  challenge,
@@ -24,6 +24,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
24
24
  readonly context?: import("zod/mini").ZodMiniObject<{
25
25
  account: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | import("../../viem/Account.js").Account | undefined, `0x${string}` | import("../../viem/Account.js").Account | undefined>>;
26
26
  autoSwap: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<import("../internal/auto-swap.js").resolve.Value | undefined, import("../internal/auto-swap.js").resolve.Value | undefined>>;
27
+ feeToken: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
27
28
  mode: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniEnum<{
28
29
  pull: "pull";
29
30
  push: "push";
@@ -175,6 +176,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
175
176
  }, {
176
177
  account?: `0x${string}` | import("../../viem/Account.js").Account | undefined;
177
178
  autoSwap?: import("../internal/auto-swap.js").resolve.Value | undefined;
179
+ feeToken?: `0x${string}` | undefined;
178
180
  mode?: "pull" | "push" | undefined;
179
181
  }>;
180
182
  }, import("../../Method.js").Client<{
@@ -37,6 +37,15 @@ export declare const machineToken: Readonly<{
37
37
  * runtimes should set `decimals` explicitly to match their token.
38
38
  */
39
39
  export declare const decimals = 6;
40
+ /**
41
+ * Minimum remaining validity, in seconds, of a fee-sponsored transaction.
42
+ *
43
+ * Servers refuse to co-sign a sponsored transaction whose `validBefore` is
44
+ * closer than this, clients refuse to sign for a sponsored challenge that
45
+ * expires sooner, and servers refuse to issue a sponsored challenge that
46
+ * expires sooner after issuance.
47
+ */
48
+ export declare const minRemainingValiditySeconds = 15;
40
49
  /** Default payment-channel escrow contract addresses per chain. */
41
50
  export declare const escrowContract: {
42
51
  readonly 4217: '0x33b901018174DDabE4841042ab76ba85D4e24f25';
@@ -37,6 +37,15 @@ export const machineToken = Object.freeze({
37
37
  * runtimes should set `decimals` explicitly to match their token.
38
38
  */
39
39
  export const decimals = 6;
40
+ /**
41
+ * Minimum remaining validity, in seconds, of a fee-sponsored transaction.
42
+ *
43
+ * Servers refuse to co-sign a sponsored transaction whose `validBefore` is
44
+ * closer than this, clients refuse to sign for a sponsored challenge that
45
+ * expires sooner, and servers refuse to issue a sponsored challenge that
46
+ * expires sooner after issuance.
47
+ */
48
+ export const minRemainingValiditySeconds = 15;
40
49
  /** Default payment-channel escrow contract addresses per chain. */
41
50
  export const escrowContract = {
42
51
  [chainId.mainnet]: '0x33b901018174DDabE4841042ab76ba85D4e24f25',
@@ -445,8 +445,8 @@ export function assertRemainingValidity(parameters) {
445
445
  return fail('fee-sponsored transaction must declare validBefore for the expiring nonce');
446
446
  if (validBefore <= Math.floor(now.getTime() / 1_000))
447
447
  fail('fee-sponsored transaction has already expired');
448
- if (validBefore < Math.ceil(now.getTime() / 1_000) + 15)
449
- fail('fee-sponsored transaction needs at least 15 seconds of remaining validity');
448
+ if (validBefore < Math.ceil(now.getTime() / 1_000) + defaults.minRemainingValiditySeconds)
449
+ fail(`fee-sponsored transaction needs at least ${defaults.minRemainingValiditySeconds} seconds of remaining validity`);
450
450
  }
451
451
  /** Validates sponsor fee policy limits for a fee-payer transaction. */
452
452
  export function assertTransactionPolicy(parameters) {
@@ -350,7 +350,7 @@ export function charge(parameters = {}) {
350
350
  }
351
351
  : undefined,
352
352
  // TODO: dedupe `{charge,session}.request`
353
- async request({ credential, request }) {
353
+ async request({ credential, expires, request }) {
354
354
  const chainId = await (async () => {
355
355
  if (request.chainId)
356
356
  return request.chainId;
@@ -385,6 +385,17 @@ export function charge(parameters = {}) {
385
385
  return account ?? (remoteFeePayer ? true : undefined);
386
386
  return account || remoteFeePayer ? true : undefined;
387
387
  })();
388
+ // Issued sponsored challenges must leave room for the fee payer's
389
+ // minimum remaining validity, or every credential for them is rejected.
390
+ // Zero-amount proofs and push-only routes never use the fee payer.
391
+ const modes = (request.supportedModes ?? supportedModes);
392
+ if (!credential &&
393
+ resolvedFeePayer &&
394
+ Number(request.amount) > 0 &&
395
+ (modes === undefined || modes.includes('pull')) &&
396
+ expires !== undefined &&
397
+ !(Date.parse(expires) - Date.now() >= defaults.minRemainingValiditySeconds * 1000))
398
+ throw new Error(`Sponsored charge challenges must expire at least ${defaults.minRemainingValiditySeconds} seconds after issuance.`);
388
399
  return {
389
400
  ...request,
390
401
  machineTokenEnabled: request.machineTokenEnabled || undefined,
@@ -1031,6 +1042,8 @@ const ambiguousErrorNames = new Set([
1031
1042
  * - JSON-RPC code `4`: the EIP-7966 timeout for a pooled, unconfirmed transaction.
1032
1043
  * - Messages containing the words "already known" or "known transaction": already
1033
1044
  * pooled ("unknown transaction type" does not match).
1045
+ * - Tempo's `ExpiringNonceReplay`: an expiring-nonce transaction with this hash was
1046
+ * already accepted, such as when a transport retries a send the node took.
1034
1047
  */
1035
1048
  function isAmbiguousBroadcastError(error) {
1036
1049
  for (let cause = error; cause; cause = cause.cause) {
@@ -1043,7 +1056,8 @@ function isAmbiguousBroadcastError(error) {
1043
1056
  return true;
1044
1057
  if (code === 4)
1045
1058
  return true;
1046
- if (typeof message === 'string' && /\balready ?known\b|\bknown transaction\b/i.test(message))
1059
+ if (typeof message === 'string' &&
1060
+ /\balready ?known\b|\bknown transaction\b|\bExpiringNonceReplay\b/i.test(message))
1047
1061
  return true;
1048
1062
  }
1049
1063
  return false;