mppx 0.5.12 → 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 (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/server/internal/html/config.d.ts.map +1 -1
  3. package/dist/server/internal/html/config.js +8 -1
  4. package/dist/server/internal/html/config.js.map +1 -1
  5. package/dist/tempo/Methods.d.ts +8 -0
  6. package/dist/tempo/Methods.d.ts.map +1 -1
  7. package/dist/tempo/Methods.js +6 -2
  8. package/dist/tempo/Methods.js.map +1 -1
  9. package/dist/tempo/client/Charge.d.ts +11 -1
  10. package/dist/tempo/client/Charge.d.ts.map +1 -1
  11. package/dist/tempo/client/Charge.js +14 -2
  12. package/dist/tempo/client/Charge.js.map +1 -1
  13. package/dist/tempo/client/Methods.d.ts +6 -0
  14. package/dist/tempo/client/Methods.d.ts.map +1 -1
  15. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  16. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  17. package/dist/tempo/internal/fee-payer.js +24 -2
  18. package/dist/tempo/internal/fee-payer.js.map +1 -1
  19. package/dist/tempo/server/Charge.d.ts +17 -0
  20. package/dist/tempo/server/Charge.d.ts.map +1 -1
  21. package/dist/tempo/server/Charge.js +7 -1
  22. package/dist/tempo/server/Charge.js.map +1 -1
  23. package/dist/tempo/server/Methods.d.ts +6 -0
  24. package/dist/tempo/server/Methods.d.ts.map +1 -1
  25. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  26. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  27. package/dist/tempo/server/internal/html.gen.js +1 -1
  28. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/server/Transport.test.ts +20 -0
  31. package/src/server/internal/html/config.ts +9 -1
  32. package/src/tempo/Methods.test.ts +25 -0
  33. package/src/tempo/Methods.ts +30 -22
  34. package/src/tempo/client/Charge.ts +20 -6
  35. package/src/tempo/internal/fee-payer.test.ts +74 -0
  36. package/src/tempo/internal/fee-payer.ts +36 -2
  37. package/src/tempo/server/Charge.test.ts +186 -1
  38. package/src/tempo/server/Charge.ts +25 -0
  39. package/src/tempo/server/internal/html/main.ts +2 -2
  40. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,0p1bAA0p1b,CAAA"}
1
+ {"version":3,"file":"html.gen.js","sourceRoot":"","sources":["../../../../src/tempo/server/internal/html.gen.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,MAAM,CAAC,MAAM,IAAI,GAAG,w91bAAw91b,CAAA"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.5.12",
4
+ "version": "0.5.13",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "files": [
@@ -269,6 +269,26 @@ describe('http', () => {
269
269
  expect(body).toContain('Gotta Pay')
270
270
  })
271
271
 
272
+ test('uses paymentRequired as the title when title is omitted', async () => {
273
+ const transport = Transport.http()
274
+ const request = new Request('https://example.com', {
275
+ headers: { Accept: 'text/html' },
276
+ })
277
+
278
+ const response = await transport.respondChallenge({
279
+ challenge,
280
+ input: request,
281
+ html: {
282
+ ...htmlOptions,
283
+ text: { paymentRequired: 'Gotta Pay' },
284
+ },
285
+ })
286
+
287
+ const body = await response.text()
288
+ expect(body).toContain('<title>Gotta Pay</title>')
289
+ expect(body).toContain('<span>Gotta Pay</span>')
290
+ })
291
+
272
292
  test('applies custom theme logo', async () => {
273
293
  const transport = Transport.http()
274
294
  const request = new Request('https://example.com', {
@@ -172,7 +172,15 @@ export function resolveOptions(options: Options): {
172
172
  },
173
173
  (options.theme as never) ?? {},
174
174
  )
175
- const text = sanitizeRecord(mergeDefined(defaultText, (options.text as never) ?? {}))
175
+ const textOverrides = (options.text as Text | undefined) ?? undefined
176
+ const mergedText = mergeDefined(defaultText, (textOverrides as never) ?? {})
177
+ const text = sanitizeRecord({
178
+ ...mergedText,
179
+ title:
180
+ typeof textOverrides?.title === 'string' && textOverrides.title.length > 0
181
+ ? mergedText.title
182
+ : mergedText.paymentRequired,
183
+ })
176
184
  return { theme, text }
177
185
  }
178
186
 
@@ -39,6 +39,31 @@ describe('charge', () => {
39
39
  expect(result.success).toBe(true)
40
40
  })
41
41
 
42
+ test('schema: validates request with supportedModes', () => {
43
+ const result = Methods.charge.schema.request.safeParse({
44
+ amount: '1',
45
+ currency: '0x20c0000000000000000000000000000000000001',
46
+ decimals: 6,
47
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
48
+ supportedModes: ['pull'],
49
+ })
50
+ expect(result.success).toBe(true)
51
+ if (!result.success) return
52
+
53
+ expect(result.data.methodDetails?.supportedModes).toEqual(['pull'])
54
+ })
55
+
56
+ test('schema: rejects empty supportedModes', () => {
57
+ const result = Methods.charge.schema.request.safeParse({
58
+ amount: '1',
59
+ currency: '0x20c0000000000000000000000000000000000001',
60
+ decimals: 6,
61
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
62
+ supportedModes: [],
63
+ })
64
+ expect(result.success).toBe(false)
65
+ })
66
+
42
67
  test('schema: validates request with memo', () => {
43
68
  const result = Methods.charge.schema.request.safeParse({
44
69
  amount: '1',
@@ -4,6 +4,9 @@ import { parseUnits } from 'viem'
4
4
  import * as Method from '../Method.js'
5
5
  import * as z from '../zod.js'
6
6
 
7
+ export const chargeModes = ['push', 'pull'] as const
8
+ export type ChargeMode = (typeof chargeModes)[number]
9
+
7
10
  const split = z.object({
8
11
  amount: z.amount(),
9
12
  memo: z.optional(z.hash()),
@@ -47,6 +50,7 @@ export const charge = Method.from({
47
50
  memo: z.optional(z.hash()),
48
51
  recipient: z.optional(z.string()),
49
52
  splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(10))),
53
+ supportedModes: z.optional(z.array(z.enum(chargeModes)).check(z.minLength(1))),
50
54
  })
51
55
  .check(
52
56
  z.refine(({ amount, decimals, splits }) => {
@@ -64,28 +68,32 @@ export const charge = Method.from({
64
68
  )
65
69
  }, 'Invalid splits'),
66
70
  ),
67
- z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
68
- ...rest,
69
- amount: parseUnits(amount, decimals).toString(),
70
- ...(chainId !== undefined ||
71
- feePayer !== undefined ||
72
- memo !== undefined ||
73
- splits !== undefined
74
- ? {
75
- methodDetails: {
76
- ...(chainId !== undefined && { chainId }),
77
- ...(feePayer !== undefined && { feePayer }),
78
- ...(memo !== undefined && { memo }),
79
- ...(splits !== undefined && {
80
- splits: splits.map((split) => ({
81
- ...split,
82
- amount: parseUnits(split.amount, decimals).toString(),
83
- })),
84
- }),
85
- },
86
- }
87
- : {}),
88
- })),
71
+ z.transform(
72
+ ({ amount, chainId, decimals, feePayer, memo, splits, supportedModes, ...rest }) => ({
73
+ ...rest,
74
+ amount: parseUnits(amount, decimals).toString(),
75
+ ...(chainId !== undefined ||
76
+ feePayer !== undefined ||
77
+ memo !== undefined ||
78
+ splits !== undefined ||
79
+ supportedModes !== undefined
80
+ ? {
81
+ methodDetails: {
82
+ ...(chainId !== undefined && { chainId }),
83
+ ...(feePayer !== undefined && { feePayer }),
84
+ ...(memo !== undefined && { memo }),
85
+ ...(splits !== undefined && {
86
+ splits: splits.map((split) => ({
87
+ ...split,
88
+ amount: parseUnits(split.amount, decimals).toString(),
89
+ })),
90
+ }),
91
+ ...(supportedModes !== undefined && { supportedModes }),
92
+ },
93
+ }
94
+ : {}),
95
+ }),
96
+ ),
89
97
  ),
90
98
  },
91
99
  })
@@ -47,7 +47,7 @@ export function charge(parameters: charge.Parameters = {}) {
47
47
  context: z.object({
48
48
  account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
49
49
  autoSwap: z.optional(z.custom<charge.AutoSwap>()),
50
- mode: z.optional(z.enum(['push', 'pull'])),
50
+ mode: z.optional(z.enum(Methods.chargeModes)),
51
51
  }),
52
52
 
53
53
  async createCredential({ challenge, context }) {
@@ -74,11 +74,7 @@ export function charge(parameters: charge.Parameters = {}) {
74
74
  })
75
75
  }
76
76
 
77
- const mode =
78
- context?.mode ?? parameters.mode ?? (account.type === 'json-rpc' ? 'push' : 'pull')
79
-
80
77
  const currency = request.currency as Address
81
-
82
78
  if (parameters.expectedRecipients) {
83
79
  const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase()))
84
80
  const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined
@@ -89,6 +85,21 @@ export function charge(parameters: charge.Parameters = {}) {
89
85
  }
90
86
  }
91
87
  }
88
+ const supportedModes = (methodDetails?.supportedModes as
89
+ | readonly Methods.ChargeMode[]
90
+ | undefined) ?? ['pull', 'push']
91
+ const mode = (() => {
92
+ const explicitMode = context?.mode ?? parameters.mode
93
+ if (explicitMode) {
94
+ if (!supportedModes.includes(explicitMode))
95
+ throw new Error(`Challenge does not support ${explicitMode} mode.`)
96
+ return explicitMode
97
+ }
98
+
99
+ const preferredMode = account.type === 'json-rpc' ? 'push' : 'pull'
100
+ if (supportedModes.includes(preferredMode)) return preferredMode
101
+ return supportedModes[0]!
102
+ })()
92
103
 
93
104
  const memo = methodDetails?.memo
94
105
  ? (methodDetails.memo as Hex.Hex)
@@ -193,9 +204,12 @@ export declare namespace charge {
193
204
  * - `'push'`: Client broadcasts the transaction and sends the tx hash to the server.
194
205
  * - `'pull'`: Client signs the transaction and sends the serialized tx to the server for broadcast.
195
206
  *
207
+ * If the server advertises `supportedModes`, this setting must be one of
208
+ * the supported values for the challenge.
209
+ *
196
210
  * @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts.
197
211
  */
198
- mode?: 'push' | 'pull' | undefined
212
+ mode?: Methods.ChargeMode | undefined
199
213
  } & Account.getResolver.Parameters &
200
214
  Client.getResolver.Parameters
201
215
  }
@@ -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,
@@ -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,17 +27,47 @@ export const callScopes = [
26
27
  [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo],
27
28
  ]
28
29
 
30
+ export type Policy = {
31
+ maxGas: bigint
32
+ maxFeePerGas: bigint
33
+ maxPriorityFeePerGas: bigint
34
+ maxTotalFee: bigint
35
+ maxValidityWindowSeconds: number
36
+ }
37
+
29
38
  /**
30
39
  * maxTotalFee must be high enough to cover `transferWithMemo` and
31
40
  * swap transactions at peak gas prices. Bumped from 0.01 ETH in #327.
32
41
  */
33
- const policy = {
42
+ const defaultPolicy: Policy = {
34
43
  maxGas: 2_000_000n,
35
44
  maxFeePerGas: 100_000_000_000n,
36
45
  maxPriorityFeePerGas: 10_000_000_000n,
37
46
  maxTotalFee: 50_000_000_000_000_000n,
38
47
  maxValidityWindowSeconds: 15 * 60,
39
- } 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
+ }
40
71
 
41
72
  /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
42
73
  export function validateCalls(
@@ -89,6 +120,7 @@ export function prepareSponsoredTransaction(parameters: {
89
120
  details: Record<string, string>
90
121
  expectedFeeToken?: TempoAddress.Address | undefined
91
122
  now?: Date | undefined
123
+ policy?: Partial<Policy> | undefined
92
124
  transaction: ReturnType<(typeof Transaction)['deserialize']>
93
125
  }) {
94
126
  const {
@@ -98,8 +130,10 @@ export function prepareSponsoredTransaction(parameters: {
98
130
  details,
99
131
  expectedFeeToken,
100
132
  now = new Date(),
133
+ policy: policyOverrides,
101
134
  transaction,
102
135
  } = parameters
136
+ const policy = getPolicy(chainId, policyOverrides)
103
137
 
104
138
  const {
105
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'
@@ -124,6 +124,166 @@ describe('tempo', () => {
124
124
  httpServer.close()
125
125
  })
126
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
+
127
287
  test('behavior: rejects replayed transaction hash', async () => {
128
288
  const dedupServer = Mppx_server.create({
129
289
  methods: [
@@ -3116,6 +3276,31 @@ describe('tempo', () => {
3116
3276
  })
3117
3277
  expect(challenge.request.currency).toBe(asset)
3118
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
+ })
3119
3304
  })
3120
3305
 
3121
3306
  describe('attribution memo', () => {
@@ -60,6 +60,7 @@ export function charge<const parameters extends charge.Parameters>(
60
60
  decimals = defaults.decimals,
61
61
  description,
62
62
  externalId,
63
+ feePayerPolicy,
63
64
  html,
64
65
  memo,
65
66
  waitForConfirmation = true,
@@ -162,6 +163,9 @@ export function charge<const parameters extends charge.Parameters>(
162
163
 
163
164
  const { amount, methodDetails } = resolvedRequest
164
165
  const expires = challenge.expires
166
+ const supportedModes = methodDetails?.supportedModes as
167
+ | readonly Methods.ChargeMode[]
168
+ | undefined
165
169
 
166
170
  const currency = resolvedRequest.currency as `0x${string}`
167
171
  const recipient = resolvedRequest.recipient as `0x${string}`
@@ -178,6 +182,9 @@ export function charge<const parameters extends charge.Parameters>(
178
182
 
179
183
  switch (payload.type) {
180
184
  case 'hash': {
185
+ if (supportedModes && !supportedModes.includes('push'))
186
+ throw new MismatchError('Hash credentials are not supported for this challenge.', {})
187
+
181
188
  const hash = payload.hash as `0x${string}`
182
189
  if (!(await markHashUsed(store, hash))) {
183
190
  throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
@@ -258,6 +265,12 @@ export function charge<const parameters extends charge.Parameters>(
258
265
  }
259
266
 
260
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
+
261
274
  const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
262
275
 
263
276
  // Pre-broadcast dedup: catch exact byte-for-byte replays early.
@@ -301,6 +314,7 @@ export function charge<const parameters extends charge.Parameters>(
301
314
  chainId: chainId ?? client.chain!.id,
302
315
  details: { amount, currency, recipient },
303
316
  expectedFeeToken,
317
+ policy: feePayerPolicy,
304
318
  transaction: {
305
319
  ...transaction,
306
320
  ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
@@ -385,6 +399,15 @@ export declare namespace charge {
385
399
  type Parameters = {
386
400
  /** Render payment page when Accept header is text/html (e.g. in browsers) */
387
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
388
411
  /** Testnet mode. */
389
412
  testnet?: boolean | undefined
390
413
  /**
@@ -424,6 +447,8 @@ export declare namespace charge {
424
447
  > & {
425
448
  decimals: number
426
449
  }
450
+
451
+ type FeePayerPolicy = Partial<FeePayer.Policy>
427
452
  }
428
453
 
429
454
  type ExpectedTransfer = {
@@ -73,8 +73,8 @@ const provider = Provider.create({
73
73
  })
74
74
 
75
75
  const button = document.createElement('button')
76
- button.innerHTML =
77
- 'Continue with <svg aria-label="Tempo" viewBox="0 0 107 25" role="img"><path d="M8.10464 23.7163H1.82475L7.64513 5.79356H0.201172L1.82475 0.540352H22.5637L20.9401 5.79356H13.8944L8.10464 23.7163Z"></path><path d="M31.474 23.7163H16.5861L24.0607 0.540352H38.8873L37.4782 4.95923H28.8701L27.3078 9.93433H35.6402L34.231 14.2914H25.8681L24.3057 19.2974H32.8525L31.474 23.7163Z"></path><path d="M38.2124 23.7163H33.2192L40.7244 0.540352H49.0567L48.781 13.0245L56.8989 0.540352H66.0277L58.5531 23.7163H52.3039L57.3584 7.86395L46.9736 23.7163H43.267L43.4201 7.80214L38.2124 23.7163Z"></path><path d="M73.057 4.83563L70.6369 12.3137H71.3108C72.8425 12.3137 74.1189 11.9532 75.14 11.2322C76.1612 10.4906 76.8249 9.43991 77.1312 8.08025C77.3967 6.90601 77.2538 6.07167 76.7023 5.57725C76.1509 5.08284 75.2319 4.83563 73.9453 4.83563H73.057ZM66.9915 23.7163H60.7116L68.1862 0.540352H75.814C77.5703 0.540352 79.0816 0.828764 80.3478 1.40559C81.6344 1.96181 82.5738 2.76524 83.166 3.81588C83.7787 4.84592 83.9829 6.05107 83.7787 7.43133C83.5132 9.2442 82.8189 10.8408 81.6956 12.221C80.5724 13.6013 79.1122 14.6725 77.315 15.4347C75.5383 16.1764 73.5471 16.5472 71.3415 16.5472H69.289L66.9915 23.7163Z"></path><path d="M98.747 22.233C96.664 23.4691 94.4481 24.0871 92.0996 24.0871H92.0383C89.9552 24.0871 88.1989 23.6236 86.7693 22.6965C85.3602 21.7489 84.3493 20.4717 83.7366 18.8648C83.1443 17.2579 83.0014 15.4966 83.3077 13.5807C83.6957 11.1704 84.5841 8.94549 85.9728 6.90601C87.3616 4.86653 89.0975 3.23906 91.1805 2.02361C93.2636 0.808164 95.4897 0.200439 97.8587 0.200439H97.9199C100.085 0.200439 101.872 0.663958 103.281 1.591C104.71 2.51803 105.701 3.78498 106.252 5.39185C106.824 6.97811 106.947 8.76008 106.62 10.7378C106.232 13.0657 105.343 15.2596 103.955 17.3197C102.566 19.3592 100.83 20.997 98.747 22.233ZM90.0777 18.2468C90.6292 19.2974 91.589 19.8227 92.9573 19.8227H93.0186C94.1418 19.8227 95.1833 19.4004 96.1432 18.5558C97.1235 17.6905 97.9506 16.5369 98.6245 15.0948C99.3189 13.6528 99.8294 12.0459 100.156 10.2742C100.463 8.54377 100.34 7.15322 99.7886 6.10257C99.2372 5.03133 98.2875 4.49571 96.9397 4.49571H96.8784C95.8369 4.49571 94.826 4.92833 93.8457 5.79356C92.8858 6.6588 92.0485 7.82274 91.3337 9.2854C90.6189 10.7481 90.0982 12.3343 89.7714 14.0442C89.4446 15.7747 89.5468 17.1755 90.0777 18.2468Z"></path></svg>'
76
+ const buttonLabel = c.text.pay === 'Pay' ? 'Continue with' : c.text.pay
77
+ button.innerHTML = `${buttonLabel} <svg aria-label="Tempo" viewBox="0 0 107 25" role="img"><path d="M8.10464 23.7163H1.82475L7.64513 5.79356H0.201172L1.82475 0.540352H22.5637L20.9401 5.79356H13.8944L8.10464 23.7163Z"></path><path d="M31.474 23.7163H16.5861L24.0607 0.540352H38.8873L37.4782 4.95923H28.8701L27.3078 9.93433H35.6402L34.231 14.2914H25.8681L24.3057 19.2974H32.8525L31.474 23.7163Z"></path><path d="M38.2124 23.7163H33.2192L40.7244 0.540352H49.0567L48.781 13.0245L56.8989 0.540352H66.0277L58.5531 23.7163H52.3039L57.3584 7.86395L46.9736 23.7163H43.267L43.4201 7.80214L38.2124 23.7163Z"></path><path d="M73.057 4.83563L70.6369 12.3137H71.3108C72.8425 12.3137 74.1189 11.9532 75.14 11.2322C76.1612 10.4906 76.8249 9.43991 77.1312 8.08025C77.3967 6.90601 77.2538 6.07167 76.7023 5.57725C76.1509 5.08284 75.2319 4.83563 73.9453 4.83563H73.057ZM66.9915 23.7163H60.7116L68.1862 0.540352H75.814C77.5703 0.540352 79.0816 0.828764 80.3478 1.40559C81.6344 1.96181 82.5738 2.76524 83.166 3.81588C83.7787 4.84592 83.9829 6.05107 83.7787 7.43133C83.5132 9.2442 82.8189 10.8408 81.6956 12.221C80.5724 13.6013 79.1122 14.6725 77.315 15.4347C75.5383 16.1764 73.5471 16.5472 71.3415 16.5472H69.289L66.9915 23.7163Z"></path><path d="M98.747 22.233C96.664 23.4691 94.4481 24.0871 92.0996 24.0871H92.0383C89.9552 24.0871 88.1989 23.6236 86.7693 22.6965C85.3602 21.7489 84.3493 20.4717 83.7366 18.8648C83.1443 17.2579 83.0014 15.4966 83.3077 13.5807C83.6957 11.1704 84.5841 8.94549 85.9728 6.90601C87.3616 4.86653 89.0975 3.23906 91.1805 2.02361C93.2636 0.808164 95.4897 0.200439 97.8587 0.200439H97.9199C100.085 0.200439 101.872 0.663958 103.281 1.591C104.71 2.51803 105.701 3.78498 106.252 5.39185C106.824 6.97811 106.947 8.76008 106.62 10.7378C106.232 13.0657 105.343 15.2596 103.955 17.3197C102.566 19.3592 100.83 20.997 98.747 22.233ZM90.0777 18.2468C90.6292 19.2974 91.589 19.8227 92.9573 19.8227H93.0186C94.1418 19.8227 95.1833 19.4004 96.1432 18.5558C97.1235 17.6905 97.9506 16.5369 98.6245 15.0948C99.3189 13.6528 99.8294 12.0459 100.156 10.2742C100.463 8.54377 100.34 7.15322 99.7886 6.10257C99.2372 5.03133 98.2875 4.49571 96.9397 4.49571H96.8784C95.8369 4.49571 94.826 4.92833 93.8457 5.79356C92.8858 6.6588 92.0485 7.82274 91.3337 9.2854C90.6189 10.7481 90.0982 12.3343 89.7714 14.0442C89.4446 15.7747 89.5468 17.1755 90.0777 18.2468Z"></path></svg>`
78
78
  button.onclick = async () => {
79
79
  try {
80
80
  c.error()