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 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..eb8b5c30 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,37 @@ 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 + + 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 }) - - 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 +102,7 @@ export function charge(parameters: charge.Parameters = {}) { }) : undefined - const calls = [...(swapCalls ?? []), transferCall] + const calls = [...(swapCalls ?? []), ...transferCalls] if (mode === 'push') { const { receipts } = await sendCallsSync(client, { @@ -99,11 +119,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 @@ -131,6 +159,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/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..82c48aa0 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -650,6 +650,107 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: accepts split payments', async () => { + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + 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: 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({ + 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[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: { signature, type: 'transaction' as const }, + }) + + const authResponse = await fetch(httpServer.url, { + headers: { Authorization: Credential.serialize(credential) }, + }) + expect(authResponse.status).toBe(200) + + httpServer.close() + }) + test('behavior: rejects expired request', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( @@ -1156,6 +1257,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 +1792,70 @@ describe('tempo', () => { httpServer.close() }) + + 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({ + 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(200) + + httpServer.close() + }) }) describe('intent: charge; type: transaction; waitForConfirmation: false', () => { @@ -2295,6 +2493,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..6967061d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -1,4 +1,3 @@ -import type { TempoAddress as TempoAddress_types } from 'ox/tempo' import { decodeFunctionData, keccak256, parseEventLogs, type TransactionReceipt } from 'viem' import { getTransactionReceipt, @@ -17,6 +16,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 +126,12 @@ export function charge( const hash = payload.hash as `0x${string}` await assertHashUnused(store, hash) - const receipt = await getTransactionReceipt(client, { - hash, - }) - - assertTransferLog(receipt, { - amount, + const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient }) + const receipt = await getTransactionReceipt(client, { hash }) + assertTransferLogs(receipt, { currency, from: receipt.from, - memo, - recipient, + transfers: expectedTransfers, }) await markHashUsed(store, hash) @@ -161,58 +157,15 @@ 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 - } - } + 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 }) - return false - }) - - if (!call) - throw new MismatchError('Invalid transaction: no matching payment call found', { - amount, - currency, - recipient, - }) - - if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) + if (isFeePayerTx) FeePayer.validateCalls(transaction.calls, { amount, currency, recipient }) const resolvedFeeToken = @@ -234,12 +187,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! as `0x${string}`, + transfers, }) // Post-broadcast dedup: catch malleable input variants // (different serialized bytes, same underlying tx) that @@ -323,72 +274,187 @@ 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) => ({ + ...transfer, + ...(!transfer.memo ? { allowAnyMemo: true } : {}), + })) as ExpectedTransfer[] +} + +function assertTransferCalls( + calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[], parameters: { - amount: string - currency: TempoAddress_types.Address - from: TempoAddress_types.Address - memo: `0x${string}` | undefined - recipient: TempoAddress_types.Address + currency: `0x${string}` + exactCount?: boolean | undefined + transfers: readonly ExpectedTransfer[] }, -): void { - const { amount, currency, from, memo, recipient } = parameters - - if (memo) { - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, - }) +) { + const transferCalls = getTransferCalls(calls) - 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, + 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), }) - const memoLogs = parseEventLogs({ - abi: Abis.tip20, - eventName: 'TransferWithMemo', - logs: receipt.logs, + const used = new Set() + + // 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 }) - 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 (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 }[], +) { + 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 === 0 || + selectors + .slice(offset) + .some( + (selector) => selector !== Selectors.transfer && selector !== Selectors.transferWithMemo, + ) + ) { + throw new MismatchError('Invalid transaction: no matching payment call found', {}) + } + + return transferCalls +} + +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 + + 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 } + } + } catch { + return null + } + + return null +} - if (!match) +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() + + // 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 + 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, - currency, - recipient, + amount: transfer.amount, + currency: parameters.currency, + recipient: transfer.recipient, }) + } + + used.add(matchIndex) } }