mppx 0.5.11 → 0.5.13

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -225,13 +225,10 @@ describe('Session', () => {
225
225
  // drain
226
226
  }
227
227
 
228
- const calledHeaders = (mockFetch.mock.calls[0]![1] as RequestInit).headers as Record<
229
- string,
230
- string
231
- >
232
- expect(calledHeaders['content-type']).toBe('application/json')
233
- expect(calledHeaders['x-custom']).toBe('value')
234
- expect(calledHeaders.Accept).toBe('text/event-stream')
228
+ const calledHeaders = new Headers((mockFetch.mock.calls[0]![1] as RequestInit).headers)
229
+ expect(calledHeaders.get('content-type')).toBe('application/json')
230
+ expect(calledHeaders.get('x-custom')).toBe('value')
231
+ expect(calledHeaders.get('accept')).toBe('text/event-stream')
235
232
  })
236
233
  })
237
234
 
@@ -1,2 +1,3 @@
1
+ export * as Proof from './Proof.js'
1
2
  export * as Methods from './Methods.js'
2
3
  export * as Session from './session/index.js'
@@ -285,6 +285,80 @@ describe('prepareSponsoredTransaction', () => {
285
285
  ).not.toThrow()
286
286
  })
287
287
 
288
+ test('accepts higher Moderato priority fees by default', () => {
289
+ expect(() =>
290
+ prepareSponsoredTransaction({
291
+ account: sponsor,
292
+ chainId: 42431,
293
+ details,
294
+ expectedFeeToken: bogus,
295
+ transaction: {
296
+ ...baseTransaction,
297
+ gas: 626_497n,
298
+ maxFeePerGas: 24_000_000_000n,
299
+ maxPriorityFeePerGas: 24_000_000_000n,
300
+ } as any,
301
+ }),
302
+ ).not.toThrow()
303
+ })
304
+
305
+ test('accepts fee-payer policy overrides', () => {
306
+ expect(() =>
307
+ prepareSponsoredTransaction({
308
+ account: sponsor,
309
+ chainId: 4217,
310
+ details,
311
+ expectedFeeToken: bogus,
312
+ policy: { maxPriorityFeePerGas: 50_000_000_000n },
313
+ transaction: {
314
+ ...baseTransaction,
315
+ chainId: 4217,
316
+ gas: 626_497n,
317
+ maxFeePerGas: 24_000_000_000n,
318
+ maxPriorityFeePerGas: 24_000_000_000n,
319
+ } as any,
320
+ }),
321
+ ).not.toThrow()
322
+ })
323
+
324
+ test('error: rejects excessive priority fee under a custom policy override', () => {
325
+ expect(() =>
326
+ prepareSponsoredTransaction({
327
+ account: sponsor,
328
+ chainId: 4217,
329
+ details,
330
+ expectedFeeToken: bogus,
331
+ policy: { maxPriorityFeePerGas: 20_000_000_000n },
332
+ transaction: {
333
+ ...baseTransaction,
334
+ chainId: 4217,
335
+ gas: 626_497n,
336
+ maxFeePerGas: 24_000_000_000n,
337
+ maxPriorityFeePerGas: 24_000_000_000n,
338
+ } as any,
339
+ }),
340
+ ).toThrow('maxPriorityFeePerGas exceeds sponsor policy')
341
+ })
342
+
343
+ test('ignores undefined policy override values', () => {
344
+ expect(() =>
345
+ prepareSponsoredTransaction({
346
+ account: sponsor,
347
+ chainId: 4217,
348
+ details,
349
+ expectedFeeToken: bogus,
350
+ policy: { maxPriorityFeePerGas: undefined } as any,
351
+ transaction: {
352
+ ...baseTransaction,
353
+ chainId: 4217,
354
+ gas: 626_497n,
355
+ maxFeePerGas: 24_000_000_000n,
356
+ maxPriorityFeePerGas: 24_000_000_000n,
357
+ } as any,
358
+ }),
359
+ ).toThrow('maxPriorityFeePerGas exceeds sponsor policy')
360
+ })
361
+
288
362
  test('drops unknown top-level fields from the sponsored transaction', () => {
289
363
  const sponsored = prepareSponsoredTransaction({
290
364
  account: sponsor,
@@ -322,7 +396,7 @@ describe('prepareSponsoredTransaction', () => {
322
396
  transaction: {
323
397
  ...baseTransaction,
324
398
  gas: 1_500_000n,
325
- maxFeePerGas: 10_000_000_000n,
399
+ maxFeePerGas: 50_000_000_000n,
326
400
  } as any,
327
401
  }),
328
402
  ).toThrow('total fee budget exceeds sponsor policy')
@@ -5,6 +5,7 @@ import { decodeFunctionData } from 'viem'
5
5
  import { Abis, Addresses, Transaction } from 'viem/tempo'
6
6
 
7
7
  import * as TempoAddress_internal from './address.js'
8
+ import * as defaults from './defaults.js'
8
9
  import * as Selectors from './selectors.js'
9
10
 
10
11
  /** Returns true if the serialized transaction has a Tempo envelope prefix. */
@@ -26,13 +27,47 @@ export const callScopes = [
26
27
  [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo],
27
28
  ]
28
29
 
29
- const policy = {
30
+ export type Policy = {
31
+ maxGas: bigint
32
+ maxFeePerGas: bigint
33
+ maxPriorityFeePerGas: bigint
34
+ maxTotalFee: bigint
35
+ maxValidityWindowSeconds: number
36
+ }
37
+
38
+ /**
39
+ * maxTotalFee must be high enough to cover `transferWithMemo` and
40
+ * swap transactions at peak gas prices. Bumped from 0.01 ETH in #327.
41
+ */
42
+ const defaultPolicy: Policy = {
30
43
  maxGas: 2_000_000n,
31
44
  maxFeePerGas: 100_000_000_000n,
32
45
  maxPriorityFeePerGas: 10_000_000_000n,
33
- maxTotalFee: 10_000_000_000_000_000n,
46
+ maxTotalFee: 50_000_000_000_000_000n,
34
47
  maxValidityWindowSeconds: 15 * 60,
35
- } as const
48
+ }
49
+
50
+ const policyByChainId = {
51
+ [defaults.chainId.mainnet]: defaultPolicy,
52
+ // Moderato regularly needs a higher priority fee than mainnet.
53
+ [defaults.chainId.testnet]: {
54
+ ...defaultPolicy,
55
+ maxPriorityFeePerGas: 50_000_000_000n,
56
+ },
57
+ } as const satisfies Record<defaults.ChainId, Policy>
58
+
59
+ function getPolicy(chainId: number, overrides: Partial<Policy> | undefined): Policy {
60
+ const base = policyByChainId[chainId as defaults.ChainId] ?? defaultPolicy
61
+ if (!overrides) return base
62
+
63
+ return {
64
+ maxGas: overrides.maxGas ?? base.maxGas,
65
+ maxFeePerGas: overrides.maxFeePerGas ?? base.maxFeePerGas,
66
+ maxPriorityFeePerGas: overrides.maxPriorityFeePerGas ?? base.maxPriorityFeePerGas,
67
+ maxTotalFee: overrides.maxTotalFee ?? base.maxTotalFee,
68
+ maxValidityWindowSeconds: overrides.maxValidityWindowSeconds ?? base.maxValidityWindowSeconds,
69
+ }
70
+ }
36
71
 
37
72
  /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
38
73
  export function validateCalls(
@@ -85,6 +120,7 @@ export function prepareSponsoredTransaction(parameters: {
85
120
  details: Record<string, string>
86
121
  expectedFeeToken?: TempoAddress.Address | undefined
87
122
  now?: Date | undefined
123
+ policy?: Partial<Policy> | undefined
88
124
  transaction: ReturnType<(typeof Transaction)['deserialize']>
89
125
  }) {
90
126
  const {
@@ -94,8 +130,10 @@ export function prepareSponsoredTransaction(parameters: {
94
130
  details,
95
131
  expectedFeeToken,
96
132
  now = new Date(),
133
+ policy: policyOverrides,
97
134
  transaction,
98
135
  } = parameters
136
+ const policy = getPolicy(chainId, policyOverrides)
99
137
 
100
138
  const {
101
139
  accessList,
@@ -15,7 +15,7 @@ import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from
15
15
  import { beforeAll, describe, expect, test } from 'vp/test'
16
16
  import * as Http from '~test/Http.js'
17
17
  import { closeChannelOnChain, deployEscrow, openChannel } from '~test/tempo/session.js'
18
- import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
18
+ import { accounts, asset, chain, client, fundAccount, http } from '~test/tempo/viem.js'
19
19
 
20
20
  import * as Store from '../../Store.js'
21
21
  import * as Attribution from '../Attribution.js'
@@ -25,6 +25,11 @@ import { signVoucher } from '../session/Voucher.js'
25
25
  const realm = 'api.example.com'
26
26
  const secretKey = 'test-secret-key'
27
27
 
28
+ type ProofAccessKeyContext = {
29
+ accessKey: ReturnType<typeof Account.fromSecp256k1>
30
+ rootAccount: (typeof accounts)[number]
31
+ }
32
+
28
33
  const server = Mppx_server.create({
29
34
  methods: [
30
35
  tempo_server.charge({
@@ -119,6 +124,166 @@ describe('tempo', () => {
119
124
  httpServer.close()
120
125
  })
121
126
 
127
+ test('behavior: client rejects unsupported explicit push mode', async () => {
128
+ const mppx = Mppx_client.create({
129
+ polyfill: false,
130
+ methods: [
131
+ tempo_client({
132
+ account: accounts[1],
133
+ mode: 'push',
134
+ getClient: () => client,
135
+ }),
136
+ ],
137
+ })
138
+
139
+ const httpServer = await Http.createServer(async (req, res) => {
140
+ const result = await Mppx_server.toNodeListener(
141
+ server.charge({ amount: '1', decimals: 6, supportedModes: ['pull'] }),
142
+ )(req, res)
143
+ if (result.status === 402) return
144
+ res.end('OK')
145
+ })
146
+
147
+ const response = await fetch(httpServer.url)
148
+ expect(response.status).toBe(402)
149
+
150
+ await expect(mppx.createCredential(response)).rejects.toThrow(
151
+ 'Challenge does not support push mode.',
152
+ )
153
+
154
+ httpServer.close()
155
+ })
156
+
157
+ test('behavior: falls back to pull when push is not advertised', async () => {
158
+ const jsonRpcClient = createClient({
159
+ account: accounts[0].address,
160
+ chain,
161
+ transport: http(),
162
+ })
163
+
164
+ const mppx = Mppx_client.create({
165
+ polyfill: false,
166
+ methods: [
167
+ tempo_client({
168
+ getClient: () => jsonRpcClient,
169
+ }),
170
+ ],
171
+ })
172
+
173
+ const httpServer = await Http.createServer(async (req, res) => {
174
+ const result = await Mppx_server.toNodeListener(
175
+ server.charge({
176
+ amount: '1',
177
+ decimals: 6,
178
+ recipient: accounts[2].address,
179
+ supportedModes: ['pull'],
180
+ }),
181
+ )(req, res)
182
+ if (result.status === 402) return
183
+ res.end('OK')
184
+ })
185
+
186
+ const response = await fetch(httpServer.url)
187
+ expect(response.status).toBe(402)
188
+
189
+ const credential = Credential.deserialize<{ type: 'hash' | 'proof' | 'transaction' }>(
190
+ await mppx.createCredential(response),
191
+ )
192
+ expect(credential.payload.type).toBe('transaction')
193
+
194
+ const authResponse = await fetch(httpServer.url, {
195
+ headers: { Authorization: Credential.serialize(credential) },
196
+ })
197
+ expect(authResponse.status).toBe(200)
198
+
199
+ httpServer.close()
200
+ })
201
+
202
+ test('behavior: rejects hash credential when challenge supports only pull', async () => {
203
+ const httpServer = await Http.createServer(async (req, res) => {
204
+ const result = await Mppx_server.toNodeListener(
205
+ server.charge({ amount: '1', decimals: 6, supportedModes: ['pull'] }),
206
+ )(req, res)
207
+ if (result.status === 402) return
208
+ res.end('OK')
209
+ })
210
+
211
+ const response = await fetch(httpServer.url)
212
+ expect(response.status).toBe(402)
213
+
214
+ const challenge = Challenge.fromResponse(response, {
215
+ methods: [tempo_client.charge()],
216
+ })
217
+
218
+ const { receipt } = await Actions.token.transferSync(client, {
219
+ account: accounts[1],
220
+ amount: BigInt(challenge.request.amount),
221
+ to: challenge.request.recipient as Hex.Hex,
222
+ token: challenge.request.currency as Hex.Hex,
223
+ })
224
+
225
+ const credential = Credential.from({
226
+ challenge,
227
+ payload: { hash: receipt.transactionHash, type: 'hash' as const },
228
+ })
229
+
230
+ const rejected = await fetch(httpServer.url, {
231
+ headers: { Authorization: Credential.serialize(credential) },
232
+ })
233
+ expect(rejected.status).toBe(402)
234
+
235
+ const body = (await rejected.json()) as { detail: string }
236
+ expect(body.detail).toContain('Hash credentials are not supported for this challenge.')
237
+
238
+ httpServer.close()
239
+ })
240
+
241
+ test('behavior: rejects transaction credential when challenge supports only push', async () => {
242
+ const httpServer = await Http.createServer(async (req, res) => {
243
+ const result = await Mppx_server.toNodeListener(
244
+ server.charge({ amount: '1', decimals: 6, supportedModes: ['push'] }),
245
+ )(req, res)
246
+ if (result.status === 402) return
247
+ res.end('OK')
248
+ })
249
+
250
+ const response = await fetch(httpServer.url)
251
+ expect(response.status).toBe(402)
252
+
253
+ const challenge = Challenge.fromResponse(response, {
254
+ methods: [tempo_client.charge()],
255
+ })
256
+
257
+ const prepared = await prepareTransactionRequest(client, {
258
+ account: accounts[1]!,
259
+ calls: [
260
+ Actions.token.transfer.call({
261
+ amount: BigInt(challenge.request.amount),
262
+ to: challenge.request.recipient as Hex.Hex,
263
+ token: challenge.request.currency as Hex.Hex,
264
+ }),
265
+ ],
266
+ nonceKey: 'expiring',
267
+ } as never)
268
+ prepared.gas = prepared.gas! + 5_000n
269
+ const signature = await signTransaction(client, prepared as never)
270
+
271
+ const credential = Credential.from({
272
+ challenge,
273
+ payload: { signature, type: 'transaction' as const },
274
+ })
275
+
276
+ const rejected = await fetch(httpServer.url, {
277
+ headers: { Authorization: Credential.serialize(credential) },
278
+ })
279
+ expect(rejected.status).toBe(402)
280
+
281
+ const body = (await rejected.json()) as { detail: string }
282
+ expect(body.detail).toContain('Transaction credentials are not supported for this challenge.')
283
+
284
+ httpServer.close()
285
+ })
286
+
122
287
  test('behavior: rejects replayed transaction hash', async () => {
123
288
  const dedupServer = Mppx_server.create({
124
289
  methods: [
@@ -2114,6 +2279,124 @@ describe('tempo', () => {
2114
2279
  httpServer.close()
2115
2280
  })
2116
2281
 
2282
+ for (const testCase of [
2283
+ {
2284
+ name: 'accepts proof signed by an authorized access key for the root source',
2285
+ expectedStatus: 200,
2286
+ async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
2287
+ await Actions.accessKey.authorizeSync(client, {
2288
+ account: rootAccount,
2289
+ accessKey,
2290
+ feeToken: asset,
2291
+ })
2292
+ },
2293
+ },
2294
+ {
2295
+ name: 'rejects proof signed by an unauthorized access key for the root source',
2296
+ expectedDetail: 'Proof signature does not match source.',
2297
+ expectedStatus: 402,
2298
+ },
2299
+ {
2300
+ name: 'rejects proof signed by a revoked access key for the root source',
2301
+ expectedDetail: 'Proof signature does not match source.',
2302
+ expectedStatus: 402,
2303
+ async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
2304
+ await Actions.accessKey.authorizeSync(client, {
2305
+ account: rootAccount,
2306
+ accessKey,
2307
+ feeToken: asset,
2308
+ })
2309
+ await fundAccount({ address: rootAccount.address, token: asset })
2310
+ await Actions.accessKey.revokeSync(client, {
2311
+ account: rootAccount,
2312
+ accessKey,
2313
+ feeToken: asset,
2314
+ })
2315
+ },
2316
+ },
2317
+ {
2318
+ name: 'rejects proof signed by an expired access key for the root source',
2319
+ expectedDetail: 'Proof signature does not match source.',
2320
+ expectedStatus: 402,
2321
+ async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
2322
+ await Actions.accessKey.authorizeSync(client, {
2323
+ account: rootAccount,
2324
+ accessKey,
2325
+ expiry: Math.floor(Date.now() / 1000) + 10,
2326
+ feeToken: asset,
2327
+ })
2328
+
2329
+ const metadata = await Actions.accessKey.getMetadata(client, {
2330
+ account: rootAccount.address,
2331
+ accessKey,
2332
+ })
2333
+ const originalNow = Date.now
2334
+ Date.now = () => (Number(metadata.expiry) + 5) * 1000
2335
+
2336
+ return () => {
2337
+ Date.now = originalNow
2338
+ }
2339
+ },
2340
+ },
2341
+ ] as const) {
2342
+ test(`behavior: ${testCase.name}`, async () => {
2343
+ const rootAccount = accounts[1]
2344
+ const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
2345
+ access: rootAccount,
2346
+ })
2347
+
2348
+ let cleanup: (() => void) | undefined
2349
+ let httpServer: Awaited<ReturnType<typeof Http.createServer>> | undefined
2350
+
2351
+ try {
2352
+ const maybeCleanup = await testCase.prepare?.({ accessKey, rootAccount })
2353
+ cleanup = typeof maybeCleanup === 'function' ? maybeCleanup : undefined
2354
+
2355
+ httpServer = await Http.createServer(async (req, res) => {
2356
+ const result = await Mppx_server.toNodeListener(
2357
+ server.charge({ amount: '0', decimals: 6 }),
2358
+ )(req, res)
2359
+ if (result.status === 402) return
2360
+ res.end('OK')
2361
+ })
2362
+
2363
+ const response1 = await fetch(httpServer.url)
2364
+ expect(response1.status).toBe(402)
2365
+
2366
+ const challenge = Challenge.fromResponse(response1, {
2367
+ methods: [tempo_client.charge()],
2368
+ })
2369
+
2370
+ const signature = await signTypedData(client, {
2371
+ account: accessKey,
2372
+ domain: Proof.domain(chain.id),
2373
+ types: Proof.types,
2374
+ primaryType: 'Proof',
2375
+ message: Proof.message(challenge.id),
2376
+ })
2377
+
2378
+ const credential = Credential.from({
2379
+ challenge,
2380
+ payload: { signature, type: 'proof' as const },
2381
+ source: `did:pkh:eip155:${chain.id}:${rootAccount.address}`,
2382
+ })
2383
+
2384
+ const response2 = await fetch(httpServer.url, {
2385
+ headers: { Authorization: Credential.serialize(credential) },
2386
+ })
2387
+ expect(response2.status).toBe(testCase.expectedStatus)
2388
+
2389
+ if (testCase.expectedDetail) {
2390
+ const body = (await response2.json()) as { detail: string }
2391
+ expect(body.detail).toContain(testCase.expectedDetail)
2392
+ }
2393
+ } finally {
2394
+ cleanup?.()
2395
+ httpServer?.close()
2396
+ }
2397
+ })
2398
+ }
2399
+
2117
2400
  test('behavior: rejects replayed proof credential when store is configured', async () => {
2118
2401
  const replayStore = Store.memory()
2119
2402
  const server_ = Mppx_server.create({
@@ -2993,6 +3276,31 @@ describe('tempo', () => {
2993
3276
  })
2994
3277
  expect(challenge.request.currency).toBe(asset)
2995
3278
  })
3279
+
3280
+ test('challenge contains supportedModes when configured', async () => {
3281
+ const handler = Mppx_server.create({
3282
+ methods: [
3283
+ tempo_server.charge({
3284
+ getClient: () => client,
3285
+ account: accounts[0].address,
3286
+ currency: asset,
3287
+ }),
3288
+ ],
3289
+ realm,
3290
+ secretKey,
3291
+ })
3292
+
3293
+ const result = await handler.charge({ amount: '1', supportedModes: ['pull'] })(
3294
+ new Request('https://example.com'),
3295
+ )
3296
+ expect(result.status).toBe(402)
3297
+ if (result.status !== 402) throw new Error()
3298
+
3299
+ const challenge = Challenge.fromResponse(result.challenge, {
3300
+ methods: [tempo_client.charge()],
3301
+ })
3302
+ expect(challenge.request.methodDetails?.supportedModes).toEqual(['pull'])
3303
+ })
2996
3304
  })
2997
3305
 
2998
3306
  describe('attribution memo', () => {
@@ -1,6 +1,8 @@
1
+ import * as SignatureEnvelope from 'ox/tempo/SignatureEnvelope'
1
2
  import {
2
3
  decodeFunctionData,
3
4
  formatUnits,
5
+ hashTypedData,
4
6
  keccak256,
5
7
  parseEventLogs,
6
8
  type TransactionReceipt,
@@ -58,6 +60,7 @@ export function charge<const parameters extends charge.Parameters>(
58
60
  decimals = defaults.decimals,
59
61
  description,
60
62
  externalId,
63
+ feePayerPolicy,
61
64
  html,
62
65
  memo,
63
66
  waitForConfirmation = true,
@@ -160,6 +163,9 @@ export function charge<const parameters extends charge.Parameters>(
160
163
 
161
164
  const { amount, methodDetails } = resolvedRequest
162
165
  const expires = challenge.expires
166
+ const supportedModes = methodDetails?.supportedModes as
167
+ | readonly Methods.ChargeMode[]
168
+ | undefined
163
169
 
164
170
  const currency = resolvedRequest.currency as `0x${string}`
165
171
  const recipient = resolvedRequest.recipient as `0x${string}`
@@ -176,6 +182,9 @@ export function charge<const parameters extends charge.Parameters>(
176
182
 
177
183
  switch (payload.type) {
178
184
  case 'hash': {
185
+ if (supportedModes && !supportedModes.includes('push'))
186
+ throw new MismatchError('Hash credentials are not supported for this challenge.', {})
187
+
179
188
  const hash = payload.hash as `0x${string}`
180
189
  if (!(await markHashUsed(store, hash))) {
181
190
  throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
@@ -227,7 +236,21 @@ export function charge<const parameters extends charge.Parameters>(
227
236
  message: Proof.message(challenge.id),
228
237
  signature: payload.signature as `0x${string}`,
229
238
  })
230
- if (!valid) throw new MismatchError('Proof signature does not match source.', {})
239
+ if (!valid) {
240
+ const proofSigner = recoverAuthorizedProofSigner({
241
+ chainId: resolvedChainId,
242
+ challengeId: challenge.id,
243
+ signature: payload.signature as `0x${string}`,
244
+ sourceAddress: source.address,
245
+ })
246
+ const authorized = proofSigner
247
+ ? await isActiveAccessKey(client, {
248
+ accessKey: proofSigner,
249
+ account: source.address,
250
+ })
251
+ : false
252
+ if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
253
+ }
231
254
 
232
255
  if (proofStore && !(await markProofUsed(proofStore, challenge.id))) {
233
256
  throw new VerificationFailedError({ reason: 'Proof credential has already been used' })
@@ -242,6 +265,12 @@ export function charge<const parameters extends charge.Parameters>(
242
265
  }
243
266
 
244
267
  case 'transaction': {
268
+ if (supportedModes && !supportedModes.includes('pull'))
269
+ throw new MismatchError(
270
+ 'Transaction credentials are not supported for this challenge.',
271
+ {},
272
+ )
273
+
245
274
  const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
246
275
 
247
276
  // Pre-broadcast dedup: catch exact byte-for-byte replays early.
@@ -285,6 +314,7 @@ export function charge<const parameters extends charge.Parameters>(
285
314
  chainId: chainId ?? client.chain!.id,
286
315
  details: { amount, currency, recipient },
287
316
  expectedFeeToken,
317
+ policy: feePayerPolicy,
288
318
  transaction: {
289
319
  ...transaction,
290
320
  ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
@@ -369,6 +399,15 @@ export declare namespace charge {
369
399
  type Parameters = {
370
400
  /** Render payment page when Accept header is text/html (e.g. in browsers) */
371
401
  html?: boolean | Html.Config | undefined
402
+ /**
403
+ * Override the fee-sponsor policy used when co-signing Tempo charge
404
+ * transactions. Defaults resolve per chain, including a higher
405
+ * priority-fee ceiling on Moderato.
406
+ *
407
+ * If you increase `maxGas` or `maxFeePerGas`, you may also need to raise
408
+ * `maxTotalFee` so the combined fee budget remains valid.
409
+ */
410
+ feePayerPolicy?: FeePayerPolicy | undefined
372
411
  /** Testnet mode. */
373
412
  testnet?: boolean | undefined
374
413
  /**
@@ -408,6 +447,8 @@ export declare namespace charge {
408
447
  > & {
409
448
  decimals: number
410
449
  }
450
+
451
+ type FeePayerPolicy = Partial<FeePayer.Policy>
411
452
  }
412
453
 
413
454
  type ExpectedTransfer = {
@@ -651,6 +692,63 @@ async function markProofUsed(
651
692
  })
652
693
  }
653
694
 
695
+ function recoverAuthorizedProofSigner(parameters: {
696
+ chainId: number
697
+ challengeId: string
698
+ signature: `0x${string}`
699
+ sourceAddress: `0x${string}`
700
+ }): `0x${string}` | null {
701
+ const { chainId, challengeId, signature, sourceAddress } = parameters
702
+
703
+ try {
704
+ const envelope = SignatureEnvelope.from(signature)
705
+ const proofHash = hashTypedData({
706
+ domain: Proof.domain(chainId),
707
+ types: Proof.types,
708
+ primaryType: 'Proof',
709
+ message: Proof.message(challengeId),
710
+ })
711
+
712
+ if (envelope.type === 'keychain') {
713
+ if (!TempoAddress.isEqual(envelope.userAddress, sourceAddress)) return null
714
+
715
+ const keychainPayload =
716
+ envelope.version === 'v2'
717
+ ? keccak256(`0x04${proofHash.slice(2)}${sourceAddress.slice(2)}` as `0x${string}`)
718
+ : proofHash
719
+
720
+ const signer = SignatureEnvelope.extractAddress({
721
+ payload: keychainPayload,
722
+ signature: envelope.inner,
723
+ })
724
+ const valid = SignatureEnvelope.verify(envelope.inner, {
725
+ address: signer,
726
+ payload: keychainPayload,
727
+ })
728
+ if (!valid) return null
729
+
730
+ return signer
731
+ }
732
+
733
+ return SignatureEnvelope.extractAddress({ payload: proofHash, signature: envelope })
734
+ } catch {
735
+ return null
736
+ }
737
+ }
738
+
739
+ async function isActiveAccessKey(
740
+ client: Awaited<ReturnType<ReturnType<typeof Client.getResolver>>>,
741
+ parameters: { account: `0x${string}`; accessKey: `0x${string}` },
742
+ ): Promise<boolean> {
743
+ try {
744
+ const metadata = await Actions.accessKey.getMetadata(client, parameters)
745
+ const nowSeconds = BigInt(Math.floor(Date.now() / 1000))
746
+ return !metadata.isRevoked && metadata.expiry > nowSeconds
747
+ } catch {
748
+ return false
749
+ }
750
+ }
751
+
654
752
  /** @internal */
655
753
  function toReceipt(receipt: TransactionReceipt) {
656
754
  const { status, transactionHash } = receipt