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.
- package/CHANGELOG.md +6 -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/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 +23 -0
- package/dist/tempo/internal/proof.d.ts.map +1 -0
- package/dist/tempo/internal/proof.js +17 -0
- package/dist/tempo/internal/proof.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +3 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +32 -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/cli.test.ts +230 -1
- package/src/middlewares/elysia.test.ts +127 -4
- package/src/middlewares/express.test.ts +120 -54
- package/src/middlewares/hono.test.ts +73 -34
- package/src/middlewares/nextjs.test.ts +159 -36
- 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 +36 -0
- package/src/tempo/internal/proof.ts +19 -0
- package/src/tempo/server/Charge.test.ts +362 -1
- package/src/tempo/server/Charge.ts +40 -2
- 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
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,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
|
+
}
|