mppx 0.4.11 → 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 +21 -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/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +55 -7
- 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 +18 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +28 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +24 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +51 -9
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +18 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.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 +20 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +180 -103
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +20 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +18 -3
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/Expires.ts +25 -0
- package/src/cli/cli.test.ts +230 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- 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 +373 -0
- package/src/server/Mppx.ts +64 -10
- package/src/stripe/server/Charge.ts +3 -7
- package/src/tempo/Methods.test.ts +105 -0
- package/src/tempo/Methods.ts +54 -17
- package/src/tempo/client/Charge.ts +67 -11
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.test.ts +66 -0
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/internal/proof.test.ts +36 -0
- package/src/tempo/internal/proof.ts +19 -0
- package/src/tempo/server/Charge.test.ts +593 -1
- package/src/tempo/server/Charge.ts +233 -126
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +1152 -54
- package/src/tempo/server/Session.ts +26 -17
- package/src/tempo/server/internal/transport.test.ts +32 -0
- package/src/tempo/session/Chain.test.ts +60 -5
- package/src/tempo/session/Chain.ts +30 -14
- package/src/tempo/session/Sse.test.ts +31 -0
package/src/server/Mppx.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function create<
|
|
|
153
153
|
const transport extends Transport.AnyTransport = Transport.Http,
|
|
154
154
|
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
|
|
155
155
|
const {
|
|
156
|
-
realm = Env.get('realm')
|
|
156
|
+
realm = Env.get('realm'),
|
|
157
157
|
secretKey = Env.get('secretKey'),
|
|
158
158
|
transport = Transport.http() as transport,
|
|
159
159
|
} = config
|
|
@@ -222,7 +222,7 @@ export function create<
|
|
|
222
222
|
return {
|
|
223
223
|
methods,
|
|
224
224
|
compose: composeFn,
|
|
225
|
-
realm: realm as string,
|
|
225
|
+
realm: realm as string | undefined,
|
|
226
226
|
transport,
|
|
227
227
|
...handlers,
|
|
228
228
|
} as never
|
|
@@ -235,7 +235,7 @@ export declare namespace create {
|
|
|
235
235
|
> = {
|
|
236
236
|
/** Array of configured methods. @example [tempo()] */
|
|
237
237
|
methods: methods
|
|
238
|
-
/** Server realm (e.g., hostname).
|
|
238
|
+
/** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */
|
|
239
239
|
realm?: string | undefined
|
|
240
240
|
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
|
|
241
241
|
secretKey?: string | undefined
|
|
@@ -283,6 +283,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
283
283
|
: merged
|
|
284
284
|
) as never
|
|
285
285
|
|
|
286
|
+
// Resolve realm: explicit > env var > request Host header.
|
|
287
|
+
const effectiveRealm = realm ?? resolveRealmFromRequest(input)
|
|
288
|
+
|
|
286
289
|
// Recompute challenge from options. The HMAC-bound ID means we don't need to
|
|
287
290
|
// store challenges server-side—if the client echoes back a credential with
|
|
288
291
|
// a matching ID, we know it was issued by us with these exact parameters.
|
|
@@ -290,7 +293,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
290
293
|
description,
|
|
291
294
|
expires,
|
|
292
295
|
meta,
|
|
293
|
-
realm,
|
|
296
|
+
realm: effectiveRealm,
|
|
294
297
|
request,
|
|
295
298
|
secretKey,
|
|
296
299
|
})
|
|
@@ -389,17 +392,40 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
389
392
|
return { challenge: response, status: 402 }
|
|
390
393
|
}
|
|
391
394
|
}
|
|
395
|
+
|
|
396
|
+
// Compare payment-relevant methodDetails fields (memo, splits).
|
|
397
|
+
// These are excluded from the top-level field check above but
|
|
398
|
+
// affect verification semantics — a credential issued for a
|
|
399
|
+
// no-splits route must not be accepted on a splits route.
|
|
400
|
+
for (const field of ['memo', 'splits'] as const) {
|
|
401
|
+
const routeVal = routeDetails[field]
|
|
402
|
+
const echoedVal = echoedDetails[field]
|
|
403
|
+
if (
|
|
404
|
+
routeVal !== undefined &&
|
|
405
|
+
JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
|
|
406
|
+
) {
|
|
407
|
+
const response = await transport.respondChallenge({
|
|
408
|
+
challenge,
|
|
409
|
+
input,
|
|
410
|
+
error: new Errors.InvalidChallengeError({
|
|
411
|
+
id: credential.challenge.id,
|
|
412
|
+
reason: `credential ${field} does not match this route's requirements`,
|
|
413
|
+
}),
|
|
414
|
+
})
|
|
415
|
+
return { challenge: response, status: 402 }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
392
418
|
}
|
|
393
419
|
}
|
|
394
420
|
|
|
395
|
-
// Reject expired
|
|
396
|
-
|
|
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) {
|
|
397
425
|
const response = await transport.respondChallenge({
|
|
398
426
|
challenge,
|
|
399
427
|
input,
|
|
400
|
-
error:
|
|
401
|
-
expires: credential.challenge.expires,
|
|
402
|
-
}),
|
|
428
|
+
error: error as Errors.PaymentError,
|
|
403
429
|
})
|
|
404
430
|
return { challenge: response, status: 402 }
|
|
405
431
|
}
|
|
@@ -483,7 +509,7 @@ declare namespace createMethodFn {
|
|
|
483
509
|
> = {
|
|
484
510
|
defaults?: defaults
|
|
485
511
|
method: method
|
|
486
|
-
realm: string
|
|
512
|
+
realm: string | undefined
|
|
487
513
|
request?: Method.RequestFn<method>
|
|
488
514
|
respond?: Method.RespondFn<method>
|
|
489
515
|
secretKey: string
|
|
@@ -498,6 +524,34 @@ declare namespace createMethodFn {
|
|
|
498
524
|
> = MethodFn<method, transport, defaults>
|
|
499
525
|
}
|
|
500
526
|
|
|
527
|
+
const defaultRealm = 'MPP Payment'
|
|
528
|
+
const Warnings = {
|
|
529
|
+
realmFallback: 'realm-fallback',
|
|
530
|
+
} as const
|
|
531
|
+
|
|
532
|
+
const _warned = new Set<string>()
|
|
533
|
+
function warnOnce(key: string, message: string) {
|
|
534
|
+
if (_warned.has(key)) return
|
|
535
|
+
_warned.add(key)
|
|
536
|
+
console.warn(`[mppx] ${message}`)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Extracts hostname from the request URL, falling back to a default. */
|
|
540
|
+
function resolveRealmFromRequest(input: unknown): string {
|
|
541
|
+
try {
|
|
542
|
+
const url = typeof (input as any)?.url === 'string' ? (input as any).url : undefined
|
|
543
|
+
if (url) {
|
|
544
|
+
const { protocol, hostname } = new URL(url)
|
|
545
|
+
if (/^https?:$/.test(protocol) && hostname) return hostname
|
|
546
|
+
}
|
|
547
|
+
} catch {}
|
|
548
|
+
warnOnce(
|
|
549
|
+
Warnings.realmFallback,
|
|
550
|
+
`Could not auto-detect realm from request. Falling back to "${defaultRealm}". Set \`realm\` in Mppx.create() or the MPP_REALM env var.`,
|
|
551
|
+
)
|
|
552
|
+
return defaultRealm
|
|
553
|
+
}
|
|
554
|
+
|
|
501
555
|
export type MethodFn<
|
|
502
556
|
method extends Method.Method,
|
|
503
557
|
transport extends Transport.AnyTransport,
|
|
@@ -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')
|
|
@@ -51,6 +51,111 @@ describe('charge', () => {
|
|
|
51
51
|
expect(result.success).toBe(true)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
test('schema: validates request with splits', () => {
|
|
55
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
56
|
+
amount: '1',
|
|
57
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
58
|
+
decimals: 6,
|
|
59
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
60
|
+
splits: [
|
|
61
|
+
{
|
|
62
|
+
amount: '0.25',
|
|
63
|
+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
amount: '0.1',
|
|
67
|
+
memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
|
68
|
+
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
})
|
|
72
|
+
expect(result.success).toBe(true)
|
|
73
|
+
if (!result.success) return
|
|
74
|
+
|
|
75
|
+
expect(result.data.methodDetails?.splits).toEqual([
|
|
76
|
+
{
|
|
77
|
+
amount: '250000',
|
|
78
|
+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
amount: '100000',
|
|
82
|
+
memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
|
83
|
+
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
84
|
+
},
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('schema: rejects empty splits', () => {
|
|
89
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
90
|
+
amount: '1',
|
|
91
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
92
|
+
decimals: 6,
|
|
93
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
94
|
+
splits: [],
|
|
95
|
+
})
|
|
96
|
+
expect(result.success).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('schema: rejects more than 10 splits', () => {
|
|
100
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
101
|
+
amount: '11',
|
|
102
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
103
|
+
decimals: 6,
|
|
104
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
105
|
+
splits: Array.from({ length: 11 }, (_, index) => ({
|
|
106
|
+
amount: '0.1',
|
|
107
|
+
recipient: `0x${(index + 1).toString(16).padStart(40, '0')}`,
|
|
108
|
+
})),
|
|
109
|
+
})
|
|
110
|
+
expect(result.success).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('schema: rejects split totals greater than or equal to amount', () => {
|
|
114
|
+
const result = Methods.charge.schema.request.safeParse({
|
|
115
|
+
amount: '1',
|
|
116
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
117
|
+
decimals: 6,
|
|
118
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
119
|
+
splits: [
|
|
120
|
+
{
|
|
121
|
+
amount: '0.5',
|
|
122
|
+
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
amount: '0.5',
|
|
126
|
+
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
})
|
|
130
|
+
expect(result.success).toBe(false)
|
|
131
|
+
})
|
|
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
|
+
|
|
54
159
|
test('schema: rejects invalid request', () => {
|
|
55
160
|
const result = Methods.charge.schema.request.safeParse({
|
|
56
161
|
amount: '1',
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
import type { Account } from 'viem'
|
|
1
|
+
import type { Account, Address } from 'viem'
|
|
2
2
|
import { parseUnits } from 'viem'
|
|
3
3
|
|
|
4
4
|
import * as Method from '../Method.js'
|
|
5
5
|
import * as z from '../zod.js'
|
|
6
6
|
|
|
7
|
+
const split = z.object({
|
|
8
|
+
amount: z.amount(),
|
|
9
|
+
memo: z.optional(z.hash()),
|
|
10
|
+
recipient: z.pipe(
|
|
11
|
+
z.string(),
|
|
12
|
+
z.transform((v) => v as Address),
|
|
13
|
+
),
|
|
14
|
+
})
|
|
15
|
+
|
|
7
16
|
/**
|
|
8
17
|
* Tempo charge intent for one-time TIP-20 token transfers.
|
|
9
18
|
*
|
|
@@ -17,34 +26,62 @@ export const charge = Method.from({
|
|
|
17
26
|
payload: z.discriminatedUnion('type', [
|
|
18
27
|
z.object({ hash: z.hash(), type: z.literal('hash') }),
|
|
19
28
|
z.object({ signature: z.signature(), type: z.literal('transaction') }),
|
|
29
|
+
z.object({ signature: z.signature(), type: z.literal('proof') }),
|
|
20
30
|
]),
|
|
21
31
|
},
|
|
22
32
|
request: z.pipe(
|
|
23
|
-
z
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
z.
|
|
32
|
-
z.
|
|
33
|
-
|
|
33
|
+
z
|
|
34
|
+
.object({
|
|
35
|
+
amount: z.amount(),
|
|
36
|
+
chainId: z.optional(z.number()),
|
|
37
|
+
currency: z.string(),
|
|
38
|
+
decimals: z.number(),
|
|
39
|
+
description: z.optional(z.string()),
|
|
40
|
+
externalId: z.optional(z.string()),
|
|
41
|
+
feePayer: z.optional(
|
|
42
|
+
z.pipe(
|
|
43
|
+
z.union([z.boolean(), z.custom<Account>()]),
|
|
44
|
+
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
|
|
45
|
+
),
|
|
34
46
|
),
|
|
47
|
+
memo: z.optional(z.hash()),
|
|
48
|
+
recipient: z.optional(z.string()),
|
|
49
|
+
splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(10))),
|
|
50
|
+
})
|
|
51
|
+
.check(
|
|
52
|
+
z.refine(({ amount, decimals, splits }) => {
|
|
53
|
+
if (!splits) return true
|
|
54
|
+
|
|
55
|
+
const totalAmount = parseUnits(amount, decimals)
|
|
56
|
+
const splitTotal = splits.reduce(
|
|
57
|
+
(sum, split) => sum + parseUnits(split.amount, decimals),
|
|
58
|
+
0n,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
splits.every((split) => parseUnits(split.amount, decimals) > 0n) &&
|
|
63
|
+
splitTotal < totalAmount
|
|
64
|
+
)
|
|
65
|
+
}, 'Invalid splits'),
|
|
35
66
|
),
|
|
36
|
-
|
|
37
|
-
recipient: z.optional(z.string()),
|
|
38
|
-
}),
|
|
39
|
-
z.transform(({ amount, chainId, decimals, feePayer, memo, ...rest }) => ({
|
|
67
|
+
z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
|
|
40
68
|
...rest,
|
|
41
69
|
amount: parseUnits(amount, decimals).toString(),
|
|
42
|
-
...(chainId !== undefined ||
|
|
70
|
+
...(chainId !== undefined ||
|
|
71
|
+
feePayer !== undefined ||
|
|
72
|
+
memo !== undefined ||
|
|
73
|
+
splits !== undefined
|
|
43
74
|
? {
|
|
44
75
|
methodDetails: {
|
|
45
76
|
...(chainId !== undefined && { chainId }),
|
|
46
77
|
...(feePayer !== undefined && { feePayer }),
|
|
47
78
|
...(memo !== undefined && { memo }),
|
|
79
|
+
...(splits !== undefined && {
|
|
80
|
+
splits: splits.map((split) => ({
|
|
81
|
+
...split,
|
|
82
|
+
amount: parseUnits(split.amount, decimals).toString(),
|
|
83
|
+
})),
|
|
84
|
+
}),
|
|
48
85
|
},
|
|
49
86
|
}
|
|
50
87
|
: {}),
|
|
@@ -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
|
|
|
@@ -11,7 +16,9 @@ import * as Client from '../../viem/Client.js'
|
|
|
11
16
|
import * as z from '../../zod.js'
|
|
12
17
|
import * as Attribution from '../Attribution.js'
|
|
13
18
|
import * as AutoSwap from '../internal/auto-swap.js'
|
|
19
|
+
import * as Charge_internal from '../internal/charge.js'
|
|
14
20
|
import * as defaults from '../internal/defaults.js'
|
|
21
|
+
import * as Proof from '../internal/proof.js'
|
|
15
22
|
import * as Methods from '../Methods.js'
|
|
16
23
|
|
|
17
24
|
/**
|
|
@@ -48,24 +55,60 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
48
55
|
const client = await getClient({ chainId })
|
|
49
56
|
const account = getAccount(client, context)
|
|
50
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
|
+
|
|
51
77
|
const mode =
|
|
52
78
|
context?.mode ?? parameters.mode ?? (account.type === 'json-rpc' ? 'push' : 'pull')
|
|
53
79
|
|
|
54
|
-
const { request } = challenge
|
|
55
|
-
const { amount, methodDetails } = request
|
|
56
80
|
const currency = request.currency as Address
|
|
57
|
-
|
|
81
|
+
|
|
82
|
+
if (parameters.expectedRecipients) {
|
|
83
|
+
const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase()))
|
|
84
|
+
const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined
|
|
85
|
+
if (splits) {
|
|
86
|
+
for (const split of splits) {
|
|
87
|
+
if (!allowed.has(split.recipient.toLowerCase()))
|
|
88
|
+
throw new Error(`Unexpected split recipient: ${split.recipient}`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
58
92
|
|
|
59
93
|
const memo = methodDetails?.memo
|
|
60
94
|
? (methodDetails.memo as Hex.Hex)
|
|
61
95
|
: Attribution.encode({ serverId: challenge.realm, clientId })
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
const transfers = Charge_internal.getTransfers({
|
|
97
|
+
amount,
|
|
98
|
+
methodDetails: {
|
|
99
|
+
...methodDetails,
|
|
100
|
+
memo,
|
|
101
|
+
},
|
|
102
|
+
recipient: request.recipient as Address,
|
|
68
103
|
})
|
|
104
|
+
const transferCalls = transfers.map((transfer) =>
|
|
105
|
+
Actions.token.transfer.call({
|
|
106
|
+
amount: BigInt(transfer.amount),
|
|
107
|
+
...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
|
|
108
|
+
to: transfer.recipient as Address,
|
|
109
|
+
token: currency,
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
69
112
|
|
|
70
113
|
const autoSwap = AutoSwap.resolve(
|
|
71
114
|
context?.autoSwap ?? parameters.autoSwap,
|
|
@@ -82,7 +125,14 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
82
125
|
})
|
|
83
126
|
: undefined
|
|
84
127
|
|
|
85
|
-
const calls = [...(swapCalls ?? []),
|
|
128
|
+
const calls = [...(swapCalls ?? []), ...transferCalls]
|
|
129
|
+
|
|
130
|
+
const validBefore = (() => {
|
|
131
|
+
const defaultExpiry = Math.floor(Date.now() / 1000) + 25
|
|
132
|
+
if (!challenge.expires) return defaultExpiry
|
|
133
|
+
const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000)
|
|
134
|
+
return Math.min(defaultExpiry, challengeExpiry)
|
|
135
|
+
})()
|
|
86
136
|
|
|
87
137
|
if (mode === 'push') {
|
|
88
138
|
const { receipts } = await sendCallsSync(client, {
|
|
@@ -104,6 +154,7 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
104
154
|
calls,
|
|
105
155
|
...(methodDetails?.feePayer && { feePayer: true }),
|
|
106
156
|
nonceKey: 'expiring',
|
|
157
|
+
validBefore,
|
|
107
158
|
} as never)
|
|
108
159
|
// FIXME: figure out gas estimation issue for fee payer tx
|
|
109
160
|
prepared.gas = prepared.gas! + 5_000n
|
|
@@ -131,6 +182,11 @@ export declare namespace charge {
|
|
|
131
182
|
autoSwap?: AutoSwap | undefined
|
|
132
183
|
/** Client identifier used to derive the client fingerprint in attribution memos. */
|
|
133
184
|
clientId?: string | undefined
|
|
185
|
+
/**
|
|
186
|
+
* Allowlist of expected split recipient addresses. When set, the client
|
|
187
|
+
* rejects any challenge whose split recipients are not in this list.
|
|
188
|
+
*/
|
|
189
|
+
expectedRecipients?: readonly Address[] | undefined
|
|
134
190
|
/**
|
|
135
191
|
* Controls how the charge transaction is submitted.
|
|
136
192
|
*
|
|
@@ -33,18 +33,11 @@ export function resolve(parameters: resolve.Parameters) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export declare namespace resolve {
|
|
36
|
-
type Parameters = {
|
|
37
|
-
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
| {
|
|
44
|
-
/** Address that receives payment. */
|
|
45
|
-
account?: Address | undefined
|
|
46
|
-
/** Optional fee payer account or fee payer URL for covering transaction fees. */
|
|
47
|
-
feePayer?: Account | string | undefined
|
|
48
|
-
}
|
|
49
|
-
)
|
|
36
|
+
type Parameters = {
|
|
37
|
+
recipient?: Address | undefined
|
|
38
|
+
/** Account or address that performs payment operations / receives payment. */
|
|
39
|
+
account?: Account | Address | undefined
|
|
40
|
+
/** When `true`, the account also sponsors fees. An `Account` object or URL string can also be provided as a dedicated fee payer. */
|
|
41
|
+
feePayer?: Account | string | true | undefined
|
|
42
|
+
}
|
|
50
43
|
}
|
|
@@ -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,43 @@
|
|
|
1
|
+
import type { Address } from 'viem'
|
|
2
|
+
|
|
3
|
+
export type Split = {
|
|
4
|
+
amount: string
|
|
5
|
+
memo?: string | undefined
|
|
6
|
+
recipient: Address
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type Transfer = {
|
|
10
|
+
amount: string
|
|
11
|
+
memo?: string | undefined
|
|
12
|
+
recipient: Address
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getTransfers(request: {
|
|
16
|
+
amount: string
|
|
17
|
+
methodDetails?: { memo?: string | undefined; splits?: readonly Split[] | undefined }
|
|
18
|
+
recipient: Address
|
|
19
|
+
}): Transfer[] {
|
|
20
|
+
const totalAmount = BigInt(request.amount)
|
|
21
|
+
const splits = request.methodDetails?.splits ?? []
|
|
22
|
+
|
|
23
|
+
const splitTotal = splits.reduce((sum, split) => sum + BigInt(split.amount), 0n)
|
|
24
|
+
if (splitTotal >= totalAmount)
|
|
25
|
+
throw new Error('Invalid charge request: split total must be less than total amount.')
|
|
26
|
+
|
|
27
|
+
const primaryAmount = totalAmount - splitTotal
|
|
28
|
+
if (primaryAmount <= 0n)
|
|
29
|
+
throw new Error('Invalid charge request: primary transfer amount must be positive.')
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
amount: primaryAmount.toString(),
|
|
34
|
+
memo: request.methodDetails?.memo,
|
|
35
|
+
recipient: request.recipient,
|
|
36
|
+
},
|
|
37
|
+
...splits.map((split) => ({
|
|
38
|
+
amount: split.amount,
|
|
39
|
+
memo: split.memo,
|
|
40
|
+
recipient: split.recipient,
|
|
41
|
+
})),
|
|
42
|
+
]
|
|
43
|
+
}
|