mppx 0.4.12 → 0.5.1
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 +12 -0
- package/dist/Expires.d.ts +7 -0
- package/dist/Expires.d.ts.map +1 -1
- package/dist/Expires.js +21 -0
- package/dist/Expires.js.map +1 -1
- package/dist/cli/account.d.ts.map +1 -1
- package/dist/cli/account.js +12 -2
- package/dist/cli/account.js.map +1 -1
- package/dist/server/Mppx.js +6 -5
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +3 -3
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +3 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +1 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +3 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +18 -2
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/proof.d.ts +29 -0
- package/dist/tempo/internal/proof.d.ts.map +1 -0
- package/dist/tempo/internal/proof.js +32 -0
- package/dist/tempo/internal/proof.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +11 -3
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +54 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Expires.ts +25 -0
- package/src/cli/account.ts +13 -2
- package/src/cli/cli.test.ts +230 -1
- package/src/middlewares/elysia.test.ts +130 -9
- package/src/middlewares/express.test.ts +123 -59
- package/src/middlewares/hono.test.ts +81 -39
- package/src/middlewares/nextjs.test.ts +162 -41
- package/src/server/Mppx.test.ts +86 -0
- package/src/server/Mppx.ts +5 -5
- package/src/stripe/server/Charge.ts +3 -7
- package/src/tempo/Methods.test.ts +26 -0
- package/src/tempo/Methods.ts +1 -0
- package/src/tempo/client/Charge.ts +26 -3
- package/src/tempo/internal/charge.test.ts +66 -0
- package/src/tempo/internal/proof.test.ts +83 -0
- package/src/tempo/internal/proof.ts +35 -0
- package/src/tempo/server/Charge.test.ts +660 -1
- package/src/tempo/server/Charge.ts +80 -5
- package/src/tempo/server/Session.test.ts +1123 -53
- package/src/tempo/server/internal/transport.test.ts +32 -0
- package/src/tempo/session/Chain.test.ts +35 -0
- package/src/tempo/session/Sse.test.ts +31 -0
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import * as http from 'node:http'
|
|
2
2
|
|
|
3
|
-
import { Receipt } from 'mppx'
|
|
3
|
+
import { Challenge, Credential, Receipt } from 'mppx'
|
|
4
4
|
import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
|
|
5
5
|
import { Mppx, discovery } from 'mppx/nextjs'
|
|
6
6
|
import { tempo as tempo_server } from 'mppx/server'
|
|
7
7
|
import type { Address } from 'viem'
|
|
8
8
|
import { Addresses } from 'viem/tempo'
|
|
9
9
|
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
10
|
+
import * as TestHttp from '~test/Http.js'
|
|
10
11
|
import { deployEscrow } from '~test/tempo/session.js'
|
|
11
|
-
import { accounts, asset, client, fundAccount } from '~test/tempo/viem.js'
|
|
12
|
+
import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js'
|
|
12
13
|
|
|
13
14
|
function createServer(handler: (request: Request) => Promise<Response> | Response) {
|
|
14
|
-
return new Promise<
|
|
15
|
+
return new Promise<TestHttp.TestServer>((resolve) => {
|
|
15
16
|
const server = http.createServer(async (req, res) => {
|
|
16
17
|
const url = `http://localhost${req.url}`
|
|
17
18
|
const headers = new Headers()
|
|
@@ -26,23 +27,21 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
|
|
|
26
27
|
})
|
|
27
28
|
server.listen(0, () => {
|
|
28
29
|
const { port } = server.address() as { port: number }
|
|
29
|
-
resolve({
|
|
30
|
-
url: `http://localhost:${port}`,
|
|
31
|
-
close: () => server.close(),
|
|
32
|
-
})
|
|
30
|
+
resolve(TestHttp.wrapServer(server, { port, url: `http://localhost:${port}` }))
|
|
33
31
|
})
|
|
34
32
|
})
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
const secretKey = 'test-secret-key'
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
function createChargeHarness(feePayer: boolean) {
|
|
40
38
|
const mppx = Mppx.create({
|
|
41
39
|
methods: [
|
|
42
40
|
tempo_server.charge({
|
|
43
41
|
getClient: () => client,
|
|
44
42
|
currency: asset,
|
|
45
43
|
account: accounts[0],
|
|
44
|
+
...(feePayer ? { feePayer: true } : {}),
|
|
46
45
|
}),
|
|
47
46
|
],
|
|
48
47
|
secretKey,
|
|
@@ -58,7 +57,13 @@ describe('charge', () => {
|
|
|
58
57
|
],
|
|
59
58
|
})
|
|
60
59
|
|
|
60
|
+
return { fetch, mppx }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('charge', () => {
|
|
61
64
|
test('returns 402 when no credential', async () => {
|
|
65
|
+
const { mppx } = createChargeHarness(false)
|
|
66
|
+
|
|
62
67
|
const handler = mppx.charge({ amount: '1' })(() =>
|
|
63
68
|
Response.json({ fortune: 'You will be rich' }),
|
|
64
69
|
)
|
|
@@ -72,6 +77,8 @@ describe('charge', () => {
|
|
|
72
77
|
})
|
|
73
78
|
|
|
74
79
|
test('returns 200 with receipt on valid payment', async () => {
|
|
80
|
+
const { fetch, mppx } = createChargeHarness(false)
|
|
81
|
+
|
|
75
82
|
const handler = mppx.charge({ amount: '1' })(() =>
|
|
76
83
|
Response.json({ fortune: 'You will be rich' }),
|
|
77
84
|
)
|
|
@@ -90,7 +97,108 @@ describe('charge', () => {
|
|
|
90
97
|
server.close()
|
|
91
98
|
})
|
|
92
99
|
|
|
100
|
+
test('fee payer: returns 200 with receipt on valid payment', async () => {
|
|
101
|
+
const { fetch, mppx } = createChargeHarness(true)
|
|
102
|
+
|
|
103
|
+
const handler = mppx.charge({ amount: '1' })(() =>
|
|
104
|
+
Response.json({ fortune: 'You will be rich' }),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const server = await createServer(handler)
|
|
108
|
+
const response = await fetch(server.url)
|
|
109
|
+
expect(response.status).toBe(200)
|
|
110
|
+
expect(Receipt.fromResponse(response).status).toBe('success')
|
|
111
|
+
|
|
112
|
+
server.close()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('zero-amount charge creates a proof credential and receipt', async () => {
|
|
116
|
+
const { fetch, mppx } = createChargeHarness(false)
|
|
117
|
+
|
|
118
|
+
const handler = mppx.charge({ amount: '0' })((request) =>
|
|
119
|
+
Response.json({ payer: request.headers.get('Authorization') }),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const server = await createServer(handler)
|
|
123
|
+
|
|
124
|
+
const challengeResponse = await globalThis.fetch(server.url)
|
|
125
|
+
expect(challengeResponse.status).toBe(402)
|
|
126
|
+
|
|
127
|
+
const response = await fetch(server.url)
|
|
128
|
+
expect(response.status).toBe(200)
|
|
129
|
+
|
|
130
|
+
const body = (await response.json()) as { payer: string }
|
|
131
|
+
const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(body.payer)
|
|
132
|
+
expect(credential.challenge.request.amount).toBe('0')
|
|
133
|
+
expect(credential.payload.type).toBe('proof')
|
|
134
|
+
expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${accounts[1].address}`)
|
|
135
|
+
|
|
136
|
+
const receipt = Receipt.fromResponse(response)
|
|
137
|
+
expect(receipt.reference).toBe(credential.challenge.id)
|
|
138
|
+
|
|
139
|
+
server.close()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('zero-amount charge with testnet currency omission creates a proof credential', async () => {
|
|
143
|
+
const isTestnet = true
|
|
144
|
+
const mainnetCurrency = '0x20C000000000000000000000b9537d11c60E8b50' as const
|
|
145
|
+
|
|
146
|
+
const mppx = Mppx.create({
|
|
147
|
+
methods: [
|
|
148
|
+
tempo_server.charge({
|
|
149
|
+
account: accounts[0],
|
|
150
|
+
getClient: () => client,
|
|
151
|
+
...(isTestnet ? {} : { currency: mainnetCurrency }),
|
|
152
|
+
recipient: accounts[0].address,
|
|
153
|
+
testnet: isTestnet,
|
|
154
|
+
}),
|
|
155
|
+
],
|
|
156
|
+
secretKey,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const { fetch } = Mppx_client.create({
|
|
160
|
+
polyfill: false,
|
|
161
|
+
methods: [
|
|
162
|
+
tempo_client.charge({
|
|
163
|
+
account: accounts[1],
|
|
164
|
+
getClient: () => client,
|
|
165
|
+
}),
|
|
166
|
+
],
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const handler = mppx.charge({ amount: '0', chainId: chain.id })((request) =>
|
|
170
|
+
Response.json({ payer: request.headers.get('Authorization') }),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const server = await createServer(handler)
|
|
174
|
+
|
|
175
|
+
const challengeResponse = await globalThis.fetch(server.url)
|
|
176
|
+
expect(challengeResponse.status).toBe(402)
|
|
177
|
+
|
|
178
|
+
const challenge = Challenge.fromResponse(challengeResponse, {
|
|
179
|
+
methods: [tempo_client.charge()],
|
|
180
|
+
})
|
|
181
|
+
expect(challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
|
|
182
|
+
|
|
183
|
+
const response = await fetch(server.url)
|
|
184
|
+
expect(response.status).toBe(200)
|
|
185
|
+
|
|
186
|
+
const body = (await response.json()) as { payer: string }
|
|
187
|
+
const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(body.payer)
|
|
188
|
+
expect(credential.challenge.request.amount).toBe('0')
|
|
189
|
+
expect(credential.challenge.request.currency).toBe('0x20c0000000000000000000000000000000000000')
|
|
190
|
+
expect(credential.payload.type).toBe('proof')
|
|
191
|
+
expect(credential.source).toBe(`did:pkh:eip155:${chain.id}:${accounts[1].address}`)
|
|
192
|
+
|
|
193
|
+
const receipt = Receipt.fromResponse(response)
|
|
194
|
+
expect(receipt.reference).toBe(credential.challenge.id)
|
|
195
|
+
|
|
196
|
+
server.close()
|
|
197
|
+
})
|
|
198
|
+
|
|
93
199
|
test('serves /openapi.json from a handler-derived route config', async () => {
|
|
200
|
+
const { mppx } = createChargeHarness(false)
|
|
201
|
+
|
|
94
202
|
const pay = mppx.charge({ amount: '1' })
|
|
95
203
|
const server = await createServer(
|
|
96
204
|
discovery(mppx, {
|
|
@@ -119,13 +227,7 @@ describe('charge', () => {
|
|
|
119
227
|
describe('session', () => {
|
|
120
228
|
let escrowContract: Address
|
|
121
229
|
|
|
122
|
-
|
|
123
|
-
escrowContract = await deployEscrow()
|
|
124
|
-
await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
|
|
125
|
-
await fundAccount({ address: accounts[2].address, token: asset })
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('returns 402 when no credential', async () => {
|
|
230
|
+
function createSessionHarness(feePayer: boolean) {
|
|
129
231
|
const mppx = Mppx.create({
|
|
130
232
|
methods: [
|
|
131
233
|
tempo_server.session({
|
|
@@ -133,12 +235,38 @@ describe('session', () => {
|
|
|
133
235
|
account: accounts[0],
|
|
134
236
|
currency: asset,
|
|
135
237
|
escrowContract,
|
|
136
|
-
|
|
238
|
+
...(feePayer ? { feePayer: accounts[1] } : {}),
|
|
239
|
+
} as any),
|
|
137
240
|
],
|
|
138
241
|
secretKey,
|
|
139
242
|
})
|
|
140
243
|
|
|
141
|
-
const
|
|
244
|
+
const { fetch } = Mppx_client.create({
|
|
245
|
+
polyfill: false,
|
|
246
|
+
methods: [
|
|
247
|
+
sessionIntent({
|
|
248
|
+
account: accounts[2],
|
|
249
|
+
deposit: '10',
|
|
250
|
+
getClient: () => client,
|
|
251
|
+
}),
|
|
252
|
+
],
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return { fetch, mppx }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
beforeAll(async () => {
|
|
259
|
+
escrowContract = await deployEscrow()
|
|
260
|
+
await fundAccount({ address: accounts[1].address, token: Addresses.pathUsd })
|
|
261
|
+
await fundAccount({ address: accounts[1].address, token: asset })
|
|
262
|
+
await fundAccount({ address: accounts[2].address, token: Addresses.pathUsd })
|
|
263
|
+
await fundAccount({ address: accounts[2].address, token: asset })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('returns 402 when no credential', async () => {
|
|
267
|
+
const { mppx } = createSessionHarness(false)
|
|
268
|
+
|
|
269
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
142
270
|
Response.json({ data: 'streamed' }),
|
|
143
271
|
)
|
|
144
272
|
|
|
@@ -151,31 +279,9 @@ describe('session', () => {
|
|
|
151
279
|
})
|
|
152
280
|
|
|
153
281
|
test('returns 200 with receipt on valid payment', async () => {
|
|
154
|
-
const mppx =
|
|
155
|
-
methods: [
|
|
156
|
-
tempo_server.session({
|
|
157
|
-
getClient: () => client,
|
|
158
|
-
account: accounts[0],
|
|
159
|
-
currency: asset,
|
|
160
|
-
escrowContract,
|
|
161
|
-
feePayer: true,
|
|
162
|
-
}),
|
|
163
|
-
],
|
|
164
|
-
secretKey,
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
const { fetch } = Mppx_client.create({
|
|
168
|
-
polyfill: false,
|
|
169
|
-
methods: [
|
|
170
|
-
sessionIntent({
|
|
171
|
-
account: accounts[2],
|
|
172
|
-
deposit: '10',
|
|
173
|
-
getClient: () => client,
|
|
174
|
-
}),
|
|
175
|
-
],
|
|
176
|
-
})
|
|
282
|
+
const { fetch, mppx } = createSessionHarness(false)
|
|
177
283
|
|
|
178
|
-
const handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
|
|
284
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
179
285
|
Response.json({ data: 'streamed' }),
|
|
180
286
|
)
|
|
181
287
|
|
|
@@ -192,4 +298,19 @@ describe('session', () => {
|
|
|
192
298
|
|
|
193
299
|
server.close()
|
|
194
300
|
})
|
|
301
|
+
|
|
302
|
+
test('fee payer: returns 200 with receipt on valid payment', async () => {
|
|
303
|
+
const { fetch, mppx } = createSessionHarness(true)
|
|
304
|
+
|
|
305
|
+
const handler = mppx.session({ amount: '1', currency: asset, unitType: 'token' })(() =>
|
|
306
|
+
Response.json({ data: 'streamed' }),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
const server = await createServer(handler)
|
|
310
|
+
const response = await fetch(server.url)
|
|
311
|
+
expect(response.status).toBe(200)
|
|
312
|
+
expect(Receipt.fromResponse(response).status).toBe('success')
|
|
313
|
+
|
|
314
|
+
server.close()
|
|
315
|
+
})
|
|
195
316
|
})
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -422,6 +422,92 @@ describe('request handler', () => {
|
|
|
422
422
|
`)
|
|
423
423
|
expect((body as { detail: string }).detail).toContain('Payment expired at')
|
|
424
424
|
})
|
|
425
|
+
test('returns 402 when credential challenge has no expires (fail-closed)', async () => {
|
|
426
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
427
|
+
amount: '1000',
|
|
428
|
+
currency: asset,
|
|
429
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
430
|
+
recipient: accounts[0].address,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// Get a valid challenge from the server to capture the exact request shape
|
|
434
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
435
|
+
expect(firstResult.status).toBe(402)
|
|
436
|
+
if (firstResult.status !== 402) throw new Error()
|
|
437
|
+
|
|
438
|
+
const serverChallenge = Challenge.fromResponse(firstResult.challenge)
|
|
439
|
+
|
|
440
|
+
// Re-create the same challenge WITHOUT expires, with a valid HMAC
|
|
441
|
+
const { expires: _, ...rest } = serverChallenge
|
|
442
|
+
const challengeNoExpires = Challenge.from({
|
|
443
|
+
secretKey,
|
|
444
|
+
realm: rest.realm,
|
|
445
|
+
method: rest.method,
|
|
446
|
+
intent: rest.intent,
|
|
447
|
+
request: rest.request,
|
|
448
|
+
...(rest.opaque && { meta: rest.opaque }),
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
const credential = Credential.from({
|
|
452
|
+
challenge: challengeNoExpires,
|
|
453
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const result = await handle(
|
|
457
|
+
new Request('https://example.com/resource', {
|
|
458
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
459
|
+
}),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
expect(result.status).toBe(402)
|
|
463
|
+
if (result.status !== 402) throw new Error()
|
|
464
|
+
|
|
465
|
+
const body = (await result.challenge.json()) as { title: string; detail: string }
|
|
466
|
+
expect(body.title).toBe('Invalid Challenge')
|
|
467
|
+
expect(body.detail).toContain('missing required expires')
|
|
468
|
+
})
|
|
469
|
+
test('returns 402 when credential challenge has malformed expires', async () => {
|
|
470
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
471
|
+
amount: '1000',
|
|
472
|
+
currency: asset,
|
|
473
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
474
|
+
recipient: accounts[0].address,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Get a valid challenge from the server to capture the exact request shape
|
|
478
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
479
|
+
expect(firstResult.status).toBe(402)
|
|
480
|
+
if (firstResult.status !== 402) throw new Error()
|
|
481
|
+
|
|
482
|
+
const serverChallenge = Challenge.fromResponse(firstResult.challenge)
|
|
483
|
+
|
|
484
|
+
// Re-create the challenge with a valid HMAC but inject a malformed expires
|
|
485
|
+
// by patching the challenge object after construction (bypasses zod at build time).
|
|
486
|
+
const challengeMalformed = {
|
|
487
|
+
...serverChallenge,
|
|
488
|
+
expires: 'not-a-timestamp',
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const credential = Credential.from({
|
|
492
|
+
challenge: challengeMalformed as any,
|
|
493
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// Credential.serialize does not re-validate, so the malformed expires
|
|
497
|
+
// reaches the server. Deserialization rejects it via zod schema.
|
|
498
|
+
const result = await handle(
|
|
499
|
+
new Request('https://example.com/resource', {
|
|
500
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
501
|
+
}),
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
expect(result.status).toBe(402)
|
|
505
|
+
if (result.status !== 402) throw new Error()
|
|
506
|
+
|
|
507
|
+
const body = (await result.challenge.json()) as { title: string; detail: string }
|
|
508
|
+
expect(body.title).toBe('Malformed Credential')
|
|
509
|
+
})
|
|
510
|
+
|
|
425
511
|
test('returns 402 when payload schema validation fails', async () => {
|
|
426
512
|
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
427
513
|
amount: '1000',
|
package/src/server/Mppx.ts
CHANGED
|
@@ -418,14 +418,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
-
// Reject expired
|
|
422
|
-
|
|
421
|
+
// Reject credentials without expires (fail-closed) or with expired timestamp
|
|
422
|
+
try {
|
|
423
|
+
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
424
|
+
} catch (error) {
|
|
423
425
|
const response = await transport.respondChallenge({
|
|
424
426
|
challenge,
|
|
425
427
|
input,
|
|
426
|
-
error:
|
|
427
|
-
expires: credential.challenge.expires,
|
|
428
|
-
}),
|
|
428
|
+
error: error as Errors.PaymentError,
|
|
429
429
|
})
|
|
430
430
|
return { challenge: response, status: 402 }
|
|
431
431
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import type * as Credential from '../../Credential.js'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
PaymentExpiredError,
|
|
5
|
-
VerificationFailedError,
|
|
6
|
-
} from '../../Errors.js'
|
|
2
|
+
import { PaymentActionRequiredError, VerificationFailedError } from '../../Errors.js'
|
|
3
|
+
import * as Expires from '../../Expires.js'
|
|
7
4
|
import type { LooseOmit, OneOf } from '../../internal/types.js'
|
|
8
5
|
import * as Method from '../../Method.js'
|
|
9
6
|
import type { StripeClient } from '../internal/types.js'
|
|
@@ -66,8 +63,7 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
66
63
|
const { challenge } = credential
|
|
67
64
|
const { request } = challenge
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
throw new PaymentExpiredError({ expires: challenge.expires })
|
|
66
|
+
Expires.assert(challenge.expires, challenge.id)
|
|
71
67
|
|
|
72
68
|
const parsed = Methods.charge.schema.credential.payload.safeParse(credential.payload)
|
|
73
69
|
if (!parsed.success) throw new Error('Invalid credential payload: missing or malformed spt')
|
|
@@ -130,6 +130,32 @@ describe('charge', () => {
|
|
|
130
130
|
expect(result.success).toBe(false)
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
test('schema: rejects zero-amount with splits', () => {
|
|
134
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
135
|
+
amount: '0',
|
|
136
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
137
|
+
decimals: 6,
|
|
138
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
139
|
+
splits: [
|
|
140
|
+
{
|
|
141
|
+
amount: '0.1',
|
|
142
|
+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
})
|
|
146
|
+
expect(result.success).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('schema: accepts zero-amount without splits', () => {
|
|
150
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
151
|
+
amount: '0',
|
|
152
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
153
|
+
decimals: 6,
|
|
154
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
155
|
+
})
|
|
156
|
+
expect(result.success).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
133
159
|
test('schema: rejects invalid request', () => {
|
|
134
160
|
const result = Methods.charge.schema.request.safeParse({
|
|
135
161
|
amount: '1',
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const charge = Method.from({
|
|
|
26
26
|
payload: z.discriminatedUnion('type', [
|
|
27
27
|
z.object({ hash: z.hash(), type: z.literal('hash') }),
|
|
28
28
|
z.object({ signature: z.signature(), type: z.literal('transaction') }),
|
|
29
|
+
z.object({ signature: z.signature(), type: z.literal('proof') }),
|
|
29
30
|
]),
|
|
30
31
|
},
|
|
31
32
|
request: z.pipe(
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type * as Hex from 'ox/Hex'
|
|
2
2
|
import type { Address } from 'viem'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
prepareTransactionRequest,
|
|
5
|
+
sendCallsSync,
|
|
6
|
+
signTypedData,
|
|
7
|
+
signTransaction,
|
|
8
|
+
} from 'viem/actions'
|
|
4
9
|
import { tempo as tempo_chain } from 'viem/chains'
|
|
5
10
|
import { Actions } from 'viem/tempo'
|
|
6
11
|
|
|
@@ -13,6 +18,7 @@ import * as Attribution from '../Attribution.js'
|
|
|
13
18
|
import * as AutoSwap from '../internal/auto-swap.js'
|
|
14
19
|
import * as Charge_internal from '../internal/charge.js'
|
|
15
20
|
import * as defaults from '../internal/defaults.js'
|
|
21
|
+
import * as Proof from '../internal/proof.js'
|
|
16
22
|
import * as Methods from '../Methods.js'
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -49,11 +55,28 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
49
55
|
const client = await getClient({ chainId })
|
|
50
56
|
const account = getAccount(client, context)
|
|
51
57
|
|
|
58
|
+
const { request } = challenge
|
|
59
|
+
const { amount, methodDetails } = request
|
|
60
|
+
|
|
61
|
+
// Zero-amount: sign EIP-712 typed data instead of creating a transaction.
|
|
62
|
+
if (BigInt(amount) === 0n) {
|
|
63
|
+
const signature = await signTypedData(client, {
|
|
64
|
+
account,
|
|
65
|
+
domain: Proof.domain(chainId!),
|
|
66
|
+
types: Proof.types,
|
|
67
|
+
primaryType: 'Proof',
|
|
68
|
+
message: Proof.message(challenge.id),
|
|
69
|
+
})
|
|
70
|
+
return Credential.serialize({
|
|
71
|
+
challenge,
|
|
72
|
+
payload: { signature, type: 'proof' },
|
|
73
|
+
source: Proof.proofSource({ address: account.address, chainId: chainId! }),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
const mode =
|
|
53
78
|
context?.mode ?? parameters.mode ?? (account.type === 'json-rpc' ? 'push' : 'pull')
|
|
54
79
|
|
|
55
|
-
const { request } = challenge
|
|
56
|
-
const { amount, methodDetails } = request
|
|
57
80
|
const currency = request.currency as Address
|
|
58
81
|
|
|
59
82
|
if (parameters.expectedRecipients) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Address } from 'viem'
|
|
2
|
+
import { describe, expect, test } from 'vp/test'
|
|
3
|
+
|
|
4
|
+
import { getTransfers } from './charge.js'
|
|
5
|
+
|
|
6
|
+
const recipient = '0x1234567890abcdef1234567890abcdef12345678' as Address
|
|
7
|
+
|
|
8
|
+
describe('getTransfers', () => {
|
|
9
|
+
test('returns single transfer when no splits', () => {
|
|
10
|
+
const transfers = getTransfers({ amount: '100', recipient })
|
|
11
|
+
expect(transfers).toEqual([{ amount: '100', memo: undefined, recipient }])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('splits amount between primary and split recipients', () => {
|
|
15
|
+
const splitRecipient = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address
|
|
16
|
+
const transfers = getTransfers({
|
|
17
|
+
amount: '100',
|
|
18
|
+
methodDetails: { splits: [{ amount: '30', recipient: splitRecipient }] },
|
|
19
|
+
recipient,
|
|
20
|
+
})
|
|
21
|
+
expect(transfers).toHaveLength(2)
|
|
22
|
+
expect(transfers[0]!.amount).toBe('70')
|
|
23
|
+
expect(transfers[0]!.recipient).toBe(recipient)
|
|
24
|
+
expect(transfers[1]!.amount).toBe('30')
|
|
25
|
+
expect(transfers[1]!.recipient).toBe(splitRecipient)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('throws when amount is zero with no splits', () => {
|
|
29
|
+
expect(() => getTransfers({ amount: '0', recipient })).toThrow(
|
|
30
|
+
'split total must be less than total amount',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('throws when amount is zero with splits', () => {
|
|
35
|
+
const splitRecipient = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address
|
|
36
|
+
expect(() =>
|
|
37
|
+
getTransfers({
|
|
38
|
+
amount: '0',
|
|
39
|
+
methodDetails: { splits: [{ amount: '0', recipient: splitRecipient }] },
|
|
40
|
+
recipient,
|
|
41
|
+
}),
|
|
42
|
+
).toThrow()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('throws when split total equals amount', () => {
|
|
46
|
+
const splitRecipient = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address
|
|
47
|
+
expect(() =>
|
|
48
|
+
getTransfers({
|
|
49
|
+
amount: '100',
|
|
50
|
+
methodDetails: { splits: [{ amount: '100', recipient: splitRecipient }] },
|
|
51
|
+
recipient,
|
|
52
|
+
}),
|
|
53
|
+
).toThrow('split total must be less than total amount')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('throws when split total exceeds amount', () => {
|
|
57
|
+
const splitRecipient = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Address
|
|
58
|
+
expect(() =>
|
|
59
|
+
getTransfers({
|
|
60
|
+
amount: '100',
|
|
61
|
+
methodDetails: { splits: [{ amount: '200', recipient: splitRecipient }] },
|
|
62
|
+
recipient,
|
|
63
|
+
}),
|
|
64
|
+
).toThrow('split total must be less than total amount')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import * as Proof from './proof.js'
|
|
4
|
+
|
|
5
|
+
const parseProofSourceCases = [
|
|
6
|
+
{
|
|
7
|
+
expected: {
|
|
8
|
+
address: '0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
|
|
9
|
+
chainId: 42431,
|
|
10
|
+
},
|
|
11
|
+
name: 'parses a valid did:pkh:eip155 source',
|
|
12
|
+
source: 'did:pkh:eip155:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
expected: null,
|
|
16
|
+
name: 'rejects non-numeric chain ids',
|
|
17
|
+
source: 'did:pkh:eip155:not-a-number:0x1234',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
expected: null,
|
|
21
|
+
name: 'rejects leading-zero chain ids',
|
|
22
|
+
source: 'did:pkh:eip155:01:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
expected: null,
|
|
26
|
+
name: 'rejects unsafe integer chain ids',
|
|
27
|
+
source: 'did:pkh:eip155:9007199254740992:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
expected: null,
|
|
31
|
+
name: 'rejects invalid addresses',
|
|
32
|
+
source: 'did:pkh:eip155:42431:not-an-address',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
expected: null,
|
|
36
|
+
name: 'rejects extra path segments',
|
|
37
|
+
source: 'did:pkh:eip155:42431:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12:extra',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
expected: null,
|
|
41
|
+
name: 'rejects unsupported namespaces',
|
|
42
|
+
source: 'did:pkh:solana:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
|
|
43
|
+
},
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
describe('Proof', () => {
|
|
47
|
+
test('types has Proof with challengeId field', () => {
|
|
48
|
+
expect(Proof.types).toEqual({
|
|
49
|
+
Proof: [{ name: 'challengeId', type: 'string' }],
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('domain returns EIP-712 domain with name, version, chainId', () => {
|
|
54
|
+
const d = Proof.domain(42431)
|
|
55
|
+
expect(d).toEqual({ name: 'MPP', version: '1', chainId: 42431 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('domain uses provided chainId', () => {
|
|
59
|
+
expect(Proof.domain(1).chainId).toBe(1)
|
|
60
|
+
expect(Proof.domain(99999).chainId).toBe(99999)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('message wraps challengeId', () => {
|
|
64
|
+
expect(Proof.message('abc123')).toEqual({ challengeId: 'abc123' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('proofSource constructs did:pkh DID', () => {
|
|
68
|
+
expect(Proof.proofSource({ address: '0x1234567890abcdef', chainId: 42431 })).toBe(
|
|
69
|
+
'did:pkh:eip155:42431:0x1234567890abcdef',
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('proofSource preserves address casing', () => {
|
|
74
|
+
const address = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12'
|
|
75
|
+
expect(Proof.proofSource({ address, chainId: 1 })).toBe(`did:pkh:eip155:1:${address}`)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
for (const { expected, name, source } of parseProofSourceCases) {
|
|
79
|
+
test(`parseProofSource ${name}`, () => {
|
|
80
|
+
expect(Proof.parseProofSource(source)).toEqual(expected)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
})
|