From 0aca728fa9167b7a57b2439296438a7ef3e6131d Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 16 Mar 2026 14:58:46 +0000 Subject: [PATCH 1/4] feat: Update predictDeposit Across flow --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/strategy/across/across-quotes.test.ts | 313 +++++++++++++++++- .../src/strategy/across/across-quotes.ts | 267 ++++++++++++--- 3 files changed, 535 insertions(+), 46 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c4e82c68b24..44f8a52e983 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support gasless Relay deposits via `execute` endpoint ([#8133](https://github.com/MetaMask/core/pull/8133)) - Build Across post-swap transfer actions for `predictDeposit` quotes so Predict deposits can bridge swapped output into the destination proxy wallet ([#8159](https://github.com/MetaMask/core/pull/8159)) +- Improve `predictDeposit` Across quote handling to decode proxy-setup destination calls into post-swap actions while sending transfer-only deposits directly to the destination recipient ([#8208](https://github.com/MetaMask/core/pull/8208)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 11da6c8cf36..f16679c9873 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -87,8 +87,22 @@ const QUOTE_MOCK: AcrossSwapApprovalResponse = { const TOKEN_TRANSFER_INTERFACE = new Interface([ 'function transfer(address to, uint256 amount)', ]); +const SAFE_FACTORY_INTERFACE = new Interface([ + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)', +]); +const SAFE_INTERFACE = new Interface([ + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)', +]); const TRANSFER_RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const FACTORY_ADDRESS = '0xfac7fac7fac7fac7fac7fac7fac7fac7fac7fac7' as Hex; +const SAFE_ADDRESS = '0x5afe5afe5afe5afe5afe5afe5afe5afe5afe5afe' as Hex; +const SAFE_TX_TARGET = '0xc0ffee254729296a45a3885639AC7E10F9d54979'; +const SAFE_TX_DATA = '0x12345678'; +const SAFE_SIGNATURE_BYTES = '0xabcdef'; +const CREATE_PROXY_R = `0x${'11'.repeat(32)}`; +const CREATE_PROXY_S = `0x${'22'.repeat(32)}`; function buildTransferData( recipient: string = TRANSFER_RECIPIENT, @@ -100,6 +114,34 @@ function buildTransferData( ]) as Hex; } +function buildCreateProxyData(): Hex { + return SAFE_FACTORY_INTERFACE.encodeFunctionData('createProxy', [ + ZERO_ADDRESS, + '0', + ZERO_ADDRESS, + { + r: CREATE_PROXY_R, + s: CREATE_PROXY_S, + v: 27, + }, + ]) as Hex; +} + +function buildExecTransactionData(): Hex { + return SAFE_INTERFACE.encodeFunctionData('execTransaction', [ + SAFE_TX_TARGET, + '0', + SAFE_TX_DATA, + 0, + 0, + 0, + 0, + ZERO_ADDRESS, + ZERO_ADDRESS, + SAFE_SIGNATURE_BYTES, + ]) as Hex; +} + function getRequestBody(): { actions: unknown[] } { const [, options] = jest.mocked(successfulFetch).mock.calls[0]; @@ -390,7 +432,7 @@ describe('Across Quotes', () => { expect(getRequestBody().actions).toStrictEqual([]); }); - it('uses predict deposit post-swap action for token transfer transactions', async () => { + it('uses direct recipient for predict transfer-only transactions', async () => { const transferData = buildTransferData(TRANSFER_RECIPIENT); successfulFetchMock.mockResolvedValue({ @@ -413,8 +455,136 @@ describe('Across Quotes', () => { const [url] = successfulFetchMock.mock.calls[0]; const params = new URL(url as string).searchParams; + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + expect(getRequestBody().actions).toStrictEqual([]); + }); + + it('uses direct recipient for nested predict transfer-only transactions', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + nestedTransactions: [{ data: transferData }], + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + expect(getRequestBody().actions).toStrictEqual([]); + }); + + it('uses decoded actions for predict setup transactions', async () => { + const createProxyData = buildCreateProxyData(); + const execTransactionData = buildExecTransactionData(); + const transferData = buildTransferData(TRANSFER_RECIPIENT, 0); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + nestedTransactions: [ + { to: FACTORY_ADDRESS, data: createProxyData }, + { to: SAFE_ADDRESS, data: execTransactionData }, + { to: QUOTE_REQUEST_MOCK.targetTokenAddress, data: transferData }, + ], + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + expect(params.get('recipient')).toBe(FROM_MOCK); expect(getRequestBody().actions).toStrictEqual([ + { + args: [ + { + populateDynamically: false, + value: ZERO_ADDRESS, + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: ZERO_ADDRESS, + }, + { + populateDynamically: false, + value: ['27', CREATE_PROXY_R, CREATE_PROXY_S], + }, + ], + functionSignature: + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)', + isNativeTransfer: false, + target: FACTORY_ADDRESS, + value: '0', + }, + { + args: [ + { + populateDynamically: false, + value: SAFE_TX_TARGET.toLowerCase(), + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: SAFE_TX_DATA, + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: '0', + }, + { + populateDynamically: false, + value: ZERO_ADDRESS, + }, + { + populateDynamically: false, + value: ZERO_ADDRESS, + }, + { + populateDynamically: false, + value: SAFE_SIGNATURE_BYTES, + }, + ], + functionSignature: + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)', + isNativeTransfer: false, + target: SAFE_ADDRESS, + value: '0', + }, { args: [ { @@ -435,8 +605,9 @@ describe('Across Quotes', () => { ]); }); - it('uses predict deposit post-swap action from nested transfer transactions', async () => { - const transferData = buildTransferData(TRANSFER_RECIPIENT); + it('falls back to target token address for setup transfer actions without an explicit target', async () => { + const createProxyData = buildCreateProxyData(); + const transferData = buildTransferData(TRANSFER_RECIPIENT, 0); successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -448,10 +619,63 @@ describe('Across Quotes', () => { transaction: { ...TRANSACTION_META_MOCK, type: TransactionType.predictDeposit, - nestedTransactions: [{ data: transferData }], + nestedTransactions: [ + { to: FACTORY_ADDRESS, data: createProxyData }, + { data: transferData }, + ], } as TransactionMeta, }); + const body = getRequestBody(); + expect(body.actions).toHaveLength(2); + expect(body.actions[1]).toMatchObject({ + functionSignature: 'function transfer(address to, uint256 value)', + target: QUOTE_REQUEST_MOCK.targetTokenAddress, + }); + }); + + it('uses from address for predict deposits with no calldata', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + }, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(FROM_MOCK); + expect(getRequestBody().actions).toStrictEqual([]); + }); + + it('falls back to top-level predict setup calldata when no nested calldata exists', async () => { + const createProxyData = buildCreateProxyData(); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + txParams: { + from: FROM_MOCK, + to: FACTORY_ADDRESS, + data: createProxyData, + }, + }, + }); + const [url] = successfulFetchMock.mock.calls[0]; const params = new URL(url as string).searchParams; @@ -461,22 +685,93 @@ describe('Across Quotes', () => { args: [ { populateDynamically: false, - value: TRANSFER_RECIPIENT.toLowerCase(), + value: ZERO_ADDRESS, }, { - balanceSourceToken: QUOTE_REQUEST_MOCK.targetTokenAddress, - populateDynamically: true, + populateDynamically: false, value: '0', }, + { + populateDynamically: false, + value: ZERO_ADDRESS, + }, + { + populateDynamically: false, + value: ['27', CREATE_PROXY_R, CREATE_PROXY_S], + }, ], - functionSignature: 'function transfer(address to, uint256 value)', + functionSignature: + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)', isNativeTransfer: false, - target: QUOTE_REQUEST_MOCK.targetTokenAddress, + target: FACTORY_ADDRESS, value: '0', }, ]); }); + it('throws for unsupported predict setup calldata', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + nestedTransactions: [ + { + to: FACTORY_ADDRESS, + data: '0xdeadbeef' as Hex, + }, + ], + } as TransactionMeta, + }), + ).rejects.toThrow(/Across only supports transfer-style/u); + }); + + it('throws when predict createProxy calldata is missing target', async () => { + const createProxyData = buildCreateProxyData(); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + nestedTransactions: [{ data: createProxyData }], + } as TransactionMeta, + }), + ).rejects.toThrow(/Across only supports transfer-style/u); + }); + + it('throws when predict execTransaction calldata is missing target', async () => { + const execTransactionData = buildExecTransactionData(); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + nestedTransactions: [{ data: execTransactionData }], + } as TransactionMeta, + }), + ).rejects.toThrow(/Across only supports transfer-style/u); + }); + it('throws when destination flow is not transfer-style', async () => { await expect( getAcrossQuotes({ diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index 8de9e76ef73..fd457eec991 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -33,8 +33,16 @@ import { TOKEN_TRANSFER_FOUR_BYTE } from '../relay/constants'; const log = createModuleLogger(projectLogger, 'across-strategy'); -const TOKEN_TRANSFER_INTERFACE = new Interface([ - 'function transfer(address to, uint256 value)', +const TOKEN_TRANSFER_SIGNATURE = 'function transfer(address to, uint256 value)'; +const CREATE_PROXY_SIGNATURE = + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)'; +const SAFE_EXEC_TRANSACTION_SIGNATURE = + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)'; + +const TOKEN_TRANSFER_INTERFACE = new Interface([TOKEN_TRANSFER_SIGNATURE]); +const CREATE_PROXY_INTERFACE = new Interface([CREATE_PROXY_SIGNATURE]); +const SAFE_EXEC_TRANSACTION_INTERFACE = new Interface([ + SAFE_EXEC_TRANSACTION_SIGNATURE, ]); const UNSUPPORTED_AUTHORIZATION_LIST_ERROR = @@ -49,6 +57,11 @@ type AcrossDestination = { recipient: Hex; }; +type AcrossDestinationCall = { + data: Hex; + target?: Hex; +}; + /** * Fetch Across quotes. * @@ -204,31 +217,43 @@ function getAcrossDestination( transaction: TransactionMeta, request: QuoteRequest, ): AcrossDestination { - const { txParams } = transaction; const { from } = request; - const transferData = getTransferData(transaction); - - if (transferData) { - const transferRecipient = getTransferRecipient(transferData); + const destinationCalls = getDestinationCalls(transaction); + const transferCall = destinationCalls.find((call) => + isTransferCall(call.data), + ); - if (transaction.type === TransactionType.predictDeposit) { + if (transaction.type === TransactionType.predictDeposit) { + if (destinationCalls.length === 0) { return { - actions: [buildAcrossTransferAction(transferRecipient, request)], + actions: [], recipient: from, }; } + if (destinationCalls.length === 1 && transferCall) { + return { + actions: [], + recipient: getTransferRecipient(transferCall.data), + }; + } + return { - actions: [], - recipient: transferRecipient, + actions: destinationCalls.map((call) => + buildAcrossActionFromCall(call, request), + ), + recipient: from, }; } - const data = txParams?.data as Hex | undefined; - const hasNoData = data === undefined || data === '0x'; - const nestedCalldata = getNestedCalldata(transaction); + if (transferCall) { + return { + actions: [], + recipient: getTransferRecipient(transferCall.data), + }; + } - if (hasNoData && nestedCalldata.length === 0) { + if (destinationCalls.length === 0) { return { actions: [], recipient: from, @@ -238,15 +263,34 @@ function getAcrossDestination( throw new Error(UNSUPPORTED_DESTINATION_ERROR); } +function buildAcrossActionFromCall( + call: AcrossDestinationCall, + request: QuoteRequest, +): AcrossAction { + if (isTransferCall(call.data)) { + return buildAcrossTransferAction(call, request); + } + + if (isCreateProxyCall(call.data)) { + return buildCreateProxyAction(call); + } + + if (isSafeExecTransactionCall(call.data)) { + return buildSafeExecTransactionAction(call); + } + + throw new Error(UNSUPPORTED_DESTINATION_ERROR); +} + function buildAcrossTransferAction( - transferRecipient: Hex, + call: AcrossDestinationCall, request: QuoteRequest, ): AcrossAction { return { args: [ { populateDynamically: false, - value: transferRecipient, + value: getTransferRecipient(call.data), }, { balanceSourceToken: request.targetTokenAddress, @@ -254,36 +298,181 @@ function buildAcrossTransferAction( value: '0', }, ], - functionSignature: 'function transfer(address to, uint256 value)', + functionSignature: TOKEN_TRANSFER_SIGNATURE, + isNativeTransfer: false, + target: call.target ?? request.targetTokenAddress, + value: '0', + }; +} + +function buildCreateProxyAction(call: AcrossDestinationCall): AcrossAction { + if (!call.target) { + throw new Error(UNSUPPORTED_DESTINATION_ERROR); + } + + const [paymentToken, payment, paymentReceiver, createSig] = + CREATE_PROXY_INTERFACE.decodeFunctionData('createProxy', call.data) as [ + Hex, + BigNumber, + Hex, + { r: Hex; s: Hex; v: number }, + ]; + + return { + args: [ + { + populateDynamically: false, + value: normalizeHexString(paymentToken), + }, + { + populateDynamically: false, + value: payment.toString(), + }, + { + populateDynamically: false, + value: normalizeHexString(paymentReceiver), + }, + { + populateDynamically: false, + value: [ + String(createSig.v), + normalizeHexString(createSig.r), + normalizeHexString(createSig.s), + ], + }, + ], + functionSignature: CREATE_PROXY_SIGNATURE, + isNativeTransfer: false, + target: call.target, + value: '0', + }; +} + +function buildSafeExecTransactionAction( + call: AcrossDestinationCall, +): AcrossAction { + if (!call.target) { + throw new Error(UNSUPPORTED_DESTINATION_ERROR); + } + + const [ + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + signatures, + ] = SAFE_EXEC_TRANSACTION_INTERFACE.decodeFunctionData( + 'execTransaction', + call.data, + ) as [ + Hex, + BigNumber, + Hex, + number, + BigNumber, + BigNumber, + BigNumber, + Hex, + Hex, + Hex, + ]; + + return { + args: [ + { + populateDynamically: false, + value: normalizeHexString(to), + }, + { + populateDynamically: false, + value: value.toString(), + }, + { + populateDynamically: false, + value: normalizeHexString(data), + }, + { + populateDynamically: false, + value: String(operation), + }, + { + populateDynamically: false, + value: safeTxGas.toString(), + }, + { + populateDynamically: false, + value: baseGas.toString(), + }, + { + populateDynamically: false, + value: gasPrice.toString(), + }, + { + populateDynamically: false, + value: normalizeHexString(gasToken), + }, + { + populateDynamically: false, + value: normalizeHexString(refundReceiver), + }, + { + populateDynamically: false, + value: normalizeHexString(signatures), + }, + ], + functionSignature: SAFE_EXEC_TRANSACTION_SIGNATURE, isNativeTransfer: false, - target: request.targetTokenAddress, + target: call.target, value: '0', }; } -function getTransferData(transaction: TransactionMeta): Hex | undefined { - const { nestedTransactions, txParams } = transaction; +function getDestinationCalls( + transaction: TransactionMeta, +): AcrossDestinationCall[] { + const nestedCalls = ( + transaction.nestedTransactions ?? [] + ).flatMap((nestedTx: { data?: Hex; to?: Hex }) => + nestedTx.data !== undefined && nestedTx.data !== '0x' + ? [{ data: nestedTx.data, target: nestedTx.to }] + : [], + ); + + if (nestedCalls.length > 0) { + return nestedCalls; + } - const nestedTransferData = nestedTransactions?.find( - (nestedTx: { data?: Hex }) => - nestedTx.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), - )?.data; + const data = transaction.txParams?.data as Hex | undefined; - const data = txParams?.data as Hex | undefined; - const tokenTransferData = data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE) - ? data - : undefined; + if (data === undefined || data === '0x') { + return []; + } - return nestedTransferData ?? tokenTransferData; + return [ + { + data, + target: transaction.txParams?.to as Hex | undefined, + }, + ]; } -function getNestedCalldata(transaction: TransactionMeta): Hex[] { - return (transaction.nestedTransactions ?? []) - .map((nestedTx: { data?: Hex }) => nestedTx.data) - .filter( - (data: Hex | undefined): data is Hex => - data !== undefined && data !== '0x', - ); +function isTransferCall(data: Hex): boolean { + return data.startsWith(TOKEN_TRANSFER_FOUR_BYTE); +} + +function isCreateProxyCall(data: Hex): boolean { + return data.startsWith(CREATE_PROXY_INTERFACE.getSighash('createProxy')); +} + +function isSafeExecTransactionCall(data: Hex): boolean { + return data.startsWith( + SAFE_EXEC_TRANSACTION_INTERFACE.getSighash('execTransaction'), + ); } function getTransferRecipient(data: Hex): Hex { @@ -293,6 +482,10 @@ function getTransferRecipient(data: Hex): Hex { ).to.toLowerCase() as Hex; } +function normalizeHexString(value: string): string { + return value.toLowerCase(); +} + async function normalizeQuote( original: AcrossQuoteWithoutMetaMask, request: QuoteRequest, From 932ceeb1cc4e9775fd2403d68127445041572ff2 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 17 Mar 2026 10:36:53 +0000 Subject: [PATCH 2/4] Address review feedback --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../strategy/across/across-actions.test.ts | 188 +++++++++++++ .../src/strategy/across/across-actions.ts | 214 ++++++++++++++ .../src/strategy/across/across-quotes.test.ts | 184 +++++++----- .../src/strategy/across/across-quotes.ts | 265 ++---------------- 5 files changed, 531 insertions(+), 322 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/across/across-actions.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 44f8a52e983..4ea1bc4363c 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support gasless Relay deposits via `execute` endpoint ([#8133](https://github.com/MetaMask/core/pull/8133)) - Build Across post-swap transfer actions for `predictDeposit` quotes so Predict deposits can bridge swapped output into the destination proxy wallet ([#8159](https://github.com/MetaMask/core/pull/8159)) -- Improve `predictDeposit` Across quote handling to decode proxy-setup destination calls into post-swap actions while sending transfer-only deposits directly to the destination recipient ([#8208](https://github.com/MetaMask/core/pull/8208)) +- Improve Across quote handling to decode supported destination calls into post-swap actions while sending transfer-only destinations directly to the destination recipient ([#8208](https://github.com/MetaMask/core/pull/8208)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts new file mode 100644 index 00000000000..77e08fecb96 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts @@ -0,0 +1,188 @@ +import { Interface } from '@ethersproject/abi'; +import type { Hex } from '@metamask/utils'; + +import { + buildAcrossActionFromCall, + CREATE_PROXY_SIGNATURE, + getTransferRecipient, + isExtractableOutputTokenTransferCall, + SAFE_EXEC_TRANSACTION_SIGNATURE, + TOKEN_TRANSFER_SIGNATURE, +} from './across-actions'; +import type { QuoteRequest } from '../../types'; + +const TOKEN_TRANSFER_INTERFACE = new Interface([TOKEN_TRANSFER_SIGNATURE]); +const CREATE_PROXY_INTERFACE = new Interface([CREATE_PROXY_SIGNATURE]); +const SAFE_EXEC_TRANSACTION_INTERFACE = new Interface([ + SAFE_EXEC_TRANSACTION_SIGNATURE, +]); + +const REQUEST_MOCK: QuoteRequest = { + from: '0x1234567890123456789012345678901234567891' as Hex, + sourceBalanceRaw: '10000000000000000000', + sourceChainId: '0x1', + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '123', + targetChainId: '0x2', + targetTokenAddress: '0xdef' as Hex, +}; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const TRANSFER_RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex; +const TRANSFER_TARGET = REQUEST_MOCK.targetTokenAddress; +const CREATE_PROXY_TARGET = '0xfac7fac7fac7fac7fac7fac7fac7fac7fac7fac7' as Hex; +const EXEC_TRANSACTION_TARGET = + '0x5afe5afe5afe5afe5afe5afe5afe5afe5afe5afe' as Hex; + +function buildTransferData( + recipient: Hex = TRANSFER_RECIPIENT, + amount = 1, +): Hex { + return TOKEN_TRANSFER_INTERFACE.encodeFunctionData('transfer', [ + recipient, + amount, + ]) as Hex; +} + +function buildCreateProxyData(): Hex { + return CREATE_PROXY_INTERFACE.encodeFunctionData('createProxy', [ + ZERO_ADDRESS, + '0', + ZERO_ADDRESS, + { + r: `0x${'11'.repeat(32)}`, + s: `0x${'22'.repeat(32)}`, + v: 27, + }, + ]) as Hex; +} + +function buildExecTransactionData(): Hex { + return SAFE_EXEC_TRANSACTION_INTERFACE.encodeFunctionData('execTransaction', [ + '0xc0ffee254729296a45a3885639AC7E10F9d54979', + '0', + '0x12345678', + 0, + 0, + 0, + 0, + ZERO_ADDRESS, + ZERO_ADDRESS, + '0xabcdef', + ]) as Hex; +} + +describe('across-actions', () => { + it('builds transfer actions with a dynamic output-token amount', () => { + expect( + buildAcrossActionFromCall({ data: buildTransferData() }, REQUEST_MOCK), + ).toStrictEqual({ + args: [ + { + populateDynamically: false, + value: TRANSFER_RECIPIENT.toLowerCase(), + }, + { + balanceSourceToken: REQUEST_MOCK.targetTokenAddress, + populateDynamically: true, + value: '0', + }, + ], + functionSignature: TOKEN_TRANSFER_SIGNATURE, + isNativeTransfer: false, + target: REQUEST_MOCK.targetTokenAddress, + value: '0', + }); + + expect( + buildAcrossActionFromCall( + { + data: buildTransferData(), + target: TRANSFER_TARGET, + }, + REQUEST_MOCK, + ).target, + ).toBe(TRANSFER_TARGET); + }); + + it('builds non-transfer actions by decoding the supported signature registry', () => { + expect( + buildAcrossActionFromCall( + { + data: buildExecTransactionData(), + target: EXEC_TRANSACTION_TARGET, + }, + REQUEST_MOCK, + ), + ).toMatchObject({ + functionSignature: SAFE_EXEC_TRANSACTION_SIGNATURE, + target: EXEC_TRANSACTION_TARGET, + value: '0', + }); + }); + + it('throws when decoding a supported action that requires a target without one', () => { + expect(() => + buildAcrossActionFromCall({ data: buildCreateProxyData() }, REQUEST_MOCK), + ).toThrow(/Across only supports transfer-style/u); + }); + + it('throws when the calldata does not match a supported signature', () => { + expect(() => + buildAcrossActionFromCall( + { + data: '0xdeadbeef' as Hex, + target: CREATE_PROXY_TARGET, + }, + REQUEST_MOCK, + ), + ).toThrow(/Across only supports transfer-style/u); + }); + + it('extracts and normalizes transfer recipients from calldata', () => { + expect(getTransferRecipient(buildTransferData())).toBe( + TRANSFER_RECIPIENT.toLowerCase(), + ); + }); + + it('throws when asking for a transfer recipient from non-transfer calldata', () => { + expect(() => getTransferRecipient(buildCreateProxyData())).toThrow( + /Across only supports transfer-style/u, + ); + }); + + it('recognizes extractable output-token transfers', () => { + expect( + isExtractableOutputTokenTransferCall( + { + data: buildTransferData(), + target: TRANSFER_TARGET, + }, + REQUEST_MOCK, + ), + ).toBe(true); + }); + + it('rejects unsupported or non-output-token transfer calls as extractable recipients', () => { + expect( + isExtractableOutputTokenTransferCall( + { + data: buildTransferData(), + target: '0x9999999999999999999999999999999999999999' as Hex, + }, + REQUEST_MOCK, + ), + ).toBe(false); + + expect( + isExtractableOutputTokenTransferCall( + { + data: '0xdeadbeef' as Hex, + target: TRANSFER_TARGET, + }, + REQUEST_MOCK, + ), + ).toBe(false); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-actions.ts b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts new file mode 100644 index 00000000000..1c93003f1e5 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts @@ -0,0 +1,214 @@ +import { Interface } from '@ethersproject/abi'; +import type { TransactionDescription } from '@ethersproject/abi'; +import type { Hex } from '@metamask/utils'; + +import type { AcrossAction, AcrossActionArg } from './types'; +import type { QuoteRequest } from '../../types'; + +export const TOKEN_TRANSFER_SIGNATURE = + 'function transfer(address to, uint256 value)'; +export const CREATE_PROXY_SIGNATURE = + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)'; +export const SAFE_EXEC_TRANSACTION_SIGNATURE = + 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)'; + +export const UNSUPPORTED_DESTINATION_ERROR = + 'Across only supports transfer-style destination flows at the moment'; + +export type AcrossDestinationCall = { + data: Hex; + target?: Hex; +}; + +type AcrossActionDefinition = { + functionSignature: string; + getArg?: ( + arg: unknown, + argIndex: number, + request: QuoteRequest, + ) => AcrossActionArg | undefined; + getTarget: (call: AcrossDestinationCall, request: QuoteRequest) => Hex; + interface: Interface; + methodName: string; +}; + +type ParsedAcrossActionCall = { + definition: AcrossActionDefinition; + transaction: TransactionDescription; +}; + +type BigNumberLike = { + _isBigNumber: true; + toString: () => string; +}; + +const TOKEN_TRANSFER_INTERFACE = new Interface([TOKEN_TRANSFER_SIGNATURE]); +const CREATE_PROXY_INTERFACE = new Interface([CREATE_PROXY_SIGNATURE]); +const SAFE_EXEC_TRANSACTION_INTERFACE = new Interface([ + SAFE_EXEC_TRANSACTION_SIGNATURE, +]); + +const ACROSS_ACTION_DEFINITIONS: AcrossActionDefinition[] = [ + { + functionSignature: TOKEN_TRANSFER_SIGNATURE, + getArg: (_arg, argIndex, request) => + argIndex === 1 + ? { + balanceSourceToken: request.targetTokenAddress, + populateDynamically: true, + value: '0', + } + : undefined, + getTarget: (call, request) => call.target ?? request.targetTokenAddress, + interface: TOKEN_TRANSFER_INTERFACE, + methodName: 'transfer', + }, + { + functionSignature: CREATE_PROXY_SIGNATURE, + getTarget: (call) => getRequiredTarget(call), + interface: CREATE_PROXY_INTERFACE, + methodName: 'createProxy', + }, + { + functionSignature: SAFE_EXEC_TRANSACTION_SIGNATURE, + getTarget: (call) => getRequiredTarget(call), + interface: SAFE_EXEC_TRANSACTION_INTERFACE, + methodName: 'execTransaction', + }, +]; + +export function buildAcrossActionFromCall( + call: AcrossDestinationCall, + request: QuoteRequest, +): AcrossAction { + const parsedCall = parseAcrossActionCall(call.data); + + return { + args: Array.from(parsedCall.transaction.args).map((arg, argIndex) => { + const customArg = parsedCall.definition.getArg?.(arg, argIndex, request); + + if (customArg) { + return customArg; + } + + return { + populateDynamically: false, + value: serializeAcrossActionValue(arg), + }; + }), + functionSignature: parsedCall.definition.functionSignature, + isNativeTransfer: false, + target: parsedCall.definition.getTarget(call, request), + value: '0', + }; +} + +export function getTransferRecipient(data: Hex): Hex { + const parsedCall = parseAcrossActionCall(data); + + if (parsedCall.definition.methodName !== 'transfer') { + throw new Error(UNSUPPORTED_DESTINATION_ERROR); + } + + return normalizeHexString(String(parsedCall.transaction.args[0])) as Hex; +} + +export function isExtractableOutputTokenTransferCall( + call: AcrossDestinationCall, + request: QuoteRequest, +): boolean { + const parsedCall = tryParseAcrossActionCall(call.data); + + return ( + parsedCall?.definition.methodName === 'transfer' && + (call.target === undefined || + normalizeHexString(call.target) === + normalizeHexString(request.targetTokenAddress)) + ); +} + +function getRequiredTarget(call: AcrossDestinationCall): Hex { + if (!call.target) { + throw new Error(UNSUPPORTED_DESTINATION_ERROR); + } + + return call.target; +} + +function normalizeHexString(value: string): string { + /* istanbul ignore next: current supported Across action signatures only emit hex strings here. */ + if (!value.startsWith('0x')) { + return value; + } + + return value.toLowerCase(); +} + +function parseAcrossActionCall(data: Hex): ParsedAcrossActionCall { + const parsedCall = tryParseAcrossActionCall(data); + + if (!parsedCall) { + throw new Error(UNSUPPORTED_DESTINATION_ERROR); + } + + return parsedCall; +} + +function serializeAcrossActionValue(value: unknown): AcrossActionArg['value'] { + if (Array.isArray(value)) { + return value.map((entry) => + serializeAcrossActionScalar(entry), + ) as AcrossActionArg['value']; + } + + return serializeAcrossActionScalar(value); +} + +function serializeAcrossActionScalar(value: unknown): string { + if (typeof value === 'string') { + return normalizeHexString(value); + } + + if ( + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'boolean' + ) { + return String(value); + } + + if (isBigNumberLike(value)) { + return value.toString(); + } + + /* istanbul ignore next: supported Across action ABIs only decode scalars and tuples of scalars. */ + throw new Error(UNSUPPORTED_DESTINATION_ERROR); +} + +function isBigNumberLike(value: unknown): value is BigNumberLike { + return ( + typeof value === 'object' && + value !== null && + '_isBigNumber' in value && + value._isBigNumber === true && + 'toString' in value && + typeof value.toString === 'function' + ); +} + +function tryParseAcrossActionCall( + data: Hex, +): ParsedAcrossActionCall | undefined { + for (const definition of ACROSS_ACTION_DEFINITIONS) { + try { + return { + definition, + transaction: definition.interface.parseTransaction({ data }), + }; + } catch { + // Intentionally empty. + } + } + + return undefined; +} diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index f16679c9873..47f3bc30ba3 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -483,7 +483,7 @@ describe('Across Quotes', () => { expect(getRequestBody().actions).toStrictEqual([]); }); - it('uses decoded actions for predict setup transactions', async () => { + it('uses decoded actions for supported setup transactions', async () => { const createProxyData = buildCreateProxyData(); const execTransactionData = buildExecTransactionData(); const transferData = buildTransferData(TRANSFER_RECIPIENT, 0); @@ -497,7 +497,6 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, nestedTransactions: [ { to: FACTORY_ADDRESS, data: createProxyData }, { to: SAFE_ADDRESS, data: execTransactionData }, @@ -509,7 +508,7 @@ describe('Across Quotes', () => { const [url] = successfulFetchMock.mock.calls[0]; const params = new URL(url as string).searchParams; - expect(params.get('recipient')).toBe(FROM_MOCK); + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); expect(getRequestBody().actions).toStrictEqual([ { args: [ @@ -585,23 +584,6 @@ describe('Across Quotes', () => { target: SAFE_ADDRESS, value: '0', }, - { - args: [ - { - populateDynamically: false, - value: TRANSFER_RECIPIENT.toLowerCase(), - }, - { - balanceSourceToken: QUOTE_REQUEST_MOCK.targetTokenAddress, - populateDynamically: true, - value: '0', - }, - ], - functionSignature: 'function transfer(address to, uint256 value)', - isNativeTransfer: false, - target: QUOTE_REQUEST_MOCK.targetTokenAddress, - value: '0', - }, ]); }); @@ -618,7 +600,6 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, nestedTransactions: [ { to: FACTORY_ADDRESS, data: createProxyData }, { data: transferData }, @@ -627,14 +608,26 @@ describe('Across Quotes', () => { }); const body = getRequestBody(); - expect(body.actions).toHaveLength(2); - expect(body.actions[1]).toMatchObject({ - functionSignature: 'function transfer(address to, uint256 value)', - target: QUOTE_REQUEST_MOCK.targetTokenAddress, + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + expect(body.actions).toHaveLength(1); + expect(body.actions[0]).toMatchObject({ + functionSignature: + 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)', + target: FACTORY_ADDRESS, }); }); - it('uses from address for predict deposits with no calldata', async () => { + it('uses the first transfer in a batch as recipient and keeps later transfers as post-swap actions', async () => { + const firstTransferRecipient = + '0x1111111111111111111111111111111111111111' as Hex; + const secondTransferRecipient = + '0x2222222222222222222222222222222222222222' as Hex; + const firstTransferData = buildTransferData(firstTransferRecipient, 0); + const secondTransferData = buildTransferData(secondTransferRecipient, 0); + successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as Response); @@ -644,8 +637,55 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, + nestedTransactions: [ + { + to: QUOTE_REQUEST_MOCK.targetTokenAddress, + data: firstTransferData, + }, + { + to: QUOTE_REQUEST_MOCK.targetTokenAddress, + data: secondTransferData, + }, + ], + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe( + firstTransferRecipient.toLowerCase(), + ); + expect(getRequestBody().actions).toStrictEqual([ + { + args: [ + { + populateDynamically: false, + value: secondTransferRecipient.toLowerCase(), + }, + { + balanceSourceToken: QUOTE_REQUEST_MOCK.targetTokenAddress, + populateDynamically: true, + value: '0', + }, + ], + functionSignature: 'function transfer(address to, uint256 value)', + isNativeTransfer: false, + target: QUOTE_REQUEST_MOCK.targetTokenAddress, + value: '0', }, + ]); + }); + + it('uses from address when no destination calldata exists', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, }); const [url] = successfulFetchMock.mock.calls[0]; @@ -655,7 +695,7 @@ describe('Across Quotes', () => { expect(getRequestBody().actions).toStrictEqual([]); }); - it('falls back to top-level predict setup calldata when no nested calldata exists', async () => { + it('falls back to top-level setup calldata when no nested calldata exists', async () => { const createProxyData = buildCreateProxyData(); successfulFetchMock.mockResolvedValue({ @@ -667,7 +707,6 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, txParams: { from: FROM_MOCK, to: FACTORY_ADDRESS, @@ -709,7 +748,7 @@ describe('Across Quotes', () => { ]); }); - it('throws for unsupported predict setup calldata', async () => { + it('throws for unsupported setup calldata', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as Response); @@ -720,7 +759,6 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, nestedTransactions: [ { to: FACTORY_ADDRESS, @@ -732,7 +770,7 @@ describe('Across Quotes', () => { ).rejects.toThrow(/Across only supports transfer-style/u); }); - it('throws when predict createProxy calldata is missing target', async () => { + it('throws when createProxy calldata is missing target', async () => { const createProxyData = buildCreateProxyData(); successfulFetchMock.mockResolvedValue({ @@ -745,14 +783,13 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, nestedTransactions: [{ data: createProxyData }], } as TransactionMeta, }), ).rejects.toThrow(/Across only supports transfer-style/u); }); - it('throws when predict execTransaction calldata is missing target', async () => { + it('throws when execTransaction calldata is missing target', async () => { const execTransactionData = buildExecTransactionData(); successfulFetchMock.mockResolvedValue({ @@ -765,7 +802,6 @@ describe('Across Quotes', () => { requests: [QUOTE_REQUEST_MOCK], transaction: { ...TRANSACTION_META_MOCK, - type: TransactionType.predictDeposit, nestedTransactions: [{ data: execTransactionData }], } as TransactionMeta, }), @@ -1547,33 +1583,30 @@ describe('Across Quotes', () => { expect(params.get('recipient')).toBe(FROM_MOCK); }); - it('uses nested transaction transfer recipient when available', async () => { + it('throws when nested transactions mix a transfer with unsupported calldata', async () => { const transferData = buildTransferData(TRANSFER_RECIPIENT); successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as Response); - await getAcrossQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: { - ...TRANSACTION_META_MOCK, - nestedTransactions: [ - { data: transferData }, - { data: '0xbeef' as Hex }, - ], - txParams: { - from: FROM_MOCK, - data: '0xabc' as Hex, - }, - } as TransactionMeta, - }); - - const [url] = successfulFetchMock.mock.calls[0]; - const params = new URL(url as string).searchParams; - - expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { data: transferData }, + { data: '0xbeef' as Hex }, + ], + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/Across only supports transfer-style/u); }); it('uses txParams data when single nested transaction has no data', async () => { @@ -1653,33 +1686,30 @@ describe('Across Quotes', () => { expect(result).toHaveLength(1); }); - it('extracts recipient from token transfer in nested transactions array', async () => { + it('throws when nested transactions include unsupported calldata before a transfer', async () => { const transferData = buildTransferData(TRANSFER_RECIPIENT); successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as Response); - await getAcrossQuotes({ - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: { - ...TRANSACTION_META_MOCK, - nestedTransactions: [ - { data: '0xother' as Hex }, - { data: transferData }, - ], - txParams: { - from: FROM_MOCK, - data: '0xnonTransferData' as Hex, - }, - } as TransactionMeta, - }); - - const [url] = successfulFetchMock.mock.calls[0]; - const params = new URL(url as string).searchParams; - - expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { data: '0xother' as Hex }, + { data: transferData }, + ], + txParams: { + from: FROM_MOCK, + data: '0xnonTransferData' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/Across only supports transfer-style/u); }); it('handles nested transactions with undefined data', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index fd457eec991..04b95d32cde 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -1,12 +1,16 @@ -import { Interface } from '@ethersproject/abi'; import { successfulFetch, toHex } from '@metamask/controller-utils'; -import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { getAcrossOrderedTransactions } from './transactions'; +import type { AcrossDestinationCall } from './across-actions'; +import { + buildAcrossActionFromCall, + getTransferRecipient, + isExtractableOutputTokenTransferCall, +} from './across-actions'; import type { AcrossAction, AcrossActionRequestBody, @@ -29,26 +33,11 @@ import { getPayStrategiesConfig, getSlippage } from '../../utils/feature-flags'; import { calculateGasCost } from '../../utils/gas'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; import { getTokenFiatRate } from '../../utils/token'; -import { TOKEN_TRANSFER_FOUR_BYTE } from '../relay/constants'; const log = createModuleLogger(projectLogger, 'across-strategy'); -const TOKEN_TRANSFER_SIGNATURE = 'function transfer(address to, uint256 value)'; -const CREATE_PROXY_SIGNATURE = - 'function createProxy(address paymentToken, uint256 payment, address payable paymentReceiver, (uint8 v, bytes32 r, bytes32 s) createSig)'; -const SAFE_EXEC_TRANSACTION_SIGNATURE = - 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)'; - -const TOKEN_TRANSFER_INTERFACE = new Interface([TOKEN_TRANSFER_SIGNATURE]); -const CREATE_PROXY_INTERFACE = new Interface([CREATE_PROXY_SIGNATURE]); -const SAFE_EXEC_TRANSACTION_INTERFACE = new Interface([ - SAFE_EXEC_TRANSACTION_SIGNATURE, -]); - const UNSUPPORTED_AUTHORIZATION_LIST_ERROR = 'Across does not support type-4/EIP-7702 authorization lists yet'; -const UNSUPPORTED_DESTINATION_ERROR = - 'Across only supports transfer-style destination flows at the moment'; type AcrossQuoteWithoutMetaMask = Omit; @@ -57,11 +46,6 @@ type AcrossDestination = { recipient: Hex; }; -type AcrossDestinationCall = { - data: Hex; - target?: Hex; -}; - /** * Fetch Across quotes. * @@ -219,40 +203,10 @@ function getAcrossDestination( ): AcrossDestination { const { from } = request; const destinationCalls = getDestinationCalls(transaction); - const transferCall = destinationCalls.find((call) => - isTransferCall(call.data), + const swapRecipientTransferCallIndex = destinationCalls.findIndex((call) => + isExtractableOutputTokenTransferCall(call, request), ); - if (transaction.type === TransactionType.predictDeposit) { - if (destinationCalls.length === 0) { - return { - actions: [], - recipient: from, - }; - } - - if (destinationCalls.length === 1 && transferCall) { - return { - actions: [], - recipient: getTransferRecipient(transferCall.data), - }; - } - - return { - actions: destinationCalls.map((call) => - buildAcrossActionFromCall(call, request), - ), - recipient: from, - }; - } - - if (transferCall) { - return { - actions: [], - recipient: getTransferRecipient(transferCall.data), - }; - } - if (destinationCalls.length === 0) { return { actions: [], @@ -260,175 +214,23 @@ function getAcrossDestination( }; } - throw new Error(UNSUPPORTED_DESTINATION_ERROR); -} - -function buildAcrossActionFromCall( - call: AcrossDestinationCall, - request: QuoteRequest, -): AcrossAction { - if (isTransferCall(call.data)) { - return buildAcrossTransferAction(call, request); - } - - if (isCreateProxyCall(call.data)) { - return buildCreateProxyAction(call); - } - - if (isSafeExecTransactionCall(call.data)) { - return buildSafeExecTransactionAction(call); - } - - throw new Error(UNSUPPORTED_DESTINATION_ERROR); -} + if (swapRecipientTransferCallIndex !== -1) { + const swapRecipientTransferCall = + destinationCalls[swapRecipientTransferCallIndex]; -function buildAcrossTransferAction( - call: AcrossDestinationCall, - request: QuoteRequest, -): AcrossAction { - return { - args: [ - { - populateDynamically: false, - value: getTransferRecipient(call.data), - }, - { - balanceSourceToken: request.targetTokenAddress, - populateDynamically: true, - value: '0', - }, - ], - functionSignature: TOKEN_TRANSFER_SIGNATURE, - isNativeTransfer: false, - target: call.target ?? request.targetTokenAddress, - value: '0', - }; -} - -function buildCreateProxyAction(call: AcrossDestinationCall): AcrossAction { - if (!call.target) { - throw new Error(UNSUPPORTED_DESTINATION_ERROR); - } - - const [paymentToken, payment, paymentReceiver, createSig] = - CREATE_PROXY_INTERFACE.decodeFunctionData('createProxy', call.data) as [ - Hex, - BigNumber, - Hex, - { r: Hex; s: Hex; v: number }, - ]; - - return { - args: [ - { - populateDynamically: false, - value: normalizeHexString(paymentToken), - }, - { - populateDynamically: false, - value: payment.toString(), - }, - { - populateDynamically: false, - value: normalizeHexString(paymentReceiver), - }, - { - populateDynamically: false, - value: [ - String(createSig.v), - normalizeHexString(createSig.r), - normalizeHexString(createSig.s), - ], - }, - ], - functionSignature: CREATE_PROXY_SIGNATURE, - isNativeTransfer: false, - target: call.target, - value: '0', - }; -} - -function buildSafeExecTransactionAction( - call: AcrossDestinationCall, -): AcrossAction { - if (!call.target) { - throw new Error(UNSUPPORTED_DESTINATION_ERROR); + return { + actions: destinationCalls + .filter((_, index) => index !== swapRecipientTransferCallIndex) + .map((call) => buildAcrossActionFromCall(call, request)), + recipient: getTransferRecipient(swapRecipientTransferCall.data), + }; } - const [ - to, - value, - data, - operation, - safeTxGas, - baseGas, - gasPrice, - gasToken, - refundReceiver, - signatures, - ] = SAFE_EXEC_TRANSACTION_INTERFACE.decodeFunctionData( - 'execTransaction', - call.data, - ) as [ - Hex, - BigNumber, - Hex, - number, - BigNumber, - BigNumber, - BigNumber, - Hex, - Hex, - Hex, - ]; - return { - args: [ - { - populateDynamically: false, - value: normalizeHexString(to), - }, - { - populateDynamically: false, - value: value.toString(), - }, - { - populateDynamically: false, - value: normalizeHexString(data), - }, - { - populateDynamically: false, - value: String(operation), - }, - { - populateDynamically: false, - value: safeTxGas.toString(), - }, - { - populateDynamically: false, - value: baseGas.toString(), - }, - { - populateDynamically: false, - value: gasPrice.toString(), - }, - { - populateDynamically: false, - value: normalizeHexString(gasToken), - }, - { - populateDynamically: false, - value: normalizeHexString(refundReceiver), - }, - { - populateDynamically: false, - value: normalizeHexString(signatures), - }, - ], - functionSignature: SAFE_EXEC_TRANSACTION_SIGNATURE, - isNativeTransfer: false, - target: call.target, - value: '0', + actions: destinationCalls.map((call) => + buildAcrossActionFromCall(call, request), + ), + recipient: from, }; } @@ -461,31 +263,6 @@ function getDestinationCalls( ]; } -function isTransferCall(data: Hex): boolean { - return data.startsWith(TOKEN_TRANSFER_FOUR_BYTE); -} - -function isCreateProxyCall(data: Hex): boolean { - return data.startsWith(CREATE_PROXY_INTERFACE.getSighash('createProxy')); -} - -function isSafeExecTransactionCall(data: Hex): boolean { - return data.startsWith( - SAFE_EXEC_TRANSACTION_INTERFACE.getSighash('execTransaction'), - ); -} - -function getTransferRecipient(data: Hex): Hex { - return TOKEN_TRANSFER_INTERFACE.decodeFunctionData( - 'transfer', - data, - ).to.toLowerCase() as Hex; -} - -function normalizeHexString(value: string): string { - return value.toLowerCase(); -} - async function normalizeQuote( original: AcrossQuoteWithoutMetaMask, request: QuoteRequest, From 7a8d479698c26ec5fdc45810cbd216f2987a0971 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 17 Mar 2026 13:15:50 +0000 Subject: [PATCH 3/4] Address PR feedback --- .../strategy/across/across-actions.test.ts | 49 ++++- .../src/strategy/across/across-actions.ts | 207 ++++++++++++------ .../src/strategy/across/across-quotes.test.ts | 16 +- .../src/strategy/across/across-quotes.ts | 79 +------ 4 files changed, 193 insertions(+), 158 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts index 77e08fecb96..358a86f19da 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-actions.test.ts @@ -1,9 +1,11 @@ import { Interface } from '@ethersproject/abi'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { buildAcrossActionFromCall, CREATE_PROXY_SIGNATURE, + getAcrossDestination, getTransferRecipient, isExtractableOutputTokenTransferCall, SAFE_EXEC_TRANSACTION_SIGNATURE, @@ -122,10 +124,51 @@ describe('across-actions', () => { }); }); + it('builds an Across destination directly from a transfer transaction', () => { + expect( + getAcrossDestination( + { + txParams: { + data: buildTransferData(), + from: REQUEST_MOCK.from, + }, + } as TransactionMeta, + REQUEST_MOCK, + ), + ).toStrictEqual({ + actions: [], + recipient: TRANSFER_RECIPIENT.toLowerCase(), + }); + }); + + it('prefers nested destination calls over top-level calldata', () => { + expect( + getAcrossDestination( + { + nestedTransactions: [ + { + data: buildTransferData(), + to: TRANSFER_TARGET, + }, + ], + txParams: { + data: buildCreateProxyData(), + from: REQUEST_MOCK.from, + to: CREATE_PROXY_TARGET, + }, + } as TransactionMeta, + REQUEST_MOCK, + ), + ).toStrictEqual({ + actions: [], + recipient: TRANSFER_RECIPIENT.toLowerCase(), + }); + }); + it('throws when decoding a supported action that requires a target without one', () => { expect(() => buildAcrossActionFromCall({ data: buildCreateProxyData() }, REQUEST_MOCK), - ).toThrow(/Across only supports transfer-style/u); + ).toThrow(/Across only supports direct token transfers/u); }); it('throws when the calldata does not match a supported signature', () => { @@ -137,7 +180,7 @@ describe('across-actions', () => { }, REQUEST_MOCK, ), - ).toThrow(/Across only supports transfer-style/u); + ).toThrow(/Destination selector: 0xdeadbeef/u); }); it('extracts and normalizes transfer recipients from calldata', () => { @@ -148,7 +191,7 @@ describe('across-actions', () => { it('throws when asking for a transfer recipient from non-transfer calldata', () => { expect(() => getTransferRecipient(buildCreateProxyData())).toThrow( - /Across only supports transfer-style/u, + /Across only supports direct token transfers/u, ); }); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-actions.ts b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts index 1c93003f1e5..2fa7613e7f2 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-actions.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-actions.ts @@ -1,5 +1,6 @@ import { Interface } from '@ethersproject/abi'; import type { TransactionDescription } from '@ethersproject/abi'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import type { AcrossAction, AcrossActionArg } from './types'; @@ -13,27 +14,20 @@ export const SAFE_EXEC_TRANSACTION_SIGNATURE = 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)'; export const UNSUPPORTED_DESTINATION_ERROR = - 'Across only supports transfer-style destination flows at the moment'; + 'Across only supports direct token transfers and a limited set of post-swap destination actions at the moment'; export type AcrossDestinationCall = { data: Hex; target?: Hex; }; -type AcrossActionDefinition = { - functionSignature: string; - getArg?: ( - arg: unknown, - argIndex: number, - request: QuoteRequest, - ) => AcrossActionArg | undefined; - getTarget: (call: AcrossDestinationCall, request: QuoteRequest) => Hex; - interface: Interface; - methodName: string; +export type AcrossDestination = { + actions: AcrossAction[]; + recipient: Hex; }; type ParsedAcrossActionCall = { - definition: AcrossActionDefinition; + functionSignature: string; transaction: TransactionDescription; }; @@ -42,85 +36,78 @@ type BigNumberLike = { toString: () => string; }; -const TOKEN_TRANSFER_INTERFACE = new Interface([TOKEN_TRANSFER_SIGNATURE]); -const CREATE_PROXY_INTERFACE = new Interface([CREATE_PROXY_SIGNATURE]); -const SAFE_EXEC_TRANSACTION_INTERFACE = new Interface([ +const ACROSS_ACTION_SIGNATURES = [ + CREATE_PROXY_SIGNATURE, SAFE_EXEC_TRANSACTION_SIGNATURE, -]); - -const ACROSS_ACTION_DEFINITIONS: AcrossActionDefinition[] = [ - { - functionSignature: TOKEN_TRANSFER_SIGNATURE, - getArg: (_arg, argIndex, request) => - argIndex === 1 - ? { - balanceSourceToken: request.targetTokenAddress, - populateDynamically: true, - value: '0', - } - : undefined, - getTarget: (call, request) => call.target ?? request.targetTokenAddress, - interface: TOKEN_TRANSFER_INTERFACE, - methodName: 'transfer', - }, - { - functionSignature: CREATE_PROXY_SIGNATURE, - getTarget: (call) => getRequiredTarget(call), - interface: CREATE_PROXY_INTERFACE, - methodName: 'createProxy', - }, - { - functionSignature: SAFE_EXEC_TRANSACTION_SIGNATURE, - getTarget: (call) => getRequiredTarget(call), - interface: SAFE_EXEC_TRANSACTION_INTERFACE, - methodName: 'execTransaction', - }, ]; export function buildAcrossActionFromCall( call: AcrossDestinationCall, request: QuoteRequest, ): AcrossAction { + if (isTransferCall(call.data)) { + return buildAcrossTransferAction(call, request); + } + const parsedCall = parseAcrossActionCall(call.data); return { - args: Array.from(parsedCall.transaction.args).map((arg, argIndex) => { - const customArg = parsedCall.definition.getArg?.(arg, argIndex, request); - - if (customArg) { - return customArg; - } - - return { - populateDynamically: false, - value: serializeAcrossActionValue(arg), - }; - }), - functionSignature: parsedCall.definition.functionSignature, + args: Array.from(parsedCall.transaction.args).map((arg) => ({ + populateDynamically: false, + value: serializeAcrossActionValue(arg), + })), + functionSignature: parsedCall.functionSignature, isNativeTransfer: false, - target: parsedCall.definition.getTarget(call, request), + target: getRequiredTarget(call), value: '0', }; } export function getTransferRecipient(data: Hex): Hex { - const parsedCall = parseAcrossActionCall(data); + const parsedCall = tryParseTransferCall(data); - if (parsedCall.definition.methodName !== 'transfer') { - throw new Error(UNSUPPORTED_DESTINATION_ERROR); + if (!parsedCall) { + throw new Error(getUnsupportedDestinationErrorMessage(data)); } - return normalizeHexString(String(parsedCall.transaction.args[0])) as Hex; + return normalizeHexString(String(parsedCall.args[0])) as Hex; +} + +export function getAcrossDestination( + transaction: TransactionMeta, + request: QuoteRequest, +): AcrossDestination { + const { from } = request; + const destinationCalls = getDestinationCalls(transaction); + const swapRecipientTransferCallIndex = destinationCalls.findIndex((call) => + isExtractableOutputTokenTransferCall(call, request), + ); + const callsForActions = [...destinationCalls]; + let recipient = from; + + if (swapRecipientTransferCallIndex !== -1) { + const [swapRecipientTransferCall] = callsForActions.splice( + swapRecipientTransferCallIndex, + 1, + ); + + recipient = getTransferRecipient(swapRecipientTransferCall.data); + } + + return { + actions: callsForActions.map((call) => + buildAcrossActionFromCall(call, request), + ), + recipient, + }; } export function isExtractableOutputTokenTransferCall( call: AcrossDestinationCall, request: QuoteRequest, ): boolean { - const parsedCall = tryParseAcrossActionCall(call.data); - return ( - parsedCall?.definition.methodName === 'transfer' && + isTransferCall(call.data) && (call.target === undefined || normalizeHexString(call.target) === normalizeHexString(request.targetTokenAddress)) @@ -148,7 +135,7 @@ function parseAcrossActionCall(data: Hex): ParsedAcrossActionCall { const parsedCall = tryParseAcrossActionCall(data); if (!parsedCall) { - throw new Error(UNSUPPORTED_DESTINATION_ERROR); + throw new Error(getUnsupportedDestinationErrorMessage(data)); } return parsedCall; @@ -164,6 +151,74 @@ function serializeAcrossActionValue(value: unknown): AcrossActionArg['value'] { return serializeAcrossActionScalar(value); } +function buildAcrossTransferAction( + call: AcrossDestinationCall, + request: QuoteRequest, +): AcrossAction { + return { + args: [ + { + populateDynamically: false, + value: getTransferRecipient(call.data), + }, + { + balanceSourceToken: request.targetTokenAddress, + populateDynamically: true, + value: '0', + }, + ], + functionSignature: TOKEN_TRANSFER_SIGNATURE, + isNativeTransfer: false, + target: call.target ?? request.targetTokenAddress, + value: '0', + }; +} + +function getDestinationCalls( + transaction: TransactionMeta, +): AcrossDestinationCall[] { + const nestedCalls = ( + transaction.nestedTransactions ?? [] + ).flatMap((nestedTx: { data?: Hex; to?: Hex }) => + nestedTx.data !== undefined && nestedTx.data !== '0x' + ? [{ data: nestedTx.data, target: nestedTx.to }] + : [], + ); + + if (nestedCalls.length > 0) { + return nestedCalls; + } + + const data = transaction.txParams?.data as Hex | undefined; + + if (data === undefined || data === '0x') { + return []; + } + + return [ + { + data, + target: transaction.txParams?.to as Hex | undefined, + }, + ]; +} + +function getUnsupportedDestinationErrorMessage(data?: Hex): string { + const selector = getDestinationSelector(data); + + return selector + ? `${UNSUPPORTED_DESTINATION_ERROR}. Destination selector: ${selector}` + : UNSUPPORTED_DESTINATION_ERROR; +} + +function getDestinationSelector(data?: Hex): Hex | undefined { + if (!data || data.length < 10) { + return undefined; + } + + return data.slice(0, 10).toLowerCase() as Hex; +} + function serializeAcrossActionScalar(value: unknown): string { if (typeof value === 'string') { return normalizeHexString(value); @@ -196,14 +251,20 @@ function isBigNumberLike(value: unknown): value is BigNumberLike { ); } +function isTransferCall(data: Hex): boolean { + return tryParseTransferCall(data) !== undefined; +} + function tryParseAcrossActionCall( data: Hex, ): ParsedAcrossActionCall | undefined { - for (const definition of ACROSS_ACTION_DEFINITIONS) { + for (const functionSignature of ACROSS_ACTION_SIGNATURES) { try { + const actionInterface = new Interface([functionSignature]); + return { - definition, - transaction: definition.interface.parseTransaction({ data }), + functionSignature, + transaction: actionInterface.parseTransaction({ data }), }; } catch { // Intentionally empty. @@ -212,3 +273,11 @@ function tryParseAcrossActionCall( return undefined; } + +function tryParseTransferCall(data: Hex): TransactionDescription | undefined { + try { + return new Interface([TOKEN_TRANSFER_SIGNATURE]).parseTransaction({ data }); + } catch { + return undefined; + } +} diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 47f3bc30ba3..29a80dd984d 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -767,7 +767,7 @@ describe('Across Quotes', () => { ], } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Destination selector: 0xdeadbeef/u); }); it('throws when createProxy calldata is missing target', async () => { @@ -786,7 +786,7 @@ describe('Across Quotes', () => { nestedTransactions: [{ data: createProxyData }], } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Across only supports direct token transfers/u); }); it('throws when execTransaction calldata is missing target', async () => { @@ -805,7 +805,7 @@ describe('Across Quotes', () => { nestedTransactions: [{ data: execTransactionData }], } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Across only supports direct token transfers/u); }); it('throws when destination flow is not transfer-style', async () => { @@ -821,7 +821,7 @@ describe('Across Quotes', () => { }, }, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Across only supports direct token transfers/u); }); it('throws when txParams include authorization list', async () => { @@ -1606,7 +1606,7 @@ describe('Across Quotes', () => { }, } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Across only supports direct token transfers/u); }); it('uses txParams data when single nested transaction has no data', async () => { @@ -1627,7 +1627,7 @@ describe('Across Quotes', () => { }, } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Destination selector: 0xdeadbeef/u); }); it('omits slippage param when slippage is undefined', async () => { @@ -1709,7 +1709,7 @@ describe('Across Quotes', () => { }, } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Across only supports direct token transfers/u); }); it('handles nested transactions with undefined data', async () => { @@ -1759,7 +1759,7 @@ describe('Across Quotes', () => { }, } as TransactionMeta, }), - ).rejects.toThrow(/Across only supports transfer-style/u); + ).rejects.toThrow(/Destination selector: 0xdeadbeef/u); }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index 04b95d32cde..0f0392c921f 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -1,16 +1,10 @@ import { successfulFetch, toHex } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { getAcrossOrderedTransactions } from './transactions'; -import type { AcrossDestinationCall } from './across-actions'; -import { - buildAcrossActionFromCall, - getTransferRecipient, - isExtractableOutputTokenTransferCall, -} from './across-actions'; +import { getAcrossDestination } from './across-actions'; import type { AcrossAction, AcrossActionRequestBody, @@ -41,11 +35,6 @@ const UNSUPPORTED_AUTHORIZATION_LIST_ERROR = type AcrossQuoteWithoutMetaMask = Omit; -type AcrossDestination = { - actions: AcrossAction[]; - recipient: Hex; -}; - /** * Fetch Across quotes. * @@ -197,72 +186,6 @@ async function requestAcrossApproval( return (await response.json()) as AcrossSwapApprovalResponse; } -function getAcrossDestination( - transaction: TransactionMeta, - request: QuoteRequest, -): AcrossDestination { - const { from } = request; - const destinationCalls = getDestinationCalls(transaction); - const swapRecipientTransferCallIndex = destinationCalls.findIndex((call) => - isExtractableOutputTokenTransferCall(call, request), - ); - - if (destinationCalls.length === 0) { - return { - actions: [], - recipient: from, - }; - } - - if (swapRecipientTransferCallIndex !== -1) { - const swapRecipientTransferCall = - destinationCalls[swapRecipientTransferCallIndex]; - - return { - actions: destinationCalls - .filter((_, index) => index !== swapRecipientTransferCallIndex) - .map((call) => buildAcrossActionFromCall(call, request)), - recipient: getTransferRecipient(swapRecipientTransferCall.data), - }; - } - - return { - actions: destinationCalls.map((call) => - buildAcrossActionFromCall(call, request), - ), - recipient: from, - }; -} - -function getDestinationCalls( - transaction: TransactionMeta, -): AcrossDestinationCall[] { - const nestedCalls = ( - transaction.nestedTransactions ?? [] - ).flatMap((nestedTx: { data?: Hex; to?: Hex }) => - nestedTx.data !== undefined && nestedTx.data !== '0x' - ? [{ data: nestedTx.data, target: nestedTx.to }] - : [], - ); - - if (nestedCalls.length > 0) { - return nestedCalls; - } - - const data = transaction.txParams?.data as Hex | undefined; - - if (data === undefined || data === '0x') { - return []; - } - - return [ - { - data, - target: transaction.txParams?.to as Hex | undefined, - }, - ]; -} - async function normalizeQuote( original: AcrossQuoteWithoutMetaMask, request: QuoteRequest, From 8457618a0c4bb527a6895a8530a72297591cba96 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 19 Mar 2026 13:32:05 +0000 Subject: [PATCH 4/4] Fix Across import order and restore changelog entry --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- .../src/strategy/across/across-quotes.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4ea1bc4363c..1039de52ed2 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) - Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) - Remove duplication in gas estimation for Relay and Across strategies ([#8145](https://github.com/MetaMask/core/pull/8145)) +- Improve Across quote handling to decode supported destination calls into post-swap actions while sending transfer-only destinations directly to the destination recipient ([#8208](https://github.com/MetaMask/core/pull/8208)) ## [17.1.0] @@ -50,7 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support gasless Relay deposits via `execute` endpoint ([#8133](https://github.com/MetaMask/core/pull/8133)) - Build Across post-swap transfer actions for `predictDeposit` quotes so Predict deposits can bridge swapped output into the destination proxy wallet ([#8159](https://github.com/MetaMask/core/pull/8159)) -- Improve Across quote handling to decode supported destination calls into post-swap actions while sending transfer-only destinations directly to the destination recipient ([#8208](https://github.com/MetaMask/core/pull/8208)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index 0f0392c921f..713d0a316f8 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -3,8 +3,8 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { getAcrossOrderedTransactions } from './transactions'; import { getAcrossDestination } from './across-actions'; +import { getAcrossOrderedTransactions } from './transactions'; import type { AcrossAction, AcrossActionRequestBody,