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.
- package/CHANGELOG.md +8 -0
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +8 -1
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/tempo/Methods.d.ts +8 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +6 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +11 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +14 -2
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +6 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +24 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +7 -1
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +6 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +1 -1
- package/src/server/Transport.test.ts +20 -0
- package/src/server/internal/html/config.ts +9 -1
- package/src/tempo/Methods.test.ts +25 -0
- package/src/tempo/Methods.ts +30 -22
- package/src/tempo/client/Charge.ts +20 -6
- package/src/tempo/internal/fee-payer.test.ts +74 -0
- package/src/tempo/internal/fee-payer.ts +36 -2
- package/src/tempo/server/Charge.test.ts +186 -1
- package/src/tempo/server/Charge.ts +25 -0
- package/src/tempo/server/internal/html/main.ts +2 -2
- 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,
|
|
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
|
@@ -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
|
|
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',
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -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(
|
|
68
|
-
...rest
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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?:
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
77
|
-
|
|
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()
|