Skip to content

Commit b944184

Browse files
committed
feat(tempo): add split payments for charge
1 parent f8b203c commit b944184

10 files changed

Lines changed: 920 additions & 161 deletions

File tree

.changeset/quiet-cooks-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': minor
3+
---
4+
5+
Add split-payment support to Tempo charge requests, including client transaction construction and stricter server verification for split transfers.

src/tempo/Methods.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,85 @@ describe('charge', () => {
5151
expect(result.success).toBe(true)
5252
})
5353

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+
54133
test('schema: rejects invalid request', () => {
55134
const result = Methods.charge.schema.request.safeParse({
56135
amount: '1',

src/tempo/Methods.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { parseUnits } from 'viem'
33

44
import * as Method from '../Method.js'
55
import * as z from '../zod.js'
6+
import { maxSplits } from './internal/charge.js'
7+
8+
const split = z.object({
9+
amount: z.amount(),
10+
memo: z.optional(z.hash()),
11+
recipient: z.string(),
12+
})
613

714
/**
815
* Tempo charge intent for one-time TIP-20 token transfers.
@@ -20,31 +27,58 @@ export const charge = Method.from({
2027
]),
2128
},
2229
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)),
30+
z
31+
.object({
32+
amount: z.amount(),
33+
chainId: z.optional(z.number()),
34+
currency: z.string(),
35+
decimals: z.number(),
36+
description: z.optional(z.string()),
37+
externalId: z.optional(z.string()),
38+
feePayer: z.optional(
39+
z.pipe(
40+
z.union([z.boolean(), z.custom<Account>()]),
41+
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
42+
),
3443
),
44+
memo: z.optional(z.hash()),
45+
recipient: z.optional(z.string()),
46+
splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(maxSplits))),
47+
})
48+
.check(
49+
z.refine(({ amount, decimals, splits }) => {
50+
if (!splits) return true
51+
52+
const totalAmount = parseUnits(amount, decimals)
53+
const splitTotal = splits.reduce(
54+
(sum, split) => sum + parseUnits(split.amount, decimals),
55+
0n,
56+
)
57+
58+
return (
59+
splits.every((split) => parseUnits(split.amount, decimals) > 0n) &&
60+
splitTotal < totalAmount
61+
)
62+
}, 'Invalid splits'),
3563
),
36-
memo: z.optional(z.hash()),
37-
recipient: z.optional(z.string()),
38-
}),
39-
z.transform(({ amount, chainId, decimals, feePayer, memo, ...rest }) => ({
64+
z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
4065
...rest,
4166
amount: parseUnits(amount, decimals).toString(),
42-
...(chainId !== undefined || feePayer !== undefined || memo !== undefined
67+
...(chainId !== undefined ||
68+
feePayer !== undefined ||
69+
memo !== undefined ||
70+
splits !== undefined
4371
? {
4472
methodDetails: {
4573
...(chainId !== undefined && { chainId }),
4674
...(feePayer !== undefined && { feePayer }),
4775
...(memo !== undefined && { memo }),
76+
...(splits !== undefined && {
77+
splits: splits.map((split) => ({
78+
...split,
79+
amount: parseUnits(split.amount, decimals).toString(),
80+
})),
81+
}),
4882
},
4983
}
5084
: {}),

src/tempo/client/Charge.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Client from '../../viem/Client.js'
1111
import * as z from '../../zod.js'
1212
import * as Attribution from '../Attribution.js'
1313
import * as AutoSwap from '../internal/auto-swap.js'
14+
import * as Charge_internal from '../internal/charge.js'
1415
import * as defaults from '../internal/defaults.js'
1516
import * as Methods from '../Methods.js'
1617

@@ -54,18 +55,26 @@ export function charge(parameters: charge.Parameters = {}) {
5455
const { request } = challenge
5556
const { amount, methodDetails } = request
5657
const currency = request.currency as Address
57-
const recipient = request.recipient as Address
5858

5959
const memo = methodDetails?.memo
6060
? (methodDetails.memo as Hex.Hex)
6161
: 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,
62+
const transfers = Charge_internal.getTransfers({
63+
amount,
64+
methodDetails: {
65+
...methodDetails,
66+
memo,
67+
},
68+
recipient: request.recipient as Address,
6869
})
70+
const transferCalls = transfers.map((transfer) =>
71+
Actions.token.transfer.call({
72+
amount: BigInt(transfer.amount),
73+
...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
74+
to: transfer.recipient as Address,
75+
token: currency,
76+
}),
77+
)
6978

7079
const autoSwap = AutoSwap.resolve(
7180
context?.autoSwap ?? parameters.autoSwap,
@@ -82,7 +91,7 @@ export function charge(parameters: charge.Parameters = {}) {
8291
})
8392
: undefined
8493

85-
const calls = [...(swapCalls ?? []), transferCall]
94+
const calls = [...(swapCalls ?? []), ...transferCalls]
8695

8796
if (mode === 'push') {
8897
const { receipts } = await sendCallsSync(client, {

src/tempo/internal/charge.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { getTransfers, maxSplits, maxTransferCalls } from './charge.js'
4+
5+
describe('constants', () => {
6+
test('maxSplits is 10', () => {
7+
expect(maxSplits).toBe(10)
8+
})
9+
10+
test('maxTransferCalls is 1 + maxSplits', () => {
11+
expect(maxTransferCalls).toBe(1 + maxSplits)
12+
})
13+
})
14+
15+
describe('getTransfers', () => {
16+
test('returns single primary transfer when no splits', () => {
17+
const result = getTransfers({
18+
amount: '1000000',
19+
methodDetails: { memo: '0xaabb' },
20+
recipient: '0x1111111111111111111111111111111111111111',
21+
})
22+
expect(result).toEqual([
23+
{
24+
amount: '1000000',
25+
memo: '0xaabb',
26+
recipient: '0x1111111111111111111111111111111111111111',
27+
},
28+
])
29+
})
30+
31+
test('returns primary + split transfers', () => {
32+
const result = getTransfers({
33+
amount: '1000000',
34+
methodDetails: {
35+
memo: '0xaabb',
36+
splits: [
37+
{ amount: '200000', recipient: '0x2222222222222222222222222222222222222222' },
38+
{ amount: '100000', recipient: '0x3333333333333333333333333333333333333333' },
39+
],
40+
},
41+
recipient: '0x1111111111111111111111111111111111111111',
42+
})
43+
expect(result).toEqual([
44+
{
45+
amount: '700000',
46+
memo: '0xaabb',
47+
recipient: '0x1111111111111111111111111111111111111111',
48+
},
49+
{
50+
amount: '200000',
51+
recipient: '0x2222222222222222222222222222222222222222',
52+
},
53+
{
54+
amount: '100000',
55+
recipient: '0x3333333333333333333333333333333333333333',
56+
},
57+
])
58+
})
59+
60+
test('preserves split memo', () => {
61+
const result = getTransfers({
62+
amount: '1000000',
63+
methodDetails: {
64+
splits: [
65+
{
66+
amount: '200000',
67+
memo: '0xdeadbeef',
68+
recipient: '0x2222222222222222222222222222222222222222',
69+
},
70+
],
71+
},
72+
recipient: '0x1111111111111111111111111111111111111111',
73+
})
74+
expect(result[1]!.memo).toBe('0xdeadbeef')
75+
})
76+
77+
test('primary transfer has no memo when methodDetails.memo is undefined', () => {
78+
const result = getTransfers({
79+
amount: '1000000',
80+
methodDetails: {
81+
splits: [{ amount: '200000', recipient: '0x2222222222222222222222222222222222222222' }],
82+
},
83+
recipient: '0x1111111111111111111111111111111111111111',
84+
})
85+
expect(result[0]!.memo).toBeUndefined()
86+
})
87+
88+
test('throws when split amount is zero', () => {
89+
expect(() =>
90+
getTransfers({
91+
amount: '1000000',
92+
methodDetails: {
93+
splits: [{ amount: '0', recipient: '0x2222222222222222222222222222222222222222' }],
94+
},
95+
recipient: '0x1111111111111111111111111111111111111111',
96+
}),
97+
).toThrow('each split amount must be positive')
98+
})
99+
100+
test('throws when split amount is negative', () => {
101+
expect(() =>
102+
getTransfers({
103+
amount: '1000000',
104+
methodDetails: {
105+
splits: [{ amount: '-100', recipient: '0x2222222222222222222222222222222222222222' }],
106+
},
107+
recipient: '0x1111111111111111111111111111111111111111',
108+
}),
109+
).toThrow('each split amount must be positive')
110+
})
111+
112+
test('throws when split total equals total amount', () => {
113+
expect(() =>
114+
getTransfers({
115+
amount: '1000000',
116+
methodDetails: {
117+
splits: [{ amount: '1000000', recipient: '0x2222222222222222222222222222222222222222' }],
118+
},
119+
recipient: '0x1111111111111111111111111111111111111111',
120+
}),
121+
).toThrow('split total must be less than total amount')
122+
})
123+
124+
test('throws when split total exceeds total amount', () => {
125+
expect(() =>
126+
getTransfers({
127+
amount: '1000000',
128+
methodDetails: {
129+
splits: [
130+
{ amount: '600000', recipient: '0x2222222222222222222222222222222222222222' },
131+
{ amount: '600000', recipient: '0x3333333333333333333333333333333333333333' },
132+
],
133+
},
134+
recipient: '0x1111111111111111111111111111111111111111',
135+
}),
136+
).toThrow('split total must be less than total amount')
137+
})
138+
139+
test('handles empty splits array same as no splits', () => {
140+
const result = getTransfers({
141+
amount: '1000000',
142+
methodDetails: { splits: [] },
143+
recipient: '0x1111111111111111111111111111111111111111',
144+
})
145+
expect(result).toHaveLength(1)
146+
expect(result[0]!.amount).toBe('1000000')
147+
})
148+
149+
test('handles undefined methodDetails', () => {
150+
const result = getTransfers({
151+
amount: '1000000',
152+
recipient: '0x1111111111111111111111111111111111111111',
153+
})
154+
expect(result).toHaveLength(1)
155+
expect(result[0]!.amount).toBe('1000000')
156+
})
157+
})

0 commit comments

Comments
 (0)