Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-cooks-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': minor
---

Add split-payment support to Tempo charge requests, including client transaction construction and stricter server verification for split transfers.
79 changes: 79 additions & 0 deletions src/tempo/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,85 @@ describe('charge', () => {
expect(result.success).toBe(true)
})

test('schema: validates request with splits', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '1',
currency: '0x20c0000000000000000000000000000000000001',
decimals: 6,
recipient: '0x1234567890abcdef1234567890abcdef12345678',
splits: [
{
amount: '0.25',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
},
{
amount: '0.1',
memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
],
})
expect(result.success).toBe(true)
if (!result.success) return

expect(result.data.methodDetails?.splits).toEqual([
{
amount: '250000',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
},
{
amount: '100000',
memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
])
})

test('schema: rejects empty splits', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '1',
currency: '0x20c0000000000000000000000000000000000001',
decimals: 6,
recipient: '0x1234567890abcdef1234567890abcdef12345678',
splits: [],
})
expect(result.success).toBe(false)
})

test('schema: rejects more than 10 splits', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '11',
currency: '0x20c0000000000000000000000000000000000001',
decimals: 6,
recipient: '0x1234567890abcdef1234567890abcdef12345678',
splits: Array.from({ length: 11 }, (_, index) => ({
amount: '0.1',
recipient: `0x${(index + 1).toString(16).padStart(40, '0')}`,
})),
})
expect(result.success).toBe(false)
})

test('schema: rejects split totals greater than or equal to amount', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '1',
currency: '0x20c0000000000000000000000000000000000001',
decimals: 6,
recipient: '0x1234567890abcdef1234567890abcdef12345678',
splits: [
{
amount: '0.5',
recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
},
{
amount: '0.5',
recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
],
})
expect(result.success).toBe(false)
})

test('schema: rejects invalid request', () => {
const result = Methods.charge.schema.request.safeParse({
amount: '1',
Expand Down
66 changes: 50 additions & 16 deletions src/tempo/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { parseUnits } from 'viem'

import * as Method from '../Method.js'
import * as z from '../zod.js'
import { maxSplits } from './internal/charge.js'

const split = z.object({
amount: z.amount(),
memo: z.optional(z.hash()),
recipient: z.string(),
})

/**
* Tempo charge intent for one-time TIP-20 token transfers.
Expand All @@ -20,31 +27,58 @@ export const charge = Method.from({
]),
},
request: z.pipe(
z.object({
amount: z.amount(),
chainId: z.optional(z.number()),
currency: z.string(),
decimals: z.number(),
description: z.optional(z.string()),
externalId: z.optional(z.string()),
feePayer: z.optional(
z.pipe(
z.union([z.boolean(), z.custom<Account>()]),
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
z
.object({
amount: z.amount(),
chainId: z.optional(z.number()),
currency: z.string(),
decimals: z.number(),
description: z.optional(z.string()),
externalId: z.optional(z.string()),
feePayer: z.optional(
z.pipe(
z.union([z.boolean(), z.custom<Account>()]),
z.transform((v): boolean => (typeof v === 'object' ? true : v)),
),
),
memo: z.optional(z.hash()),
recipient: z.optional(z.string()),
splits: z.optional(z.array(split).check(z.minLength(1), z.maxLength(maxSplits))),
})
.check(
z.refine(({ amount, decimals, splits }) => {
if (!splits) return true

const totalAmount = parseUnits(amount, decimals)
const splitTotal = splits.reduce(
(sum, split) => sum + parseUnits(split.amount, decimals),
0n,
)

return (
splits.every((split) => parseUnits(split.amount, decimals) > 0n) &&
splitTotal < totalAmount
)
}, 'Invalid splits'),
),
memo: z.optional(z.hash()),
recipient: z.optional(z.string()),
}),
z.transform(({ amount, chainId, decimals, feePayer, memo, ...rest }) => ({
z.transform(({ amount, chainId, decimals, feePayer, memo, splits, ...rest }) => ({
...rest,
amount: parseUnits(amount, decimals).toString(),
...(chainId !== undefined || feePayer !== undefined || memo !== undefined
...(chainId !== undefined ||
feePayer !== undefined ||
memo !== undefined ||
splits !== undefined
? {
methodDetails: {
...(chainId !== undefined && { chainId }),
...(feePayer !== undefined && { feePayer }),
...(memo !== undefined && { memo }),
...(splits !== undefined && {
splits: splits.map((split) => ({
...split,
amount: parseUnits(split.amount, decimals).toString(),
})),
}),
},
}
: {}),
Expand Down
25 changes: 17 additions & 8 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Client from '../../viem/Client.js'
import * as z from '../../zod.js'
import * as Attribution from '../Attribution.js'
import * as AutoSwap from '../internal/auto-swap.js'
import * as Charge_internal from '../internal/charge.js'
import * as defaults from '../internal/defaults.js'
import * as Methods from '../Methods.js'

Expand Down Expand Up @@ -54,18 +55,26 @@ export function charge(parameters: charge.Parameters = {}) {
const { request } = challenge
const { amount, methodDetails } = request
const currency = request.currency as Address
const recipient = request.recipient as Address

const memo = methodDetails?.memo
? (methodDetails.memo as Hex.Hex)
: Attribution.encode({ serverId: challenge.realm, clientId })

const transferCall = Actions.token.transfer.call({
amount: BigInt(amount),
memo,
to: recipient,
token: currency,
const transfers = Charge_internal.getTransfers({
amount,
methodDetails: {
...methodDetails,
memo,
},
recipient: request.recipient as Address,
})
const transferCalls = transfers.map((transfer) =>
Actions.token.transfer.call({
amount: BigInt(transfer.amount),
...(transfer.memo && { memo: transfer.memo as Hex.Hex }),
to: transfer.recipient as Address,
token: currency,
}),
)

const autoSwap = AutoSwap.resolve(
context?.autoSwap ?? parameters.autoSwap,
Expand All @@ -82,7 +91,7 @@ export function charge(parameters: charge.Parameters = {}) {
})
: undefined

const calls = [...(swapCalls ?? []), transferCall]
const calls = [...(swapCalls ?? []), ...transferCalls]

if (mode === 'push') {
const { receipts } = await sendCallsSync(client, {
Expand Down
157 changes: 157 additions & 0 deletions src/tempo/internal/charge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, test } from 'vitest'

import { getTransfers, maxSplits, maxTransferCalls } from './charge.js'

describe('constants', () => {
test('maxSplits is 10', () => {
expect(maxSplits).toBe(10)
})

test('maxTransferCalls is 1 + maxSplits', () => {
expect(maxTransferCalls).toBe(1 + maxSplits)
})
})

describe('getTransfers', () => {
test('returns single primary transfer when no splits', () => {
const result = getTransfers({
amount: '1000000',
methodDetails: { memo: '0xaabb' },
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result).toEqual([
{
amount: '1000000',
memo: '0xaabb',
recipient: '0x1111111111111111111111111111111111111111',
},
])
})

test('returns primary + split transfers', () => {
const result = getTransfers({
amount: '1000000',
methodDetails: {
memo: '0xaabb',
splits: [
{ amount: '200000', recipient: '0x2222222222222222222222222222222222222222' },
{ amount: '100000', recipient: '0x3333333333333333333333333333333333333333' },
],
},
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result).toEqual([
{
amount: '700000',
memo: '0xaabb',
recipient: '0x1111111111111111111111111111111111111111',
},
{
amount: '200000',
recipient: '0x2222222222222222222222222222222222222222',
},
{
amount: '100000',
recipient: '0x3333333333333333333333333333333333333333',
},
])
})

test('preserves split memo', () => {
const result = getTransfers({
amount: '1000000',
methodDetails: {
splits: [
{
amount: '200000',
memo: '0xdeadbeef',
recipient: '0x2222222222222222222222222222222222222222',
},
],
},
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result[1]!.memo).toBe('0xdeadbeef')
})

test('primary transfer has no memo when methodDetails.memo is undefined', () => {
const result = getTransfers({
amount: '1000000',
methodDetails: {
splits: [{ amount: '200000', recipient: '0x2222222222222222222222222222222222222222' }],
},
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result[0]!.memo).toBeUndefined()
})

test('throws when split amount is zero', () => {
expect(() =>
getTransfers({
amount: '1000000',
methodDetails: {
splits: [{ amount: '0', recipient: '0x2222222222222222222222222222222222222222' }],
},
recipient: '0x1111111111111111111111111111111111111111',
}),
).toThrow('each split amount must be positive')
})

test('throws when split amount is negative', () => {
expect(() =>
getTransfers({
amount: '1000000',
methodDetails: {
splits: [{ amount: '-100', recipient: '0x2222222222222222222222222222222222222222' }],
},
recipient: '0x1111111111111111111111111111111111111111',
}),
).toThrow('each split amount must be positive')
})

test('throws when split total equals total amount', () => {
expect(() =>
getTransfers({
amount: '1000000',
methodDetails: {
splits: [{ amount: '1000000', recipient: '0x2222222222222222222222222222222222222222' }],
},
recipient: '0x1111111111111111111111111111111111111111',
}),
).toThrow('split total must be less than total amount')
})

test('throws when split total exceeds total amount', () => {
expect(() =>
getTransfers({
amount: '1000000',
methodDetails: {
splits: [
{ amount: '600000', recipient: '0x2222222222222222222222222222222222222222' },
{ amount: '600000', recipient: '0x3333333333333333333333333333333333333333' },
],
},
recipient: '0x1111111111111111111111111111111111111111',
}),
).toThrow('split total must be less than total amount')
})

test('handles empty splits array same as no splits', () => {
const result = getTransfers({
amount: '1000000',
methodDetails: { splits: [] },
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result).toHaveLength(1)
expect(result[0]!.amount).toBe('1000000')
})

test('handles undefined methodDetails', () => {
const result = getTransfers({
amount: '1000000',
recipient: '0x1111111111111111111111111111111111111111',
})
expect(result).toHaveLength(1)
expect(result[0]!.amount).toBe('1000000')
})
})
Loading
Loading