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.
- package/CHANGELOG.md +16 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +41 -16
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/config.d.ts +6 -4
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/internal.d.ts +8 -0
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js +33 -3
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/plugin.d.ts +2 -0
- package/dist/cli/plugins/plugin.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +3 -0
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -0
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts +10 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +17 -5
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts +2 -0
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -0
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +3 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +65 -19
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts +72 -0
- package/dist/internal/AcceptPayment.d.ts.map +1 -0
- package/dist/internal/AcceptPayment.js +185 -0
- package/dist/internal/AcceptPayment.js.map +1 -0
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +8 -4
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +33 -24
- package/dist/server/Mppx.js.map +1 -1
- 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/stripe/internal/constants.d.ts +8 -0
- package/dist/stripe/internal/constants.d.ts.map +1 -0
- package/dist/stripe/internal/constants.js +8 -0
- package/dist/stripe/internal/constants.js.map +1 -0
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +23 -5
- package/dist/stripe/server/Charge.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/Proof.d.ts +12 -0
- package/dist/tempo/Proof.d.ts.map +1 -0
- package/dist/tempo/Proof.js +10 -0
- package/dist/tempo/Proof.js.map +1 -0
- 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/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.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 +29 -3
- 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 +69 -4
- 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 +2 -2
- package/src/cli/cli.test.ts +278 -0
- package/src/cli/cli.ts +47 -16
- package/src/cli/config.ts +10 -4
- package/src/cli/internal.ts +59 -3
- package/src/cli/plugins/plugin.ts +3 -0
- package/src/cli/plugins/stripe.ts +3 -0
- package/src/cli/plugins/tempo.ts +3 -0
- package/src/client/Mppx.test-d.ts +33 -0
- package/src/client/Mppx.test.ts +130 -1
- package/src/client/Mppx.ts +35 -5
- package/src/client/Transport.test.ts +88 -55
- package/src/client/Transport.ts +13 -0
- package/src/client/internal/Fetch.browser.test.ts +16 -13
- package/src/client/internal/Fetch.test.ts +307 -10
- package/src/client/internal/Fetch.ts +85 -19
- package/src/internal/AcceptPayment.test.ts +211 -0
- package/src/internal/AcceptPayment.ts +304 -0
- package/src/mcp-sdk/client/McpClient.ts +11 -5
- package/src/server/Mppx.test.ts +141 -44
- package/src/server/Mppx.ts +43 -23
- package/src/server/Transport.test.ts +20 -0
- package/src/server/internal/html/config.ts +9 -1
- package/src/stripe/internal/constants.ts +7 -0
- package/src/stripe/server/Charge.ts +22 -4
- package/src/tempo/Methods.test.ts +25 -0
- package/src/tempo/Methods.ts +30 -22
- package/src/tempo/Proof.test-d.ts +13 -0
- package/src/tempo/Proof.test.ts +31 -0
- package/src/tempo/Proof.ts +13 -0
- package/src/tempo/client/Charge.ts +20 -6
- package/src/tempo/client/SessionManager.test.ts +4 -7
- package/src/tempo/index.ts +1 -0
- package/src/tempo/internal/fee-payer.test.ts +75 -1
- package/src/tempo/internal/fee-payer.ts +41 -3
- package/src/tempo/server/Charge.test.ts +309 -1
- package/src/tempo/server/Charge.ts +99 -1
- package/src/tempo/server/internal/html/main.ts +2 -2
- 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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
package/src/tempo/index.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
46
|
+
maxTotalFee: 50_000_000_000_000_000n,
|
|
34
47
|
maxValidityWindowSeconds: 15 * 60,
|
|
35
|
-
}
|
|
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)
|
|
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
|