mppx 0.4.12 → 0.5.0

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 (52) hide show
  1. package/CHANGELOG.md +6 -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/server/Mppx.js +6 -5
  7. package/dist/server/Mppx.js.map +1 -1
  8. package/dist/stripe/server/Charge.d.ts.map +1 -1
  9. package/dist/stripe/server/Charge.js +3 -3
  10. package/dist/stripe/server/Charge.js.map +1 -1
  11. package/dist/tempo/Methods.d.ts +3 -0
  12. package/dist/tempo/Methods.d.ts.map +1 -1
  13. package/dist/tempo/Methods.js +1 -0
  14. package/dist/tempo/Methods.js.map +1 -1
  15. package/dist/tempo/client/Charge.d.ts +3 -0
  16. package/dist/tempo/client/Charge.d.ts.map +1 -1
  17. package/dist/tempo/client/Charge.js +18 -2
  18. package/dist/tempo/client/Charge.js.map +1 -1
  19. package/dist/tempo/client/Methods.d.ts +3 -0
  20. package/dist/tempo/client/Methods.d.ts.map +1 -1
  21. package/dist/tempo/internal/proof.d.ts +23 -0
  22. package/dist/tempo/internal/proof.d.ts.map +1 -0
  23. package/dist/tempo/internal/proof.js +17 -0
  24. package/dist/tempo/internal/proof.js.map +1 -0
  25. package/dist/tempo/server/Charge.d.ts +3 -0
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +32 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Methods.d.ts +3 -0
  30. package/dist/tempo/server/Methods.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/Expires.ts +25 -0
  33. package/src/cli/cli.test.ts +230 -1
  34. package/src/middlewares/elysia.test.ts +127 -4
  35. package/src/middlewares/express.test.ts +120 -54
  36. package/src/middlewares/hono.test.ts +73 -34
  37. package/src/middlewares/nextjs.test.ts +159 -36
  38. package/src/server/Mppx.test.ts +86 -0
  39. package/src/server/Mppx.ts +5 -5
  40. package/src/stripe/server/Charge.ts +3 -7
  41. package/src/tempo/Methods.test.ts +26 -0
  42. package/src/tempo/Methods.ts +1 -0
  43. package/src/tempo/client/Charge.ts +26 -3
  44. package/src/tempo/internal/charge.test.ts +66 -0
  45. package/src/tempo/internal/proof.test.ts +36 -0
  46. package/src/tempo/internal/proof.ts +19 -0
  47. package/src/tempo/server/Charge.test.ts +362 -1
  48. package/src/tempo/server/Charge.ts +40 -2
  49. package/src/tempo/server/Session.test.ts +1123 -53
  50. package/src/tempo/server/internal/transport.test.ts +32 -0
  51. package/src/tempo/session/Chain.test.ts +35 -0
  52. package/src/tempo/session/Sse.test.ts +31 -0
@@ -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,36 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as Proof from './proof.js'
4
+
5
+ describe('Proof', () => {
6
+ test('types has Proof with challengeId field', () => {
7
+ expect(Proof.types).toEqual({
8
+ Proof: [{ name: 'challengeId', type: 'string' }],
9
+ })
10
+ })
11
+
12
+ test('domain returns EIP-712 domain with name, version, chainId', () => {
13
+ const d = Proof.domain(42431)
14
+ expect(d).toEqual({ name: 'MPP', version: '1', chainId: 42431 })
15
+ })
16
+
17
+ test('domain uses provided chainId', () => {
18
+ expect(Proof.domain(1).chainId).toBe(1)
19
+ expect(Proof.domain(99999).chainId).toBe(99999)
20
+ })
21
+
22
+ test('message wraps challengeId', () => {
23
+ expect(Proof.message('abc123')).toEqual({ challengeId: 'abc123' })
24
+ })
25
+
26
+ test('proofSource constructs did:pkh DID', () => {
27
+ expect(Proof.proofSource({ address: '0x1234567890abcdef', chainId: 42431 })).toBe(
28
+ 'did:pkh:eip155:42431:0x1234567890abcdef',
29
+ )
30
+ })
31
+
32
+ test('proofSource preserves address casing', () => {
33
+ const address = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12'
34
+ expect(Proof.proofSource({ address, chainId: 1 })).toBe(`did:pkh:eip155:1:${address}`)
35
+ })
36
+ })
@@ -0,0 +1,19 @@
1
+ /** EIP-712 typed data types for proof credentials. */
2
+ export const types = {
3
+ Proof: [{ name: 'challengeId', type: 'string' }],
4
+ } as const
5
+
6
+ /** Constructs the EIP-712 domain for a proof credential. */
7
+ export function domain(chainId: number) {
8
+ return { name: 'MPP', version: '1', chainId } as const
9
+ }
10
+
11
+ /** Constructs the EIP-712 message for a proof credential. */
12
+ export function message(challengeId: string) {
13
+ return { challengeId } as const
14
+ }
15
+
16
+ /** Constructs the expected `did:pkh` source DID for a proof credential. */
17
+ export function proofSource(parameters: { address: string; chainId: number }): string {
18
+ return `did:pkh:eip155:${parameters.chainId}:${parameters.address}`
19
+ }