mppx 0.8.4 → 0.8.6
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 +18 -0
- package/dist/Receipt.d.ts +20 -2
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +19 -12
- package/dist/Receipt.js.map +1 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +6 -4
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +4 -2
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +6 -1
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/evm/client/Charge.d.ts +2 -2
- package/dist/evm/client/Charge.d.ts.map +1 -1
- package/dist/evm/client/Charge.js +11 -17
- package/dist/evm/client/Charge.js.map +1 -1
- package/dist/evm/server/Charge.d.ts +5 -5
- package/dist/evm/server/Charge.d.ts.map +1 -1
- package/dist/evm/server/Charge.js +15 -7
- package/dist/evm/server/Charge.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +6 -2
- package/dist/tempo/client/Charge.js.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/dist/tempo/session/precompile/Chain.d.ts +26 -2
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +38 -5
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/x402/Assets.d.ts +40 -0
- package/dist/x402/Assets.d.ts.map +1 -1
- package/dist/x402/Assets.js +73 -0
- package/dist/x402/Assets.js.map +1 -1
- package/dist/x402/client/Exact.d.ts +2 -2
- package/dist/x402/client/Exact.d.ts.map +1 -1
- package/dist/x402/client/Exact.js +6 -13
- package/dist/x402/client/Exact.js.map +1 -1
- package/package.json +1 -1
- package/src/Receipt.test.ts +53 -0
- package/src/Receipt.ts +26 -13
- package/src/cli/cli.test.ts +41 -5
- package/src/cli/cli.ts +7 -4
- package/src/cli/plugins/tempo.ts +6 -2
- package/src/client/internal/Fetch.test.ts +44 -0
- package/src/client/internal/Fetch.ts +5 -1
- package/src/evm/PublicInterface.test-d.ts +15 -2
- package/src/evm/client/Charge.test.ts +248 -0
- package/src/evm/client/Charge.ts +12 -23
- package/src/evm/server/Charge.test.ts +140 -0
- package/src/evm/server/Charge.ts +19 -12
- package/src/tempo/client/Charge.test.ts +61 -0
- package/src/tempo/client/Charge.ts +6 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/precompile/Chain.test.ts +121 -2
- package/src/tempo/session/precompile/Chain.ts +56 -6
- package/src/x402/Assets.test.ts +211 -0
- package/src/x402/Assets.ts +116 -0
- package/src/x402/client/Exact.test.ts +129 -0
- package/src/x402/client/Exact.ts +7 -19
package/src/Receipt.ts
CHANGED
|
@@ -3,17 +3,7 @@ import { Base64 } from 'ox'
|
|
|
3
3
|
import * as Constants from './Constants.js'
|
|
4
4
|
import * as z from './zod.js'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* Schema for a payment receipt.
|
|
8
|
-
*
|
|
9
|
-
* @example
|
|
10
|
-
* ```ts
|
|
11
|
-
* import { Receipt } from 'mppx'
|
|
12
|
-
*
|
|
13
|
-
* const receipt = Receipt.Schema.parse(data)
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
export const Schema = z.object({
|
|
6
|
+
const shape = {
|
|
17
7
|
/** Payment method used (e.g., "tempo", "stripe"). */
|
|
18
8
|
method: z.string(),
|
|
19
9
|
/** Method-specific reference (e.g., transaction hash). */
|
|
@@ -26,11 +16,34 @@ export const Schema = z.object({
|
|
|
26
16
|
status: z.literal('success'),
|
|
27
17
|
/** RFC 3339 settlement timestamp. */
|
|
28
18
|
timestamp: z.datetime(),
|
|
29
|
-
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Base-field schema used only to derive the {@link Receipt} type without an index signature. */
|
|
22
|
+
const BaseSchema = z.object(shape)
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Schema for a payment receipt.
|
|
26
|
+
*
|
|
27
|
+
* Method specifications may define additional receipt fields beyond the
|
|
28
|
+
* base set (per the core spec's Payment-Receipt section); unknown fields
|
|
29
|
+
* are preserved through parse/serialize round-trips rather than stripped.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { Receipt } from 'mppx'
|
|
34
|
+
*
|
|
35
|
+
* const receipt = Receipt.Schema.parse(data)
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const Schema = z.looseObject(shape)
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Payment receipt returned after verification.
|
|
33
42
|
*
|
|
43
|
+
* Method-specific extension fields are preserved at runtime but not part of
|
|
44
|
+
* this base type; method packages can type them via intersection
|
|
45
|
+
* (e.g. `Receipt.Receipt & { originTxHash: string }`).
|
|
46
|
+
*
|
|
34
47
|
* @example
|
|
35
48
|
* ```ts
|
|
36
49
|
* import { Receipt } from 'mppx'
|
|
@@ -43,7 +56,7 @@ export const Schema = z.object({
|
|
|
43
56
|
* }
|
|
44
57
|
* ```
|
|
45
58
|
*/
|
|
46
|
-
export type Receipt = z.infer<typeof
|
|
59
|
+
export type Receipt = z.infer<typeof BaseSchema>
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* Deserializes a Payment-Receipt header value to a receipt.
|
package/src/cli/cli.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ afterAll(() => {
|
|
|
43
43
|
})
|
|
44
44
|
|
|
45
45
|
async function serve(argv: string[], options?: { env?: Record<string, string | undefined> }) {
|
|
46
|
-
|
|
46
|
+
const stdoutChunks: Buffer[] = []
|
|
47
47
|
let stderr = ''
|
|
48
48
|
let exitCode: number | undefined
|
|
49
49
|
const saved: Record<string, string | undefined> = {}
|
|
@@ -58,7 +58,9 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
|
|
|
58
58
|
const origLog = console.log
|
|
59
59
|
const origError = console.error
|
|
60
60
|
process.stdout.write = ((chunk: unknown) => {
|
|
61
|
-
|
|
61
|
+
if (typeof chunk === 'string') stdoutChunks.push(Buffer.from(chunk))
|
|
62
|
+
else if (chunk instanceof Uint8Array) stdoutChunks.push(Buffer.from(chunk))
|
|
63
|
+
else stdoutChunks.push(Buffer.from(String(chunk)))
|
|
62
64
|
return true
|
|
63
65
|
}) as typeof process.stdout.write
|
|
64
66
|
process.stderr.write = ((chunk: unknown) => {
|
|
@@ -66,7 +68,7 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
|
|
|
66
68
|
return true
|
|
67
69
|
}) as typeof process.stderr.write
|
|
68
70
|
console.log = (...args: unknown[]) => {
|
|
69
|
-
|
|
71
|
+
stdoutChunks.push(Buffer.from(`${args.map(String).join(' ')}\n`))
|
|
70
72
|
}
|
|
71
73
|
console.error = (...args: unknown[]) => {
|
|
72
74
|
stderr += `${args.map(String).join(' ')}\n`
|
|
@@ -74,7 +76,7 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
|
|
|
74
76
|
try {
|
|
75
77
|
await cli.serve(argv, {
|
|
76
78
|
stdout(s: string) {
|
|
77
|
-
|
|
79
|
+
stdoutChunks.push(Buffer.from(s))
|
|
78
80
|
},
|
|
79
81
|
exit(code: number) {
|
|
80
82
|
exitCode = code
|
|
@@ -90,7 +92,8 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
|
|
|
90
92
|
else process.env[key] = value
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
|
-
|
|
95
|
+
const stdoutBytes = Buffer.concat(stdoutChunks)
|
|
96
|
+
return { output: stdoutBytes.toString(), stderr, exitCode, stdoutBytes }
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
function createMockChargeMethod(name: string) {
|
|
@@ -430,6 +433,24 @@ describe('services', () => {
|
|
|
430
433
|
})
|
|
431
434
|
})
|
|
432
435
|
|
|
436
|
+
describe('request output', () => {
|
|
437
|
+
test('writes non-402 response bodies to stdout without text decoding', async () => {
|
|
438
|
+
const body = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x0a, 0x0a])
|
|
439
|
+
const httpServer = await Http.createServer((_req, res) => {
|
|
440
|
+
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
|
441
|
+
res.end(body)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const { exitCode, stdoutBytes } = await serve([httpServer.url, '-s'])
|
|
446
|
+
expect(exitCode).toBeUndefined()
|
|
447
|
+
expect(stdoutBytes).toEqual(body)
|
|
448
|
+
} finally {
|
|
449
|
+
httpServer.close()
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
|
|
433
454
|
describe('basic charge (examples/basic)', () => {
|
|
434
455
|
test('happy path: makes payment and receives response', { timeout: 120_000 }, async () => {
|
|
435
456
|
const { Actions } = await import('viem/tempo')
|
|
@@ -1819,6 +1840,21 @@ test('mppx --help', async () => {
|
|
|
1819
1840
|
expect(output).toContain('sign')
|
|
1820
1841
|
})
|
|
1821
1842
|
|
|
1843
|
+
describe('account fund help', () => {
|
|
1844
|
+
test('only advertises testnet network funding', async () => {
|
|
1845
|
+
const { output } = await serve(['account', 'fund', '--help'])
|
|
1846
|
+
expect(output).toContain('--network <testnet>')
|
|
1847
|
+
expect(output).not.toContain('mainnet|testnet')
|
|
1848
|
+
})
|
|
1849
|
+
|
|
1850
|
+
test('rejects mainnet network funding', async () => {
|
|
1851
|
+
const { exitCode, output } = await serve(['account', 'fund', '--network', 'mainnet'])
|
|
1852
|
+
expect(exitCode).toBe(1)
|
|
1853
|
+
expect(output).toContain('Invalid input')
|
|
1854
|
+
expect(output).toContain('testnet')
|
|
1855
|
+
})
|
|
1856
|
+
})
|
|
1857
|
+
|
|
1822
1858
|
// ---------------------------------------------------------------------------
|
|
1823
1859
|
// sign
|
|
1824
1860
|
// ---------------------------------------------------------------------------
|
package/src/cli/cli.ts
CHANGED
|
@@ -99,6 +99,10 @@ function outputResult<Data>(
|
|
|
99
99
|
return undefined as unknown as Data
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
async function writeResponseBody(response: Response) {
|
|
103
|
+
process.stdout.write(Buffer.from(await response.arrayBuffer()))
|
|
104
|
+
}
|
|
105
|
+
|
|
102
106
|
function canReadCommandStdin() {
|
|
103
107
|
if (process.stdin.isTTY !== false) return false
|
|
104
108
|
return process.stdin.listenerCount('data') === 0 && process.stdin.listenerCount('readable') === 0
|
|
@@ -371,7 +375,7 @@ const cli = Cli.create('mppx', {
|
|
|
371
375
|
exitCode: 22,
|
|
372
376
|
})
|
|
373
377
|
printResponseHeaders(challengeResponse, headerOpts)
|
|
374
|
-
|
|
378
|
+
await writeResponseBody(challengeResponse)
|
|
375
379
|
return
|
|
376
380
|
}
|
|
377
381
|
|
|
@@ -625,8 +629,7 @@ const cli = Cli.create('mppx', {
|
|
|
625
629
|
} catch {}
|
|
626
630
|
}
|
|
627
631
|
|
|
628
|
-
|
|
629
|
-
console.log(body)
|
|
632
|
+
await writeResponseBody(credentialResponse)
|
|
630
633
|
}
|
|
631
634
|
} catch (err) {
|
|
632
635
|
// Re-throw IncurError so incur's error handler formats it properly
|
|
@@ -864,7 +867,7 @@ const account = Cli.create('account', {
|
|
|
864
867
|
description: 'Fund account with testnet tokens',
|
|
865
868
|
options: z.object({
|
|
866
869
|
account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
|
|
867
|
-
network: z.enum(['
|
|
870
|
+
network: z.enum(['testnet']).optional().describe('Tempo network'),
|
|
868
871
|
rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
|
|
869
872
|
}),
|
|
870
873
|
output: z.object({ account: z.string(), chain: z.string(), transactions: z.array(z.string()) }),
|
package/src/cli/plugins/tempo.ts
CHANGED
|
@@ -42,6 +42,11 @@ const booleanOption = z.union([
|
|
|
42
42
|
z.literal('true').transform(() => true),
|
|
43
43
|
z.literal('false').transform(() => false),
|
|
44
44
|
])
|
|
45
|
+
|
|
46
|
+
async function writeResponseBody(response: Response) {
|
|
47
|
+
process.stdout.write(Buffer.from(await response.arrayBuffer()))
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
const tempoOptionSchema = z.object({
|
|
46
51
|
autoSwap: z.optional(booleanOption),
|
|
47
52
|
channel: z.optional(z.coerce.string()),
|
|
@@ -425,8 +430,7 @@ export function tempo() {
|
|
|
425
430
|
})
|
|
426
431
|
} else {
|
|
427
432
|
// Non-SSE: print body, then close channel
|
|
428
|
-
|
|
429
|
-
console.log(body)
|
|
433
|
+
await writeResponseBody(credentialResponse)
|
|
430
434
|
|
|
431
435
|
if (channelId && escrowContract && chainId) {
|
|
432
436
|
if (confirmEnabled) info('\n')
|
|
@@ -1742,6 +1742,50 @@ describe('Fetch.from: 402 retry path', () => {
|
|
|
1742
1742
|
expect(createCredential).toHaveBeenCalledOnce()
|
|
1743
1743
|
expect(response.status).toBe(402)
|
|
1744
1744
|
})
|
|
1745
|
+
|
|
1746
|
+
test('returns a challenge-less 402 after a rejected payment', async () => {
|
|
1747
|
+
let callCount = 0
|
|
1748
|
+
const createCredential = vi.fn(async () => 'credential')
|
|
1749
|
+
const mockFetch: typeof globalThis.fetch = async () => {
|
|
1750
|
+
callCount++
|
|
1751
|
+
if (callCount === 1) return make402()
|
|
1752
|
+
return new Response(JSON.stringify({ title: 'Verification Failed' }), {
|
|
1753
|
+
status: 402,
|
|
1754
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1755
|
+
})
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const fetch = Fetch.from({
|
|
1759
|
+
fetch: mockFetch,
|
|
1760
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
const response = await fetch('https://example.com/api')
|
|
1764
|
+
expect(callCount).toBe(2)
|
|
1765
|
+
expect(createCredential).toHaveBeenCalledOnce()
|
|
1766
|
+
expect(response.status).toBe(402)
|
|
1767
|
+
await expect(response.json()).resolves.toEqual({ title: 'Verification Failed' })
|
|
1768
|
+
})
|
|
1769
|
+
|
|
1770
|
+
test('returns a post-payment 402 with only unsupported challenges', async () => {
|
|
1771
|
+
let callCount = 0
|
|
1772
|
+
const createCredential = vi.fn(async () => 'credential')
|
|
1773
|
+
const mockFetch: typeof globalThis.fetch = async () => {
|
|
1774
|
+
callCount++
|
|
1775
|
+
if (callCount === 1) return make402()
|
|
1776
|
+
return make402({ method: 'stripe', intent: 'charge' })
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const fetch = Fetch.from({
|
|
1780
|
+
fetch: mockFetch,
|
|
1781
|
+
methods: [{ ...noopMethod, createCredential }],
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
const response = await fetch('https://example.com/api')
|
|
1785
|
+
expect(callCount).toBe(2)
|
|
1786
|
+
expect(createCredential).toHaveBeenCalledOnce()
|
|
1787
|
+
expect(response.status).toBe(402)
|
|
1788
|
+
})
|
|
1745
1789
|
})
|
|
1746
1790
|
|
|
1747
1791
|
describe('Fetch.from: acceptPaymentPolicy', () => {
|
|
@@ -221,10 +221,14 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
221
221
|
orderChallenges,
|
|
222
222
|
)
|
|
223
223
|
const selected = orderedCandidates[0]
|
|
224
|
-
if (!selected)
|
|
224
|
+
if (!selected) {
|
|
225
|
+
// A post-payment 402 with no actionable challenge (e.g. rejected credential)
|
|
226
|
+
// is the server's final answer, not a new challenge round.
|
|
227
|
+
if (retry > 0) return response
|
|
225
228
|
throw new Error(
|
|
226
229
|
`No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
|
|
227
230
|
)
|
|
231
|
+
}
|
|
228
232
|
|
|
229
233
|
const selectedChallenge = selected.challenge
|
|
230
234
|
challenge = selectedChallenge
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from 'mppx/evm/client'
|
|
8
8
|
import { assets as serverAssets, charge as serverCharge, evm as serverEvm } from 'mppx/evm/server'
|
|
9
9
|
import type { Account } from 'viem'
|
|
10
|
+
import { tokens, usdc } from 'viem/tokens'
|
|
10
11
|
import { describe, expectTypeOf, test } from 'vp/test'
|
|
11
12
|
|
|
12
13
|
import { Mppx as ClientMppx } from '../client/index.js'
|
|
@@ -30,6 +31,12 @@ const facilitator = {
|
|
|
30
31
|
describe('evm public interface', () => {
|
|
31
32
|
test('exports EVM asset metadata from root and subpaths', () => {
|
|
32
33
|
expectTypeOf(evmRoot.assets.base.USDC).toMatchTypeOf<typeof serverAssets.base.USDC>()
|
|
34
|
+
expectTypeOf(
|
|
35
|
+
evmRoot.assets.fromToken(usdc, {
|
|
36
|
+
chainId: clientChains.base,
|
|
37
|
+
transfer: { type: 'eip3009', version: '2' },
|
|
38
|
+
}),
|
|
39
|
+
).toMatchTypeOf<typeof serverAssets.base.USDC>()
|
|
33
40
|
expectTypeOf(clientAssets.baseSepolia.USDC).toMatchTypeOf<
|
|
34
41
|
typeof serverAssets.baseSepolia.USDC
|
|
35
42
|
>()
|
|
@@ -53,6 +60,13 @@ describe('evm public interface', () => {
|
|
|
53
60
|
|
|
54
61
|
const mppx = ServerMppx.create({
|
|
55
62
|
methods: [
|
|
63
|
+
serverEvm({
|
|
64
|
+
authorization: { name: 'USD Coin', version: '2' },
|
|
65
|
+
chainId: clientChains.base,
|
|
66
|
+
currency: usdc,
|
|
67
|
+
recipient,
|
|
68
|
+
x402: { facilitator },
|
|
69
|
+
}),
|
|
56
70
|
serverEvm({
|
|
57
71
|
currency: serverAssets.base.USDC,
|
|
58
72
|
recipient,
|
|
@@ -90,9 +104,8 @@ describe('evm public interface', () => {
|
|
|
90
104
|
methods: [
|
|
91
105
|
clientEvm({
|
|
92
106
|
account,
|
|
93
|
-
currencies:
|
|
107
|
+
currencies: tokens.popular,
|
|
94
108
|
maxAmount: '0.01',
|
|
95
|
-
networks: [clientEvm.chains.baseSepolia],
|
|
96
109
|
}),
|
|
97
110
|
],
|
|
98
111
|
polyfill: false,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Challenge } from 'mppx'
|
|
2
2
|
import type { Account } from 'viem'
|
|
3
|
+
import { tokens } from 'viem/tokens'
|
|
3
4
|
import { describe, expect, test, vi } from 'vp/test'
|
|
4
5
|
|
|
5
6
|
import * as Assets from '../Assets.js'
|
|
@@ -96,4 +97,251 @@ describe('evm charge client', () => {
|
|
|
96
97
|
'EVM raw currency allowlists require networks.',
|
|
97
98
|
)
|
|
98
99
|
})
|
|
100
|
+
|
|
101
|
+
test('accepts viem token sets for currency policy and decimals', async () => {
|
|
102
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
103
|
+
const client = charge({
|
|
104
|
+
account: {
|
|
105
|
+
...account,
|
|
106
|
+
signTypedData,
|
|
107
|
+
} as unknown as Account,
|
|
108
|
+
authorization: { name: 'USD Coin', version: '2' },
|
|
109
|
+
currencies: tokens.popular,
|
|
110
|
+
maxAmount: '1',
|
|
111
|
+
})
|
|
112
|
+
const challenge = Challenge.from({
|
|
113
|
+
id: 'native',
|
|
114
|
+
intent: 'charge',
|
|
115
|
+
method: 'evm',
|
|
116
|
+
realm: 'api.example.com',
|
|
117
|
+
request: {
|
|
118
|
+
amount: '1000000',
|
|
119
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
120
|
+
methodDetails: {
|
|
121
|
+
chainId: 84532,
|
|
122
|
+
credentialTypes: ['authorization'],
|
|
123
|
+
decimals: 18,
|
|
124
|
+
},
|
|
125
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await client.createCredential({ challenge } as never)
|
|
130
|
+
|
|
131
|
+
expect(signTypedData).toHaveBeenCalledWith(
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
domain: expect.objectContaining({
|
|
134
|
+
name: 'USD Coin',
|
|
135
|
+
verifyingContract: Assets.baseSepolia.USDC.address,
|
|
136
|
+
version: '2',
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('uses viem token decimals for native authorization maxAmount policy', async () => {
|
|
143
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
144
|
+
const client = charge({
|
|
145
|
+
account: {
|
|
146
|
+
...account,
|
|
147
|
+
signTypedData,
|
|
148
|
+
} as unknown as Account,
|
|
149
|
+
authorization: { name: 'USD Coin', version: '2' },
|
|
150
|
+
currencies: tokens.popular,
|
|
151
|
+
maxAmount: '1',
|
|
152
|
+
})
|
|
153
|
+
const challenge = Challenge.from({
|
|
154
|
+
id: 'native',
|
|
155
|
+
intent: 'charge',
|
|
156
|
+
method: 'evm',
|
|
157
|
+
realm: 'api.example.com',
|
|
158
|
+
request: {
|
|
159
|
+
amount: '1000001',
|
|
160
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
161
|
+
methodDetails: {
|
|
162
|
+
chainId: 84532,
|
|
163
|
+
credentialTypes: ['authorization'],
|
|
164
|
+
decimals: 18,
|
|
165
|
+
},
|
|
166
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
|
|
171
|
+
'EVM charge amount exceeds maxAmount.',
|
|
172
|
+
)
|
|
173
|
+
expect(signTypedData).not.toHaveBeenCalled()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('requires authorization metadata when signing viem token currencies', async () => {
|
|
177
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
178
|
+
const client = charge({
|
|
179
|
+
account: {
|
|
180
|
+
...account,
|
|
181
|
+
signTypedData,
|
|
182
|
+
} as unknown as Account,
|
|
183
|
+
currencies: tokens.popular,
|
|
184
|
+
maxAmount: '1',
|
|
185
|
+
})
|
|
186
|
+
const challenge = Challenge.from({
|
|
187
|
+
id: 'native',
|
|
188
|
+
intent: 'charge',
|
|
189
|
+
method: 'evm',
|
|
190
|
+
realm: 'api.example.com',
|
|
191
|
+
request: {
|
|
192
|
+
amount: '1000000',
|
|
193
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
194
|
+
methodDetails: {
|
|
195
|
+
chainId: 84532,
|
|
196
|
+
credentialTypes: ['authorization'],
|
|
197
|
+
decimals: 18,
|
|
198
|
+
},
|
|
199
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
|
|
204
|
+
'EVM authorization requires token name and version.',
|
|
205
|
+
)
|
|
206
|
+
expect(signTypedData).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('accepts viem token sets through legacy assets policy', async () => {
|
|
210
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
211
|
+
const client = charge({
|
|
212
|
+
account: {
|
|
213
|
+
...account,
|
|
214
|
+
signTypedData,
|
|
215
|
+
} as unknown as Account,
|
|
216
|
+
assets: tokens.popular,
|
|
217
|
+
authorization: { name: 'USD Coin', version: '2' },
|
|
218
|
+
maxAmount: '1',
|
|
219
|
+
})
|
|
220
|
+
const challenge = Challenge.from({
|
|
221
|
+
id: 'native',
|
|
222
|
+
intent: 'charge',
|
|
223
|
+
method: 'evm',
|
|
224
|
+
realm: 'api.example.com',
|
|
225
|
+
request: {
|
|
226
|
+
amount: '1000000',
|
|
227
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
228
|
+
methodDetails: {
|
|
229
|
+
chainId: 84532,
|
|
230
|
+
credentialTypes: ['authorization'],
|
|
231
|
+
decimals: 18,
|
|
232
|
+
},
|
|
233
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await client.createCredential({ challenge } as never)
|
|
238
|
+
|
|
239
|
+
expect(signTypedData).toHaveBeenCalledOnce()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('uses known asset metadata for native authorization policy and signing domain', async () => {
|
|
243
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
244
|
+
const client = charge({
|
|
245
|
+
account: {
|
|
246
|
+
...account,
|
|
247
|
+
signTypedData,
|
|
248
|
+
} as unknown as Account,
|
|
249
|
+
currencies: [Assets.baseSepolia.USDC],
|
|
250
|
+
maxAmount: '0.01',
|
|
251
|
+
})
|
|
252
|
+
const challenge = Challenge.from({
|
|
253
|
+
id: 'native',
|
|
254
|
+
intent: 'charge',
|
|
255
|
+
method: 'evm',
|
|
256
|
+
realm: 'api.example.com',
|
|
257
|
+
request: {
|
|
258
|
+
amount: '10000',
|
|
259
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
260
|
+
methodDetails: {
|
|
261
|
+
chainId: 84532,
|
|
262
|
+
credentialTypes: ['authorization'],
|
|
263
|
+
decimals: 18,
|
|
264
|
+
},
|
|
265
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await client.createCredential({ challenge } as never)
|
|
270
|
+
|
|
271
|
+
expect(signTypedData).toHaveBeenCalledOnce()
|
|
272
|
+
expect(signTypedData).toHaveBeenCalledWith(
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
domain: expect.objectContaining({
|
|
275
|
+
name: 'USDC',
|
|
276
|
+
version: '2',
|
|
277
|
+
}),
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('uses known asset decimals for native authorization maxAmount policy', async () => {
|
|
283
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
284
|
+
const client = charge({
|
|
285
|
+
account: {
|
|
286
|
+
...account,
|
|
287
|
+
signTypedData,
|
|
288
|
+
} as unknown as Account,
|
|
289
|
+
currencies: [Assets.baseSepolia.USDC],
|
|
290
|
+
maxAmount: '0.01',
|
|
291
|
+
})
|
|
292
|
+
const challenge = Challenge.from({
|
|
293
|
+
id: 'native',
|
|
294
|
+
intent: 'charge',
|
|
295
|
+
method: 'evm',
|
|
296
|
+
realm: 'api.example.com',
|
|
297
|
+
request: {
|
|
298
|
+
amount: '10001',
|
|
299
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
300
|
+
methodDetails: {
|
|
301
|
+
chainId: 84532,
|
|
302
|
+
credentialTypes: ['authorization'],
|
|
303
|
+
decimals: 18,
|
|
304
|
+
},
|
|
305
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
|
|
310
|
+
'EVM charge amount exceeds maxAmount.',
|
|
311
|
+
)
|
|
312
|
+
expect(signTypedData).not.toHaveBeenCalled()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('pins known asset native authorization policy to its network', async () => {
|
|
316
|
+
const signTypedData = vi.fn(async () => '0x1234')
|
|
317
|
+
const client = charge({
|
|
318
|
+
account: {
|
|
319
|
+
...account,
|
|
320
|
+
signTypedData,
|
|
321
|
+
} as unknown as Account,
|
|
322
|
+
currencies: [Assets.baseSepolia.USDC],
|
|
323
|
+
maxAmount: '0.01',
|
|
324
|
+
})
|
|
325
|
+
const challenge = Challenge.from({
|
|
326
|
+
id: 'native',
|
|
327
|
+
intent: 'charge',
|
|
328
|
+
method: 'evm',
|
|
329
|
+
realm: 'api.example.com',
|
|
330
|
+
request: {
|
|
331
|
+
amount: '10000',
|
|
332
|
+
currency: Assets.baseSepolia.USDC.address,
|
|
333
|
+
methodDetails: {
|
|
334
|
+
chainId: 8453,
|
|
335
|
+
credentialTypes: ['authorization'],
|
|
336
|
+
decimals: 6,
|
|
337
|
+
},
|
|
338
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
|
|
343
|
+
'EVM currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.',
|
|
344
|
+
)
|
|
345
|
+
expect(signTypedData).not.toHaveBeenCalled()
|
|
346
|
+
})
|
|
99
347
|
})
|
package/src/evm/client/Charge.ts
CHANGED
|
@@ -106,9 +106,9 @@ export declare namespace charge {
|
|
|
106
106
|
/** Optional allowlist of supported EVM chain IDs. */
|
|
107
107
|
networks?: readonly number[] | undefined
|
|
108
108
|
/** Optional allowlist of supported currencies. */
|
|
109
|
-
currencies?: readonly
|
|
109
|
+
currencies?: readonly Assets.Currency[] | undefined
|
|
110
110
|
/** Legacy alias for `currencies`. */
|
|
111
|
-
assets?: readonly
|
|
111
|
+
assets?: readonly Assets.Currency[] | undefined
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -127,12 +127,12 @@ function assertPolicy(parameters: charge.Parameters, request: Types.ChargeReques
|
|
|
127
127
|
throw new Error(`EVM chain ID is not allowed: ${chainId}.`)
|
|
128
128
|
|
|
129
129
|
const currencies = parameters.currencies ?? parameters.assets
|
|
130
|
-
if (currencies?.some((currency) =>
|
|
130
|
+
if (currencies?.some((currency) => Assets.isRawAddress(currency)) && !parameters.networks?.length)
|
|
131
131
|
throw new Error('EVM raw currency allowlists require networks.')
|
|
132
132
|
if (currencies) {
|
|
133
133
|
const acceptedCurrency = getAddress(request.currency as `0x${string}`)
|
|
134
134
|
const allowed = currencies.some((currency) =>
|
|
135
|
-
|
|
135
|
+
Assets.matches(currency, acceptedCurrency, network),
|
|
136
136
|
)
|
|
137
137
|
if (!allowed) throw new Error(`EVM currency is not allowed: ${acceptedCurrency}.`)
|
|
138
138
|
}
|
|
@@ -159,30 +159,18 @@ function resolveAuthorization(
|
|
|
159
159
|
const acceptedCurrency = getAddress(request.currency as `0x${string}`)
|
|
160
160
|
const network = Types.networkOf(request.methodDetails.chainId)
|
|
161
161
|
const currency = currencies?.find((currency) =>
|
|
162
|
-
|
|
162
|
+
Assets.matches(currency, acceptedCurrency, network),
|
|
163
163
|
)
|
|
164
|
-
|
|
164
|
+
const resolved = currency ? Assets.resolve(currency, network) : undefined
|
|
165
|
+
if (resolved?.transfer?.type === Types.eip3009)
|
|
165
166
|
return {
|
|
166
|
-
name:
|
|
167
|
-
version:
|
|
167
|
+
name: resolved.transfer.name,
|
|
168
|
+
version: resolved.transfer.version,
|
|
168
169
|
}
|
|
169
170
|
if (parameters.authorization) return parameters.authorization
|
|
170
171
|
throw new Error('EVM authorization requires token name and version.')
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` {
|
|
174
|
-
return Assets.isAsset(currency) ? currency.address : currency
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function currencyMatches(
|
|
178
|
-
currency: `0x${string}` | Assets.KnownAsset,
|
|
179
|
-
acceptedCurrency: `0x${string}`,
|
|
180
|
-
network: Types.EvmNetwork,
|
|
181
|
-
): boolean {
|
|
182
|
-
if (getAddress(addressOf(currency)) !== acceptedCurrency) return false
|
|
183
|
-
return !Assets.isAsset(currency) || currency.network === network
|
|
184
|
-
}
|
|
185
|
-
|
|
186
174
|
function decimalsOfAcceptedCurrency(
|
|
187
175
|
parameters: charge.Parameters,
|
|
188
176
|
request: Types.ChargeRequest,
|
|
@@ -191,8 +179,9 @@ function decimalsOfAcceptedCurrency(
|
|
|
191
179
|
const acceptedCurrency = getAddress(request.currency as `0x${string}`)
|
|
192
180
|
const network = Types.networkOf(request.methodDetails.chainId)
|
|
193
181
|
const currency = currencies?.find((currency) =>
|
|
194
|
-
|
|
182
|
+
Assets.matches(currency, acceptedCurrency, network),
|
|
195
183
|
)
|
|
196
|
-
|
|
184
|
+
const resolved = currency ? Assets.resolve(currency, network) : undefined
|
|
185
|
+
if (resolved?.decimals !== undefined) return resolved.decimals
|
|
197
186
|
return parameters.decimals
|
|
198
187
|
}
|