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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/cli/account.d.ts.map +1 -1
  7. package/dist/cli/account.js +12 -2
  8. package/dist/cli/account.js.map +1 -1
  9. package/dist/server/Mppx.js +6 -5
  10. package/dist/server/Mppx.js.map +1 -1
  11. package/dist/stripe/server/Charge.d.ts.map +1 -1
  12. package/dist/stripe/server/Charge.js +3 -3
  13. package/dist/stripe/server/Charge.js.map +1 -1
  14. package/dist/tempo/Methods.d.ts +3 -0
  15. package/dist/tempo/Methods.d.ts.map +1 -1
  16. package/dist/tempo/Methods.js +1 -0
  17. package/dist/tempo/Methods.js.map +1 -1
  18. package/dist/tempo/client/Charge.d.ts +3 -0
  19. package/dist/tempo/client/Charge.d.ts.map +1 -1
  20. package/dist/tempo/client/Charge.js +18 -2
  21. package/dist/tempo/client/Charge.js.map +1 -1
  22. package/dist/tempo/client/Methods.d.ts +3 -0
  23. package/dist/tempo/client/Methods.d.ts.map +1 -1
  24. package/dist/tempo/internal/proof.d.ts +29 -0
  25. package/dist/tempo/internal/proof.d.ts.map +1 -0
  26. package/dist/tempo/internal/proof.js +32 -0
  27. package/dist/tempo/internal/proof.js.map +1 -0
  28. package/dist/tempo/server/Charge.d.ts +11 -3
  29. package/dist/tempo/server/Charge.d.ts.map +1 -1
  30. package/dist/tempo/server/Charge.js +54 -4
  31. package/dist/tempo/server/Charge.js.map +1 -1
  32. package/dist/tempo/server/Methods.d.ts +3 -0
  33. package/dist/tempo/server/Methods.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/Expires.ts +25 -0
  36. package/src/cli/account.ts +13 -2
  37. package/src/cli/cli.test.ts +230 -1
  38. package/src/middlewares/elysia.test.ts +130 -9
  39. package/src/middlewares/express.test.ts +123 -59
  40. package/src/middlewares/hono.test.ts +81 -39
  41. package/src/middlewares/nextjs.test.ts +162 -41
  42. package/src/server/Mppx.test.ts +86 -0
  43. package/src/server/Mppx.ts +5 -5
  44. package/src/stripe/server/Charge.ts +3 -7
  45. package/src/tempo/Methods.test.ts +26 -0
  46. package/src/tempo/Methods.ts +1 -0
  47. package/src/tempo/client/Charge.ts +26 -3
  48. package/src/tempo/internal/charge.test.ts +66 -0
  49. package/src/tempo/internal/proof.test.ts +83 -0
  50. package/src/tempo/internal/proof.ts +35 -0
  51. package/src/tempo/server/Charge.test.ts +660 -1
  52. package/src/tempo/server/Charge.ts +80 -5
  53. package/src/tempo/server/Session.test.ts +1123 -53
  54. package/src/tempo/server/internal/transport.test.ts +32 -0
  55. package/src/tempo/session/Chain.test.ts +35 -0
  56. 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<{ url: string; close: () => void }>((resolve) => {
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
- describe('charge', () => {
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
- beforeAll(async () => {
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 handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
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 = Mppx.create({
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
  })
@@ -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',
@@ -418,14 +418,14 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
418
418
  }
419
419
  }
420
420
 
421
- // Reject expired credentials
422
- if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) {
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: new Errors.PaymentExpiredError({
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
- PaymentActionRequiredError,
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
- if (challenge.expires && new Date(challenge.expires) < new Date())
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',
@@ -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 { prepareTransactionRequest, sendCallsSync, signTransaction } from 'viem/actions'
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
+ })