From b944184d5e3b06fe4667d5923267c1738387dad8 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 24 Mar 2026 16:42:30 -0700 Subject: [PATCH] feat(tempo): add split payments for charge --- .changeset/quiet-cooks-smile.md | 5 + src/tempo/Methods.test.ts | 79 +++++++ src/tempo/Methods.ts | 66 ++++-- src/tempo/client/Charge.ts | 25 ++- src/tempo/internal/charge.test.ts | 157 +++++++++++++ src/tempo/internal/charge.ts | 50 +++++ src/tempo/internal/fee-payer.test.ts | 63 ++++-- src/tempo/internal/fee-payer.ts | 28 ++- src/tempo/server/Charge.test.ts | 318 ++++++++++++++++++++++++++- src/tempo/server/Charge.ts | 290 ++++++++++++++---------- 10 files changed, 920 insertions(+), 161 deletions(-) create mode 100644 .changeset/quiet-cooks-smile.md create mode 100644 src/tempo/internal/charge.test.ts create mode 100644 src/tempo/internal/charge.ts diff --git a/.changeset/quiet-cooks-smile.md b/.changeset/quiet-cooks-smile.md new file mode 100644 index 00000000..3617adb7 --- /dev/null +++ b/.changeset/quiet-cooks-smile.md @@ -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. diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts index 4685841b..b4197d2e 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -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', diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index 0edbc774..8c4f9d74 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -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. @@ -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()]), - 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()]), + 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(), + })), + }), }, } : {}), diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index a3ed4d49..06cd87f8 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -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' @@ -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, @@ -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, { diff --git a/src/tempo/internal/charge.test.ts b/src/tempo/internal/charge.test.ts new file mode 100644 index 00000000..7d4ca652 --- /dev/null +++ b/src/tempo/internal/charge.test.ts @@ -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') + }) +}) diff --git a/src/tempo/internal/charge.ts b/src/tempo/internal/charge.ts new file mode 100644 index 00000000..836d1787 --- /dev/null +++ b/src/tempo/internal/charge.ts @@ -0,0 +1,50 @@ +/** Maximum number of split recipients per charge request. */ +export const maxSplits = 10 + +/** Maximum number of transfer calls: 1 primary + up to `maxSplits` splits. */ +export const maxTransferCalls = 1 + maxSplits + +export type Split = { + amount: string + memo?: string | undefined + recipient: string +} + +export type Transfer = { + amount: string + memo?: string | undefined + recipient: string +} + +export function getTransfers(request: { + amount: string + methodDetails?: { memo?: string | undefined; splits?: readonly Split[] | undefined } + recipient: string +}): Transfer[] { + const totalAmount = BigInt(request.amount) + const splits = request.methodDetails?.splits ?? [] + + if (splits.some((split) => BigInt(split.amount) <= 0n)) + throw new Error('Invalid charge request: each split amount must be positive.') + + const splitTotal = splits.reduce((sum, split) => sum + BigInt(split.amount), 0n) + if (splitTotal >= totalAmount) + throw new Error('Invalid charge request: split total must be less than total amount.') + + const primaryAmount = totalAmount - splitTotal + if (primaryAmount <= 0n) + throw new Error('Invalid charge request: primary transfer amount must be positive.') + + return [ + { + amount: primaryAmount.toString(), + memo: request.methodDetails?.memo, + recipient: request.recipient, + }, + ...splits.map((split) => ({ + amount: split.amount, + memo: split.memo, + recipient: split.recipient, + })), + ] +} diff --git a/src/tempo/internal/fee-payer.test.ts b/src/tempo/internal/fee-payer.test.ts index bc70d0c7..84597b44 100644 --- a/src/tempo/internal/fee-payer.test.ts +++ b/src/tempo/internal/fee-payer.test.ts @@ -2,6 +2,7 @@ import { encodeFunctionData } from 'viem' import { Abis, Addresses } from 'viem/tempo' import { describe, expect, test } from 'vitest' +import { maxTransferCalls } from './charge.js' import { callScopes, FeePayerValidationError, validateCalls } from './fee-payer.js' import * as Selectors from './selectors.js' @@ -70,17 +71,7 @@ describe('validateCalls', () => { ).not.toThrow() }) - test('error: rejects empty calls', () => { - expect(() => validateCalls([], details)).toThrow(FeePayerValidationError) - }) - - test('error: rejects unknown selector', () => { - expect(() => validateCalls([{ data: '0xdeadbeef' as `0x${string}` }], details)).toThrow( - 'disallowed call pattern', - ) - }) - - test('error: rejects extra calls beyond allowed patterns', () => { + test('accepts multiple transfers after swap prefix', () => { const swapSelector = Selectors.swapExactAmountOut expect(() => validateCalls( @@ -100,19 +91,63 @@ describe('validateCalls', () => { data: encodeFunctionData({ abi: Abis.tip20, functionName: 'transfer', - args: [bogus, 100n], + args: [bogus, 90n], }), }, { data: encodeFunctionData({ abi: Abis.tip20, - functionName: 'transfer', - args: [bogus, 100n], + functionName: 'transferWithMemo', + args: [ + '0x0000000000000000000000000000000000000002', + 10n, + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ], }), }, ], details, ), + ).not.toThrow() + }) + + test('error: rejects empty calls', () => { + expect(() => validateCalls([], details)).toThrow(FeePayerValidationError) + }) + + test('error: rejects unknown selector', () => { + expect(() => validateCalls([{ data: '0xdeadbeef' as `0x${string}` }], details)).toThrow( + 'disallowed call pattern', + ) + }) + + test(`accepts exactly ${maxTransferCalls} transfers`, () => { + expect(() => + validateCalls( + Array.from({ length: maxTransferCalls }, (_, index) => ({ + data: encodeFunctionData({ + abi: Abis.tip20, + functionName: 'transfer', + args: [`0x${(index + 1).toString(16).padStart(40, '0')}`, 100n], + }), + })), + details, + ), + ).not.toThrow() + }) + + test(`error: rejects more than ${maxTransferCalls} transfers`, () => { + expect(() => + validateCalls( + Array.from({ length: maxTransferCalls + 1 }, (_, index) => ({ + data: encodeFunctionData({ + abi: Abis.tip20, + functionName: 'transfer', + args: [`0x${(index + 1).toString(16).padStart(40, '0')}`, 100n], + }), + })), + details, + ), ).toThrow('disallowed call pattern') }) diff --git a/src/tempo/internal/fee-payer.ts b/src/tempo/internal/fee-payer.ts index 87b0331a..e5474060 100644 --- a/src/tempo/internal/fee-payer.ts +++ b/src/tempo/internal/fee-payer.ts @@ -4,6 +4,7 @@ import { decodeFunctionData } from 'viem' import { Abis, Addresses } from 'viem/tempo' import * as TempoAddress_internal from './address.js' +import { maxTransferCalls } from './charge.js' import * as Selectors from './selectors.js' /** Returns true if the serialized transaction has a Tempo envelope prefix. */ @@ -30,14 +31,29 @@ export function validateCalls( calls: readonly { data?: `0x${string}` | undefined; to?: TempoAddress.Address | undefined }[], details: Record, ) { + if (calls.length === 0) + throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details) + const callSelectors = calls.map((c) => c.data?.slice(0, 10)) - const allowed = callScopes.some( - (pattern) => - pattern.length === callSelectors.length && - pattern.every((sel, i) => sel === callSelectors[i]), - ) - if (!allowed) + const hasSwapPrefix = callSelectors[0] === Selectors.approve + + if (hasSwapPrefix) { + if (callSelectors[1] !== Selectors.swapExactAmountOut) + throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details) + } else if (callSelectors[0] === Selectors.swapExactAmountOut) { + throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details) + } + + const transferSelectors = callSelectors.slice(hasSwapPrefix ? 2 : 0) + if ( + transferSelectors.length === 0 || + transferSelectors.length > maxTransferCalls || + transferSelectors.some( + (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo, + ) + ) { throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details) + } // Validate approve spender and buy target are the DEX. const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 48415654..bd9d960e 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -5,7 +5,12 @@ import type { Hex } from 'ox' import { TxEnvelopeTempo } from 'ox/tempo' import { Handler } from 'tempo.ts/server' import { createClient, custom, encodeFunctionData, parseUnits } from 'viem' -import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions' +import { + getTransactionReceipt, + prepareTransactionRequest, + sendCallsSync, + signTransaction, +} from 'viem/actions' import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vitest' import * as Http from '~test/Http.js' @@ -364,7 +369,7 @@ describe('tempo', () => { }) expect(response.status).toBe(402) const body = (await response.json()) as { detail: string } - expect(body.detail).toContain('Payment verification failed: no matching transfer found.') + expect(body.detail).toContain('no matching payment call found') } httpServer.close() @@ -430,7 +435,7 @@ describe('tempo', () => { }) expect(response.status).toBe(402) const body = (await response.json()) as { detail: string } - expect(body.detail).toContain('Payment verification failed: no matching transfer found.') + expect(body.detail).toContain('no matching payment call found') } httpServer.close() @@ -650,6 +655,139 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: accepts split payments', async () => { + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + mode: 'push', + getClient: () => client, + }), + ], + }) + + const totalAmount = parseUnits('1', 6) + const split0Amount = parseUnits('0.2', 6) + const split1Amount = parseUnits('0.1', 6) + const primaryAmount = totalAmount - split0Amount - split1Amount + + const balancesBefore = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[1].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[3].address, token: asset }), + ]) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + splits: [ + { amount: '0.2', recipient: accounts[2].address }, + { + amount: '0.1', + memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + recipient: accounts[3].address, + }, + ], + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await mppx.fetch(httpServer.url) + expect(response.status).toBe(200) + + const balancesAfter = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[1].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[3].address, token: asset }), + ]) + + // Primary recipient gets totalAmount - splits + expect(balancesAfter[0]! - balancesBefore[0]!).toBe(primaryAmount) + // Payer debited full amount + expect(balancesBefore[1]! - balancesAfter[1]!).toBe(totalAmount) + // Split recipients get their amounts + expect(balancesAfter[2]! - balancesBefore[2]!).toBe(split0Amount) + expect(balancesAfter[3]! - balancesBefore[3]!).toBe(split1Amount) + + httpServer.close() + }) + + test('behavior: rejects hash when split transfers are out of order', async () => { + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + splits: [ + { amount: '0.2', recipient: accounts[2].address }, + { amount: '0.1', recipient: accounts[3].address }, + ], + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const challenge = Challenge.fromResponse(response, { + methods: [tempo_client.charge()], + }) + const splits = challenge.request.methodDetails?.splits ?? [] + const primaryAmount = + BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount) + + const calls = [ + Actions.token.transfer.call({ + amount: BigInt(splits[1]!.amount), + to: splits[1]!.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + Actions.token.transfer.call({ + amount: primaryAmount, + to: challenge.request.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + Actions.token.transfer.call({ + amount: BigInt(splits[0]!.amount), + to: splits[0]!.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + ] + + const { receipts } = await sendCallsSync(client, { + account: accounts[1], + calls: calls as never, + experimental_fallback: true, + }) + const hash = receipts?.[0]?.transactionHash + if (!hash) throw new Error('No transaction receipt returned.') + + const credential = Credential.from({ + challenge, + payload: { hash, type: 'hash' as const }, + }) + + const authResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(authResponse.status).toBe(402) + const body = (await authResponse.json()) as { detail: string } + expect(body.detail).toContain('no matching payment call found') + + httpServer.close() + }) + test('behavior: rejects expired request', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( @@ -1156,6 +1294,62 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: fee payer with splits', async () => { + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + getClient() { + return client + }, + }), + ], + }) + + const totalAmount = parseUnits('1', 6) + const splitAmount = parseUnits('0.2', 6) + const primaryAmount = totalAmount - splitAmount + + const balancesBefore = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[1].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2].address, token: asset }), + ]) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + feePayer: accounts[0], + amount: '1', + currency: asset, + recipient: accounts[0].address, + splits: [{ amount: '0.2', recipient: accounts[2].address }], + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await mppx.fetch(httpServer.url) + expect(response.status).toBe(200) + + const balancesAfter = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[1].address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2].address, token: asset }), + ]) + + // Primary recipient (accounts[0]) gets primaryAmount + expect(balancesAfter[0]! - balancesBefore[0]!).toBe(primaryAmount) + // Payer (accounts[1]) debited full amount + expect(balancesBefore[1]! - balancesAfter[1]!).toBe(totalAmount) + // Split recipient gets split amount + expect(balancesAfter[2]! - balancesBefore[2]!).toBe(splitAmount) + + httpServer.close() + }) + test('behavior: fee payer (hoisted)', async () => { const mppx = Mppx_client.create({ polyfill: false, @@ -1658,6 +1852,72 @@ describe('tempo', () => { httpServer.close() }) + + test('error: rejects split transaction when transfers are out of order', async () => { + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + splits: [ + { amount: '0.2', recipient: accounts[2].address }, + { amount: '0.1', recipient: accounts[3].address }, + ], + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(httpServer.url) + expect(response.status).toBe(402) + + const challenge = Challenge.fromResponse(response, { + methods: [tempo_client.charge()], + }) + const splits = challenge.request.methodDetails?.splits ?? [] + const primaryAmount = + BigInt(challenge.request.amount) - BigInt(splits[0]!.amount) - BigInt(splits[1]!.amount) + + const prepared = await prepareTransactionRequest(client, { + account: accounts[1]!, + calls: [ + Actions.token.transfer.call({ + amount: BigInt(splits[0]!.amount), + to: splits[0]!.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + Actions.token.transfer.call({ + amount: primaryAmount, + to: challenge.request.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + Actions.token.transfer.call({ + amount: BigInt(splits[1]!.amount), + to: splits[1]!.recipient as Hex.Hex, + token: challenge.request.currency as Hex.Hex, + }), + ], + nonceKey: 'expiring', + } as never) + prepared.gas = prepared.gas! + 5_000n + const signature = await signTransaction(client, prepared as never) + + const credential = Credential.from({ + challenge, + payload: { signature, type: 'transaction' as const }, + }) + + const authResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(authResponse.status).toBe(402) + const body = (await authResponse.json()) as { detail: string } + expect(body.detail).toContain('no matching payment call found') + + httpServer.close() + }) }) describe('intent: charge; type: transaction; waitForConfirmation: false', () => { @@ -2295,6 +2555,58 @@ describe('tempo', () => { httpServer.close() }) + test('swaps via DEX when user lacks target currency for split payments', async () => { + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: swapPayer, + autoSwap: true, + getClient() { + return client + }, + }), + ], + }) + + const totalAmount = parseUnits('1', 6) + const splitAmount = parseUnits('0.2', 6) + const primaryAmount = totalAmount - splitAmount + + const balancesBefore = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0]!.address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2]!.address, token: asset }), + ]) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + server.charge({ + amount: '1', + currency: asset, + recipient: accounts[0]!.address, + splits: [{ amount: '0.2', recipient: accounts[2]!.address }], + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await mppx.fetch(httpServer.url) + expect(response.status).toBe(200) + + const balancesAfter = await Promise.all([ + Actions.token.getBalance(client, { account: accounts[0]!.address, token: asset }), + Actions.token.getBalance(client, { account: accounts[2]!.address, token: asset }), + ]) + + // Primary recipient receives totalAmount - splitAmount + expect(balancesAfter[0]! - balancesBefore[0]!).toBe(primaryAmount) + // Split recipient receives splitAmount + expect(balancesAfter[1]! - balancesBefore[1]!).toBe(splitAmount) + + httpServer.close() + }) + test('custom slippage and tokenIn', async () => { const mppx = Mppx_client.create({ polyfill: false, diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 645e6965..0bab84df 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,6 +1,6 @@ -import type { TempoAddress as TempoAddress_types } from 'ox/tempo' import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem' import { + getTransaction, getTransactionReceipt, sendRawTransaction, sendRawTransactionSync, @@ -17,6 +17,7 @@ import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' import * as Account from '../internal/account.js' import * as TempoAddress from '../internal/address.js' +import * as Charge_internal from '../internal/charge.js' import * as defaults from '../internal/defaults.js' import * as FeePayer from '../internal/fee-payer.js' import * as Selectors from '../internal/selectors.js' @@ -126,16 +127,18 @@ export function charge( const hash = payload.hash as `0x${string}` await assertHashUnused(store, hash) + const transaction = await getTransaction(client, { hash }) const receipt = await getTransactionReceipt(client, { hash, }) - assertTransferLog(receipt, { - amount, + const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const calls = getTransferCallsFromTransaction(transaction) + const transfers = assertTransferCalls(calls, { currency, transfers: expectedTransfers }) + assertTransferLogs(receipt, { currency, from: receipt.from, - memo, - recipient, + transfers, }) await markHashUsed(store, hash) @@ -161,57 +164,12 @@ export function charge( {}, ) - const call = transaction.calls.find((call) => { - if (!call.to || !TempoAddress.isEqual(call.to, currency)) return false - if (!call.data) return false - - const selector = call.data.slice(0, 10) - - if (memo) { - if (selector !== Selectors.transferWithMemo) return false - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_, memo_] = args as [`0x${string}`, bigint, `0x${string}`] - return ( - TempoAddress.isEqual(to, recipient) && - amount_.toString() === amount && - memo_.toLowerCase() === memo.toLowerCase() - ) - } catch { - return false - } - } - - if (selector === Selectors.transfer) { - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_] = args as [`0x${string}`, bigint] - return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount - } catch { - return false - } - } - - if (selector === Selectors.transferWithMemo) { - try { - const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) - const [to, amount_] = args as [`0x${string}`, bigint, `0x${string}`] - return TempoAddress.isEqual(to, recipient) && amount_.toString() === amount - } catch { - return false - } - } - - return false + const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const transfers = assertTransferCalls(transaction.calls, { + currency, + transfers: expectedTransfers, }) - if (!call) - throw new MismatchError('Invalid transaction: no matching payment call found', { - amount, - currency, - recipient, - }) - if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) FeePayer.validateCalls(transaction.calls, { amount, currency, recipient }) @@ -234,12 +192,10 @@ export function charge( const receipt = await sendRawTransactionSync(client, { serializedTransaction: serializedTransaction_final, }) - assertTransferLog(receipt, { - amount, + assertTransferLogs(receipt, { currency, from: transaction.from, - memo, - recipient, + transfers, }) // Post-broadcast dedup: catch malleable input variants // (different serialized bytes, same underlying tx) that @@ -323,72 +279,178 @@ export declare namespace charge { } } -/** @internal */ -function assertTransferLog( - receipt: TransactionReceipt, +type ExpectedTransfer = { + amount: string + allowAnyMemo?: boolean | undefined + memo?: `0x${string}` | undefined + recipient: `0x${string}` +} + +function getExpectedTransfers(parameters: { + amount: string + memo: `0x${string}` | undefined + methodDetails: { splits?: readonly Charge_internal.Split[] | undefined } | undefined + recipient: `0x${string}` +}): ExpectedTransfer[] { + return Charge_internal.getTransfers({ + amount: parameters.amount, + methodDetails: { + memo: parameters.memo, + splits: parameters.methodDetails?.splits, + }, + recipient: parameters.recipient, + }).map((transfer, index) => ({ + ...transfer, + ...(index === 0 && parameters.memo === undefined ? { allowAnyMemo: true } : {}), + })) as ExpectedTransfer[] +} + +/** Branded type proving that calldata has been verified against expected transfers. */ +type VerifiedTransfers = readonly ExpectedTransfer[] & { readonly __brand: 'VerifiedTransfers' } + +function assertTransferCalls( + calls: readonly { data?: `0x${string}` | undefined; to?: string | undefined }[], parameters: { - amount: string - currency: TempoAddress_types.Address - from: TempoAddress_types.Address - memo: `0x${string}` | undefined - recipient: TempoAddress_types.Address + currency: `0x${string}` + transfers: readonly ExpectedTransfer[] }, -): void { - const { amount, currency, from, memo, recipient } = parameters - - if (memo) { - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, - }) +): VerifiedTransfers { + const transferCalls = getTransferCalls(calls, parameters.transfers.length) + + transferCalls.forEach((call, index) => { + const decoded = decodeTransferCall(call, parameters.currency) + const expected = parameters.transfers[index] + + if (!decoded || !expected) + throw new MismatchError('Invalid transaction: no matching payment call found', { + currency: parameters.currency, + }) - const match = memoLogs.find( - (log) => - TempoAddress.isEqual(log.address, currency) && - TempoAddress.isEqual(log.args.from, from) && - TempoAddress.isEqual(log.args.to, recipient) && - log.args.amount.toString() === amount && - log.args.memo.toLowerCase() === memo.toLowerCase(), - ) - - if (!match) - throw new MismatchError( - 'Payment verification failed: no matching transfer with memo found.', - { - amount, - currency, - memo, - recipient, - }, + if ( + !TempoAddress.isEqual(decoded.recipient, expected.recipient) || + decoded.amount !== expected.amount || + (expected.memo + ? decoded.memo?.toLowerCase() !== expected.memo.toLowerCase() + : expected.allowAnyMemo + ? false + : decoded.memo !== undefined) + ) { + throw new MismatchError('Invalid transaction: no matching payment call found', { + amount: expected.amount, + currency: parameters.currency, + recipient: expected.recipient, + }) + } + }) + + return parameters.transfers as VerifiedTransfers +} + +function getTransferCalls( + calls: readonly { data?: `0x${string}` | undefined; to?: string | undefined }[], + expectedLength: number, +) { + const selectors = calls.map((call) => call.data?.slice(0, 10)) + const offset = + selectors[0] === Selectors.approve && selectors[1] === Selectors.swapExactAmountOut ? 2 : 0 + const transferCalls = calls.slice(offset) + + if ( + transferCalls.length !== expectedLength || + selectors + .slice(offset) + .some( + (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo, ) - } else { - const transferLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'Transfer', - logs: receipt.logs, + ) { + throw new MismatchError('Invalid transaction: no matching payment call found', { + expectedCalls: String(expectedLength), + actualCalls: String(transferCalls.length), }) + } - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, - }) + return transferCalls +} + +function getTransferCallsFromTransaction(transaction: { + calls?: readonly { data?: `0x${string}` | undefined; to?: string | undefined }[] | undefined + input?: `0x${string}` | undefined + to?: string | null | undefined +}) { + if (transaction.calls?.length) return transaction.calls + if (transaction.to && transaction.input) return [{ data: transaction.input, to: transaction.to }] + return [] +} - const match = [...transferLogs, ...memoLogs].find( - (log) => - TempoAddress.isEqual(log.address, currency) && - TempoAddress.isEqual(log.args.from, from) && - TempoAddress.isEqual(log.args.to, recipient) && - log.args.amount.toString() === amount, - ) +function decodeTransferCall( + call: { data?: `0x${string}` | undefined; to?: string | undefined }, + currency: `0x${string}`, +) { + if (!call.to || !TempoAddress.isEqual(call.to as `0x${string}`, currency) || !call.data) + return null + + const selector = call.data.slice(0, 10) + if (selector === Selectors.transfer) { + const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) + const [recipient, amount] = args as [`0x${string}`, bigint] + return { amount: amount.toString(), recipient } + } - if (!match) + if (selector === Selectors.transferWithMemo) { + const { args } = decodeFunctionData({ abi: Abis.tip20, data: call.data }) + const [recipient, amount, memo] = args as [`0x${string}`, bigint, `0x${string}`] + return { amount: amount.toString(), memo, recipient } + } + + return null +} + +function assertTransferLogs( + receipt: TransactionReceipt, + parameters: { + currency: `0x${string}` + from: string + transfers: VerifiedTransfers + }, +) { + const transferLogs = parseEventLogs({ + abi: Abis.tip20, + eventName: 'Transfer', + logs: receipt.logs, + }).map((log) => ({ ...log, kind: 'transfer' as const })) + + const memoLogs = parseEventLogs({ + abi: Abis.tip20, + eventName: 'TransferWithMemo', + logs: receipt.logs, + }).map((log) => ({ ...log, kind: 'memo' as const })) + + const logs = [...transferLogs, ...memoLogs] + const used = new Set() + + for (const transfer of parameters.transfers) { + const matchIndex = logs.findIndex((log, index) => { + if (used.has(index)) return false + if (!TempoAddress.isEqual(log.address, parameters.currency)) return false + if (!TempoAddress.isEqual(log.args.from, parameters.from as `0x${string}`)) return false + if (!TempoAddress.isEqual(log.args.to, transfer.recipient)) return false + if (log.args.amount.toString() !== transfer.amount) return false + if (transfer.memo) { + return log.kind === 'memo' && log.args.memo.toLowerCase() === transfer.memo.toLowerCase() + } + if (transfer.allowAnyMemo) return log.kind === 'transfer' || log.kind === 'memo' + return log.kind === 'transfer' + }) + + if (matchIndex === -1) { throw new MismatchError('Payment verification failed: no matching transfer found.', { - amount, - currency, - recipient, + amount: transfer.amount, + currency: parameters.currency, + recipient: transfer.recipient, }) + } + + used.add(matchIndex) } }