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.
Files changed (89) hide show
  1. package/CHANGELOG.md +21 -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/internal/env.d.ts +1 -1
  7. package/dist/internal/env.d.ts.map +1 -1
  8. package/dist/internal/env.js +2 -6
  9. package/dist/internal/env.js.map +1 -1
  10. package/dist/internal/types.d.ts +23 -0
  11. package/dist/internal/types.d.ts.map +1 -1
  12. package/dist/server/Mppx.d.ts +1 -1
  13. package/dist/server/Mppx.d.ts.map +1 -1
  14. package/dist/server/Mppx.js +55 -7
  15. package/dist/server/Mppx.js.map +1 -1
  16. package/dist/stripe/server/Charge.d.ts.map +1 -1
  17. package/dist/stripe/server/Charge.js +3 -3
  18. package/dist/stripe/server/Charge.js.map +1 -1
  19. package/dist/tempo/Methods.d.ts +18 -0
  20. package/dist/tempo/Methods.d.ts.map +1 -1
  21. package/dist/tempo/Methods.js +28 -3
  22. package/dist/tempo/Methods.js.map +1 -1
  23. package/dist/tempo/client/Charge.d.ts +24 -0
  24. package/dist/tempo/client/Charge.d.ts.map +1 -1
  25. package/dist/tempo/client/Charge.js +51 -9
  26. package/dist/tempo/client/Charge.js.map +1 -1
  27. package/dist/tempo/client/Methods.d.ts +18 -0
  28. package/dist/tempo/client/Methods.d.ts.map +1 -1
  29. package/dist/tempo/internal/account.d.ts +5 -11
  30. package/dist/tempo/internal/account.d.ts.map +1 -1
  31. package/dist/tempo/internal/charge.d.ts +20 -0
  32. package/dist/tempo/internal/charge.d.ts.map +1 -0
  33. package/dist/tempo/internal/charge.js +23 -0
  34. package/dist/tempo/internal/charge.js.map +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +15 -3
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/internal/proof.d.ts +23 -0
  39. package/dist/tempo/internal/proof.d.ts.map +1 -0
  40. package/dist/tempo/internal/proof.js +17 -0
  41. package/dist/tempo/internal/proof.js.map +1 -0
  42. package/dist/tempo/server/Charge.d.ts +20 -2
  43. package/dist/tempo/server/Charge.d.ts.map +1 -1
  44. package/dist/tempo/server/Charge.js +180 -103
  45. package/dist/tempo/server/Charge.js.map +1 -1
  46. package/dist/tempo/server/Methods.d.ts +20 -2
  47. package/dist/tempo/server/Methods.d.ts.map +1 -1
  48. package/dist/tempo/server/Methods.js +4 -1
  49. package/dist/tempo/server/Methods.js.map +1 -1
  50. package/dist/tempo/server/Session.d.ts +9 -4
  51. package/dist/tempo/server/Session.d.ts.map +1 -1
  52. package/dist/tempo/server/Session.js +18 -3
  53. package/dist/tempo/server/Session.js.map +1 -1
  54. package/dist/tempo/session/Chain.d.ts +18 -2
  55. package/dist/tempo/session/Chain.d.ts.map +1 -1
  56. package/dist/tempo/session/Chain.js +18 -14
  57. package/dist/tempo/session/Chain.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/Expires.ts +25 -0
  60. package/src/cli/cli.test.ts +230 -1
  61. package/src/internal/env.test.ts +12 -12
  62. package/src/internal/env.ts +2 -6
  63. package/src/internal/types.ts +25 -0
  64. package/src/middlewares/elysia.test.ts +127 -4
  65. package/src/middlewares/express.test.ts +120 -54
  66. package/src/middlewares/hono.test.ts +73 -34
  67. package/src/middlewares/nextjs.test.ts +159 -36
  68. package/src/server/Mppx.test.ts +373 -0
  69. package/src/server/Mppx.ts +64 -10
  70. package/src/stripe/server/Charge.ts +3 -7
  71. package/src/tempo/Methods.test.ts +105 -0
  72. package/src/tempo/Methods.ts +54 -17
  73. package/src/tempo/client/Charge.ts +67 -11
  74. package/src/tempo/internal/account.ts +7 -14
  75. package/src/tempo/internal/charge.test.ts +66 -0
  76. package/src/tempo/internal/charge.ts +43 -0
  77. package/src/tempo/internal/fee-payer.test.ts +33 -14
  78. package/src/tempo/internal/fee-payer.ts +21 -6
  79. package/src/tempo/internal/proof.test.ts +36 -0
  80. package/src/tempo/internal/proof.ts +19 -0
  81. package/src/tempo/server/Charge.test.ts +593 -1
  82. package/src/tempo/server/Charge.ts +233 -126
  83. package/src/tempo/server/Methods.ts +4 -1
  84. package/src/tempo/server/Session.test.ts +1152 -54
  85. package/src/tempo/server/Session.ts +26 -17
  86. package/src/tempo/server/internal/transport.test.ts +32 -0
  87. package/src/tempo/session/Chain.test.ts +60 -5
  88. package/src/tempo/session/Chain.ts +30 -14
  89. package/src/tempo/session/Sse.test.ts +31 -0
@@ -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') ?? 'MPP Payment',
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). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
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 credentials
396
- 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) {
397
425
  const response = await transport.respondChallenge({
398
426
  challenge,
399
427
  input,
400
- error: new Errors.PaymentExpiredError({
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
- 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')
@@ -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',
@@ -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.object({
24
- amount: z.amount(),
25
- chainId: z.optional(z.number()),
26
- currency: z.string(),
27
- decimals: z.number(),
28
- description: z.optional(z.string()),
29
- externalId: z.optional(z.string()),
30
- feePayer: z.optional(
31
- z.pipe(
32
- z.union([z.boolean(), z.custom<Account>()]),
33
- z.transform((v): boolean => (typeof v === 'object' ? true : v)),
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
- memo: z.optional(z.hash()),
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 || feePayer !== undefined || memo !== 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 { 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
 
@@ -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
- const recipient = request.recipient as Address
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
- const transferCall = Actions.token.transfer.call({
64
- amount: BigInt(amount),
65
- memo,
66
- to: recipient,
67
- token: currency,
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 ?? []), transferCall]
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 = { recipient?: Address | undefined } & (
37
- | {
38
- /** Account that performs payment operations. */
39
- account?: Account | undefined
40
- /** When true, the account also sponsors (pays) transaction fees. */
41
- feePayer?: true | undefined
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
+ }