diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9287ad4f7d..8c20b6a66e 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Adding processing for postQuote transactions with paymentOverride defined ([#8967](https://github.com/MetaMask/core/pull/8967)) - Add `@metamask/keyring-controller` `^26.0.0` as a dependency ([#8972](https://github.com/MetaMask/core/pull/8972)) - The package was already imported at runtime by `src/strategy/relay/hyperliquid-withdraw.ts` but wasn't declared in `package.json`; this PR fixes the omission. diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index c20aa399dc..9b1639f7d3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -181,9 +181,11 @@ describe('Relay Quotes Utils', () => { estimateGasMock, estimateGasBatchMock, findNetworkClientIdByChainIdMock, + getControllerStateMock, getDelegationTransactionMock, getGasFeeTokensMock, getKeyringControllerStateMock, + getPaymentOverrideDataMock, getRemoteFeatureFlagControllerStateMock, polymarketGetDepositWalletAddressMock, } = getMessengerMock(); @@ -2723,6 +2725,284 @@ describe('Relay Quotes Utils', () => { }); }); + describe('Money Account post-quote (processMoneyAccountPostQuote)', () => { + const TRANSACTION_ID_MOCK = 'money-account-tx-1'; + const MONEY_ACCOUNT_RECIPIENT_MOCK = + '0xaa00000000000000000000000000000000000001' as Hex; + const AMOUNT_HUMAN_MOCK = '100.5'; + const AMOUNT_RAW_MOCK = '100500000'; + const OVERRIDE_CALL_MOCK = { + to: '0xbb00000000000000000000000000000000000001' as Hex, + data: '0xcc' as Hex, + value: '0x0' as Hex, + }; + + const MONEY_ACCOUNT_TX_MOCK = { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_MOCK, + } as TransactionMeta; + + const MONEY_ACCOUNT_REQUEST_MOCK: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }; + + function setupMoneyAccountMocks({ + amountHuman = AMOUNT_HUMAN_MOCK, + overrideCalls = [OVERRIDE_CALL_MOCK], + recipient, + authorizationList = DELEGATION_RESULT_MOCK.authorizationList, + }: { + amountHuman?: string; + overrideCalls?: { to: Hex; data: Hex; value: Hex }[]; + recipient?: Hex; + authorizationList?: typeof DELEGATION_RESULT_MOCK.authorizationList; + } = {}): void { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_ID_MOCK]: { + tokens: [{ amountHuman }], + }, + }, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: overrideCalls, + ...(recipient ? { recipient } : {}), + ...(authorizationList ? { authorizationList } : {}), + }); + } + + it('sets tradeType to EXACT_OUTPUT and amount from request sourceTokenAmount', async () => { + setupMoneyAccountMocks(); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body.amount).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + }); + + it('includes token transfer and override calls in txs', async () => { + setupMoneyAccountMocks({ recipient: MONEY_ACCOUNT_RECIPIENT_MOCK }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs).toHaveLength(2); + expect(body.txs[0].to).toBe(QUOTE_REQUEST_MOCK.targetTokenAddress); + expect(body.txs[1].to).toBe(OVERRIDE_CALL_MOCK.to); + expect(body.txs[1].data).toBe(OVERRIDE_CALL_MOCK.data); + }); + + it('uses request.from as funding recipient when override provides no recipient', async () => { + setupMoneyAccountMocks(); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs[0].data).toContain( + QUOTE_REQUEST_MOCK.from.slice(2).toLowerCase(), + ); + }); + + it('uses override recipient as funding recipient when provided', async () => { + setupMoneyAccountMocks({ recipient: MONEY_ACCOUNT_RECIPIENT_MOCK }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs[0].data).toContain( + MONEY_ACCOUNT_RECIPIENT_MOCK.slice(2).toLowerCase(), + ); + }); + + it('does not set txs when payment override returns no calls', async () => { + setupMoneyAccountMocks({ overrideCalls: [] }); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs).toBeUndefined(); + expect(body.tradeType).not.toBe('EXACT_OUTPUT'); + }); + + it('normalizes authorization list from payment override data', async () => { + setupMoneyAccountMocks(); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.authorizationList).toStrictEqual([ + expect.objectContaining({ + chainId: 1, + nonce: 2, + yParity: 1, + }), + ]); + }); + + it('passes amountHuman and transactionData to getPaymentOverrideData', async () => { + setupMoneyAccountMocks(); + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + amount: AMOUNT_HUMAN_MOCK, + transaction: MONEY_ACCOUNT_TX_MOCK, + }), + ); + }); + + it('defaults amountHuman to 0 when transactionData has no tokens', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_ID_MOCK]: {}, + }, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + }); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith( + expect.objectContaining({ amount: '0' }), + ); + }); + + it('defaults call value to 0x0 when override call omits value', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_ID_MOCK]: { + tokens: [ + { + amountHuman: AMOUNT_HUMAN_MOCK, + amountRaw: AMOUNT_RAW_MOCK, + }, + ], + }, + }, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [{ to: OVERRIDE_CALL_MOCK.to, data: OVERRIDE_CALL_MOCK.data }], + }); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [MONEY_ACCOUNT_REQUEST_MOCK], + transaction: MONEY_ACCOUNT_TX_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs[1].value).toBe('0x0'); + }); + }); + describe('HyperLiquid source (isHyperliquidSource)', () => { const HL_REQUEST: QuoteRequest = { ...QUOTE_REQUEST_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 4d1bb5fca2..3ee17c4af7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -2,7 +2,10 @@ import { Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + AuthorizationList, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -19,6 +22,7 @@ import { PERPS_DEPOSIT_TYPES, USDC_DECIMALS, STABLECOINS, + PaymentOverride, } from '../../constants'; import { projectLogger } from '../../logger'; import type { @@ -265,6 +269,11 @@ async function getSingleQuote( // bridged token transfer, not a contract call to embed. if (!request.isPostQuote && !request.isPolymarketDepositWallet) { await processTransactions(transaction, request, body, messenger); + } else if ( + request.isPostQuote && + request.paymentOverride === PaymentOverride.MoneyAccount + ) { + await processMoneyAccountPostQuote(transaction, request, body, messenger); } else if (request.refundTo) { // For post-quote flows, honour the caller-specified refund address so that // failed Relay transactions refund to the correct account (e.g. the Predict @@ -285,6 +294,19 @@ async function getSingleQuote( } } +function normalizeAuthorizationList( + authorizationList: AuthorizationList | undefined, +): RelayQuoteRequest['authorizationList'] { + return authorizationList?.map((a) => ({ + ...a, + chainId: Number(a.chainId), + nonce: Number(a.nonce), + r: a.r as Hex, + s: a.s as Hex, + yParity: Number(a.yParity), + })); +} + /** * Add tranasction data to request body if needed. * @@ -334,18 +356,9 @@ async function processTransactions( { transaction }, ); - const normalizedAuthorizationList = delegation.authorizationList?.map( - (a) => ({ - ...a, - chainId: Number(a.chainId), - nonce: Number(a.nonce), - r: a.r as Hex, - s: a.s as Hex, - yParity: Number(a.yParity), - }), + requestBody.authorizationList = normalizeAuthorizationList( + delegation.authorizationList, ); - - requestBody.authorizationList = normalizedAuthorizationList; requestBody.tradeType = 'EXACT_OUTPUT'; const tokenTransferData = nestedTransactions?.find((nestedTx) => @@ -378,6 +391,57 @@ async function processTransactions( ]; } +async function processMoneyAccountPostQuote( + transaction: TransactionMeta, + request: QuoteRequest, + requestBody: RelayQuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { transactionData: transactionDataList } = messenger.call( + 'TransactionPayController:getState', + ); + + const transactionData = transactionDataList[transaction.id]; + const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; + + const { + calls: overrideCalls, + recipient, + authorizationList, + } = await messenger.call('TransactionPayController:getPaymentOverrideData', { + amount: amountHuman, + transaction, + transactionData, + }); + + if (!overrideCalls.length) { + log('No payment override calls for money account post-quote'); + return; + } + + const fundingRecipient = recipient ?? request.from; + + requestBody.authorizationList = normalizeAuthorizationList(authorizationList); + requestBody.tradeType = 'EXACT_OUTPUT'; + requestBody.amount = request.sourceTokenAmount; + requestBody.txs = [ + { + to: request.targetTokenAddress, + data: buildTokenTransferData(fundingRecipient, request.sourceTokenAmount), + value: '0x0', + }, + ...overrideCalls.map((call) => ({ + to: call.to as Hex, + data: call.data as Hex, + value: (call.value as Hex) ?? '0x0', + })), + ]; + + log('Added money account deposit calls to quote body', { + callCount: overrideCalls.length, + }); +} + /** * Normalizes requests for Relay. * diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fa95fa1f2d..574cc18b3f 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -167,6 +167,12 @@ export type GetPaymentOverrideDataRequest = { export type GetPaymentOverrideDataResponse = { /** Batch transaction params to prepend to the submit batch. */ calls: BatchTransactionParams[]; + + /** Optional recipient address for the funding token transfer. */ + recipient?: Hex; + + /** Optional EIP-7702 authorization list from delegation. */ + authorizationList?: AuthorizationList; }; /**