From 92b306eb6da625a8f8578eb7e1db178d24211b93 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 24 Mar 2026 15:40:34 -0700 Subject: [PATCH 1/6] feat(tempo): add split payments for charge --- .changeset/quiet-cooks-smile.md | 5 + src/tempo/Methods.test.ts | 79 +++++++++ src/tempo/Methods.ts | 65 +++++-- src/tempo/client/Charge.ts | 25 ++- src/tempo/internal/charge.ts | 41 +++++ src/tempo/internal/fee-payer.test.ts | 47 +++-- src/tempo/internal/fee-payer.ts | 27 ++- src/tempo/server/Charge.test.ts | 245 ++++++++++++++++++++++++++- src/tempo/server/Charge.ts | 244 +++++++++++++++++++------- 9 files changed, 674 insertions(+), 104 deletions(-) create mode 100644 .changeset/quiet-cooks-smile.md 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..fc4ba4ef --- /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 d0ee52b2..3b850d2d 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..594e033f 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -4,6 +4,12 @@ import { parseUnits } from 'viem' import * as Method from '../Method.js' import * as z from '../zod.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 +26,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(10))), + }) + .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.ts b/src/tempo/internal/charge.ts new file mode 100644 index 00000000..09366387 --- /dev/null +++ b/src/tempo/internal/charge.ts @@ -0,0 +1,41 @@ +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 ?? [] + + 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 bc7dabb9..f377ee29 100644 --- a/src/tempo/internal/fee-payer.test.ts +++ b/src/tempo/internal/fee-payer.test.ts @@ -70,17 +70,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 +90,48 @@ 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('error: rejects more than 11 transfers', () => { + expect(() => + validateCalls( + Array.from({ length: 12 }, (_, 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..f7193523 100644 --- a/src/tempo/internal/fee-payer.ts +++ b/src/tempo/internal/fee-payer.ts @@ -30,14 +30,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 > 11 || + 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 1ee4eb74..c7352469 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 'vp/test' import * as Http from '~test/Http.js' @@ -650,6 +655,112 @@ 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 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) + + 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 +1267,39 @@ 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 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) + + httpServer.close() + }) + test('behavior: fee payer (hoisted)', async () => { const mppx = Mppx_client.create({ polyfill: false, @@ -1658,6 +1802,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 +2505,39 @@ 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 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) + + 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..c3b72dbe 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,6 +1,7 @@ import type { TempoAddress as TempoAddress_types } from 'ox/tempo' import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem' import { + getTransaction, getTransactionReceipt, sendRawTransaction, sendRawTransactionSync, @@ -17,6 +18,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 +128,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 transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const calls = getTransferCallsFromTransaction(transaction) + assertTransferCalls(calls, { currency, transfers }) + assertTransferLogs(receipt, { currency, from: receipt.from, - memo, - recipient, + transfers, }) await markHashUsed(store, hash) @@ -161,56 +165,9 @@ 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 - }) - - if (!call) - throw new MismatchError('Invalid transaction: no matching payment call found', { - amount, - currency, - recipient, - }) + const calls = transaction.calls ?? [] + const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + assertTransferCalls(calls, { currency, transfers }) if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) FeePayer.validateCalls(transaction.calls, { amount, currency, recipient }) @@ -234,12 +191,10 @@ export function charge( const receipt = await sendRawTransactionSync(client, { serializedTransaction: serializedTransaction_final, }) - assertTransferLog(receipt, { - amount, + assertTransferLogs(receipt, { currency, - from: transaction.from, - memo, - recipient, + from: transaction.from!, + transfers, }) // Post-broadcast dedup: catch malleable input variants // (different serialized bytes, same underlying tx) that @@ -323,6 +278,177 @@ export declare namespace charge { } } +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[] +} + +function assertTransferCalls( + calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[], + parameters: { + currency: `0x${string}` + transfers: readonly ExpectedTransfer[] + }, +) { + 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, + }) + + 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, + }) + } + }) +} + +function getTransferCalls( + calls: readonly { data?: `0x${string}` | undefined; to?: `0x${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, + ) + ) { + throw new MismatchError('Invalid transaction: no matching payment call found', { + expectedCalls: String(expectedLength), + actualCalls: String(transferCalls.length), + }) + } + + return transferCalls +} + +function getTransferCallsFromTransaction(transaction: { + calls?: + | readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[] + | undefined + input?: `0x${string}` | undefined + to?: `0x${string}` | null | undefined +}) { + if (transaction.calls?.length) return transaction.calls + if (transaction.to && transaction.input) return [{ data: transaction.input, to: transaction.to }] + return [] +} + +function decodeTransferCall( + call: { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }, + currency: `0x${string}`, +) { + if (!call.to || !TempoAddress.isEqual(call.to, 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 (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: `0x${string}` + transfers: readonly ExpectedTransfer[] + }, +) { + 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)) 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: transfer.amount, + currency: parameters.currency, + recipient: transfer.recipient, + }) + } + + used.add(matchIndex) + } +} + /** @internal */ function assertTransferLog( receipt: TransactionReceipt, From 5ba958b95e2f22139e6d34308dc65ed6f3ddbd7b Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 13:41:47 -0700 Subject: [PATCH 2/6] fix: align split payment verification with spec - order-insensitive matching for both calldata and logs - hash path uses log-only verification (spec conformance) - bind validBefore to min(now+25s, challenge.expires) - add expectedRecipients allowlist for client-side split validation - allow transferWithMemo on memo-less split entries - enforce exact call count only for fee-payer transactions - sort by specificity (memo-required first) to prevent greedy matching - try/catch in decodeTransferCall for malformed calldata resilience - remove dead assertTransferLog and unused imports --- src/tempo/client/Charge.ts | 28 +++++ src/tempo/server/Charge.test.ts | 12 +- src/tempo/server/Charge.ts | 199 +++++++++++--------------------- 3 files changed, 100 insertions(+), 139 deletions(-) diff --git a/src/tempo/client/Charge.ts b/src/tempo/client/Charge.ts index 06cd87f8..45bad82e 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -56,6 +56,21 @@ export function charge(parameters: charge.Parameters = {}) { const { amount, methodDetails } = request const currency = request.currency as Address + if (parameters.expectedRecipients) { + const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase())) + const splits = methodDetails?.splits as + | readonly { recipient: string }[] + | undefined + if (splits) { + for (const split of splits) { + if (!allowed.has(split.recipient.toLowerCase())) + throw new Error( + `Unexpected split recipient: ${split.recipient}`, + ) + } + } + } + const memo = methodDetails?.memo ? (methodDetails.memo as Hex.Hex) : Attribution.encode({ serverId: challenge.realm, clientId }) @@ -108,11 +123,19 @@ export function charge(parameters: charge.Parameters = {}) { }) } + const validBefore = (() => { + const defaultExpiry = Math.floor(Date.now() / 1000) + 25 + if (!challenge.expires) return defaultExpiry + const challengeExpiry = Math.floor(new Date(challenge.expires).getTime() / 1000) + return Math.min(defaultExpiry, challengeExpiry) + })() + const prepared = await prepareTransactionRequest(client, { account, calls, ...(methodDetails?.feePayer && { feePayer: true }), nonceKey: 'expiring', + validBefore, } as never) // FIXME: figure out gas estimation issue for fee payer tx prepared.gas = prepared.gas! + 5_000n @@ -140,6 +163,11 @@ export declare namespace charge { autoSwap?: AutoSwap | undefined /** Client identifier used to derive the client fingerprint in attribution memos. */ clientId?: string | undefined + /** + * Allowlist of expected split recipient addresses. When set, the client + * rejects any challenge whose split recipients are not in this list. + */ + expectedRecipients?: readonly string[] | undefined /** * Controls how the charge transaction is submitted. * diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index c7352469..4ba387f8 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -693,7 +693,7 @@ describe('tempo', () => { httpServer.close() }) - test('behavior: rejects hash when split transfers are out of order', async () => { + test('behavior: accepts 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({ @@ -754,9 +754,7 @@ describe('tempo', () => { 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') + expect(authResponse.status).toBe(200) httpServer.close() }) @@ -1803,7 +1801,7 @@ describe('tempo', () => { httpServer.close() }) - test('error: rejects split transaction when transfers are out of order', async () => { + test('behavior: accepts 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({ @@ -1862,9 +1860,7 @@ describe('tempo', () => { 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') + expect(authResponse.status).toBe(200) httpServer.close() }) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index c3b72dbe..e5eb912b 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,7 +1,5 @@ -import type { TempoAddress as TempoAddress_types } from 'ox/tempo' import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem' import { - getTransaction, getTransactionReceipt, sendRawTransaction, sendRawTransactionSync, @@ -128,18 +126,12 @@ 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, - }) - - const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) - const calls = getTransferCallsFromTransaction(transaction) - assertTransferCalls(calls, { currency, transfers }) + const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const receipt = await getTransactionReceipt(client, { hash }) assertTransferLogs(receipt, { currency, from: receipt.from, - transfers, + transfers: expectedTransfers, }) await markHashUsed(store, hash) @@ -167,9 +159,10 @@ export function charge( const calls = transaction.calls ?? [] const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) - assertTransferCalls(calls, { currency, transfers }) + const isFeePayerTx = (feePayer || feePayerUrl) && methodDetails?.feePayer !== false + assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers }) - if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) + if (isFeePayerTx) FeePayer.validateCalls(transaction.calls, { amount, currency, recipient }) const resolvedFeeToken = @@ -298,9 +291,9 @@ function getExpectedTransfers(parameters: { splits: parameters.methodDetails?.splits, }, recipient: parameters.recipient, - }).map((transfer, index) => ({ + }).map((transfer) => ({ ...transfer, - ...(index === 0 && parameters.memo === undefined ? { allowAnyMemo: true } : {}), + ...(!transfer.memo ? { allowAnyMemo: true } : {}), })) as ExpectedTransfer[] } @@ -308,41 +301,57 @@ function assertTransferCalls( calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[], parameters: { currency: `0x${string}` + exactCount?: boolean | undefined transfers: readonly ExpectedTransfer[] }, ) { - const transferCalls = getTransferCalls(calls, parameters.transfers.length) + const transferCalls = getTransferCalls(calls) - transferCalls.forEach((call, index) => { - const decoded = decodeTransferCall(call, parameters.currency) - const expected = parameters.transfers[index] + if (parameters.exactCount && transferCalls.length !== parameters.transfers.length) + throw new MismatchError('Invalid transaction: no matching payment call found', { + expectedCalls: String(parameters.transfers.length), + actualCalls: String(transferCalls.length), + }) - if (!decoded || !expected) - throw new MismatchError('Invalid transaction: no matching payment call found', { - currency: parameters.currency, - }) + const used = new Set() - 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) - ) { + // Match memo-specific transfers before wildcards to avoid greedy + // consumption of memo-bearing calls by allowAnyMemo entries. + const sorted = [...parameters.transfers].sort((a, b) => { + if (a.memo && !b.memo) return -1 + if (!a.memo && b.memo) return 1 + return 0 + }) + + for (const expected of sorted) { + const matchIndex = transferCalls.findIndex((call, index) => { + if (used.has(index)) return false + const decoded = decodeTransferCall(call, parameters.currency) + if (!decoded) return false + + if (!TempoAddress.isEqual(decoded.recipient, expected.recipient)) return false + if (decoded.amount !== expected.amount) return false + if (expected.memo) { + return decoded.memo?.toLowerCase() === expected.memo.toLowerCase() + } + if (expected.allowAnyMemo) return true + return decoded.memo === undefined + }) + + if (matchIndex === -1) { throw new MismatchError('Invalid transaction: no matching payment call found', { amount: expected.amount, currency: parameters.currency, recipient: expected.recipient, }) } - }) + + used.add(matchIndex) + } } function getTransferCalls( calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[], - expectedLength: number, ) { const selectors = calls.map((call) => call.data?.slice(0, 10)) const offset = @@ -350,51 +359,40 @@ function getTransferCalls( const transferCalls = calls.slice(offset) if ( - transferCalls.length !== expectedLength || + transferCalls.length === 0 || selectors .slice(offset) .some( (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo, ) ) { - throw new MismatchError('Invalid transaction: no matching payment call found', { - expectedCalls: String(expectedLength), - actualCalls: String(transferCalls.length), - }) + throw new MismatchError('Invalid transaction: no matching payment call found', {}) } return transferCalls } -function getTransferCallsFromTransaction(transaction: { - calls?: - | readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[] - | undefined - input?: `0x${string}` | undefined - to?: `0x${string}` | null | undefined -}) { - if (transaction.calls?.length) return transaction.calls - if (transaction.to && transaction.input) return [{ data: transaction.input, to: transaction.to }] - return [] -} - function decodeTransferCall( call: { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }, currency: `0x${string}`, ) { if (!call.to || !TempoAddress.isEqual(call.to, 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 } - } + try { + 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 (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 } + 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 } + } + } catch { + return null } return null @@ -423,7 +421,15 @@ function assertTransferLogs( const logs = [...transferLogs, ...memoLogs] const used = new Set() - for (const transfer of parameters.transfers) { + // Match memo-specific transfers before wildcards to avoid greedy + // consumption of memo-bearing logs by allowAnyMemo entries. + const sorted = [...parameters.transfers].sort((a, b) => { + if (a.memo && !b.memo) return -1 + if (!a.memo && b.memo) return 1 + return 0 + }) + + for (const transfer of sorted) { const matchIndex = logs.findIndex((log, index) => { if (used.has(index)) return false if (!TempoAddress.isEqual(log.address, parameters.currency)) return false @@ -449,75 +455,6 @@ function assertTransferLogs( } } -/** @internal */ -function assertTransferLog( - receipt: TransactionReceipt, - parameters: { - amount: string - currency: TempoAddress_types.Address - from: TempoAddress_types.Address - memo: `0x${string}` | undefined - recipient: TempoAddress_types.Address - }, -): void { - const { amount, currency, from, memo, recipient } = parameters - - if (memo) { - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, - }) - - 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, - }, - ) - } else { - const transferLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'Transfer', - logs: receipt.logs, - }) - - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, - }) - - 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, - ) - - if (!match) - throw new MismatchError('Payment verification failed: no matching transfer found.', { - amount, - currency, - recipient, - }) - } -} - /** @internal */ function getHashStoreKey(hash: `0x${string}`): `mppx:charge:${string}` { return `mppx:charge:${hash.toLowerCase()}` From 1cf9e972f328007e53ca1d07dcdfd5bc6626a59c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 13:43:01 -0700 Subject: [PATCH 3/6] fix: resolve tsgo type errors for Call and Address types --- src/tempo/server/Charge.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index e5eb912b..01b1bc49 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -157,7 +157,10 @@ export function charge( {}, ) - const calls = transaction.calls ?? [] + const calls = (transaction.calls ?? []) as readonly { + data?: `0x${string}` | undefined + to?: `0x${string}` | undefined + }[] const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) const isFeePayerTx = (feePayer || feePayerUrl) && methodDetails?.feePayer !== false assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers }) @@ -186,7 +189,7 @@ export function charge( }) assertTransferLogs(receipt, { currency, - from: transaction.from!, + from: transaction.from! as `0x${string}`, transfers, }) // Post-broadcast dedup: catch malleable input variants From 15b3ed5f46da7fab954a8651d4df90050fcfb69d Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 13:44:28 -0700 Subject: [PATCH 4/6] fix: coerce isFeePayerTx to boolean for tsgo --- src/tempo/server/Charge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 01b1bc49..6967061d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -162,7 +162,7 @@ export function charge( to?: `0x${string}` | undefined }[] const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) - const isFeePayerTx = (feePayer || feePayerUrl) && methodDetails?.feePayer !== false + const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers }) if (isFeePayerTx) From cce70a1d1599bc8fefca3ebd4adaf793fcd2c8fc Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 13:55:53 -0700 Subject: [PATCH 5/6] fix: use pull mode for split payment tests (sendCallsSync doesn't batch on localnet) --- src/tempo/server/Charge.test.ts | 60 ++++++++++++++------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 4ba387f8..82c48aa0 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -5,12 +5,7 @@ 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, - sendCallsSync, - signTransaction, -} from 'viem/actions' +import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions' import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' import * as Http from '~test/Http.js' @@ -661,7 +656,6 @@ describe('tempo', () => { methods: [ tempo_client({ account: accounts[1], - mode: 'push', getClient: () => client, }), ], @@ -693,7 +687,7 @@ describe('tempo', () => { httpServer.close() }) - test('behavior: accepts hash when split transfers are out of order', async () => { + test('behavior: accepts transaction when split transfers are out of order', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( server.charge({ @@ -720,35 +714,33 @@ describe('tempo', () => { 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 prepared = await prepareTransactionRequest(client, { + account: accounts[1]!, + 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, + }), + ], + nonceKey: 'expiring', + } as never) + prepared.gas = prepared.gas! + 5_000n + const signature = await signTransaction(client, prepared as never) const credential = Credential.from({ challenge, - payload: { hash, type: 'hash' as const }, + payload: { signature, type: 'transaction' as const }, }) const authResponse = await fetch(httpServer.url, { From 79b269f66fd8356a4bc917d8fddd5a99bcb30704 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 14:29:12 -0700 Subject: [PATCH 6/6] style: apply formatter --- .changeset/quiet-cooks-smile.md | 2 +- src/tempo/client/Charge.ts | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.changeset/quiet-cooks-smile.md b/.changeset/quiet-cooks-smile.md index fc4ba4ef..3617adb7 100644 --- a/.changeset/quiet-cooks-smile.md +++ b/.changeset/quiet-cooks-smile.md @@ -1,5 +1,5 @@ --- -"mppx": minor +'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/client/Charge.ts b/src/tempo/client/Charge.ts index 45bad82e..eb8b5c30 100644 --- a/src/tempo/client/Charge.ts +++ b/src/tempo/client/Charge.ts @@ -58,15 +58,11 @@ export function charge(parameters: charge.Parameters = {}) { if (parameters.expectedRecipients) { const allowed = new Set(parameters.expectedRecipients.map((a) => a.toLowerCase())) - const splits = methodDetails?.splits as - | readonly { recipient: string }[] - | undefined + const splits = methodDetails?.splits as readonly { recipient: string }[] | undefined if (splits) { for (const split of splits) { if (!allowed.has(split.recipient.toLowerCase())) - throw new Error( - `Unexpected split recipient: ${split.recipient}`, - ) + throw new Error(`Unexpected split recipient: ${split.recipient}`) } } }