diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 6bc74bdfa14..e64f163417c 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Add `KeyringControllerGetStateAction` to `AllowedActions` to enable keyring-based EIP-7702 account compatibility checks in `addTransactionBatch` ([#8388](https://github.com/MetaMask/core/pull/8388)) + - `addTransactionBatch` now automatically checks whether the account's keyring supports EIP-7702 before attempting the 7702 batch path, falling back to STX/sequential when unsupported + - Clients must add `KeyringController:getState` to the TransactionController messenger's allowed actions + ## [64.3.0] ### Added diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index b78422231e7..b2c24ead95d 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -31,7 +31,10 @@ import type { FetchGasFeeEstimateOptions, GasFeeState, } from '@metamask/gas-fee-controller'; -import type { KeyringControllerSignEip7702AuthorizationAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerSignEip7702AuthorizationAction, +} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { BlockTracker, @@ -491,6 +494,7 @@ export type AllowedActions = | AccountsControllerGetSelectedAccountAction | AccountsControllerGetStateAction | ApprovalControllerAddRequestAction + | KeyringControllerGetStateAction | KeyringControllerSignEip7702AuthorizationAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 708f8bef792..637149d8932 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -51,6 +51,7 @@ import type { import type { TransactionBatchResult, TransactionParams } from '../types'; import { ERROR_MESSGE_PUBLIC_KEY, + doesAccountSupportEIP7702, doesChainSupportEIP7702, generateEIP7702BatchTransaction, isAccountUpgradedToEIP7702, @@ -136,7 +137,12 @@ export async function addTransactionBatch( log('Adding', transactionBatchRequest); - if (!transactionBatchRequest.disable7702) { + const accountCanUse7702 = doesAccountSupportEIP7702( + messenger, + transactionBatchRequest.from, + ); + + if (!transactionBatchRequest.disable7702 && accountCanUse7702) { try { return await addTransactionBatchWith7702(request); } catch (error: unknown) { diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 5410997e04a..9ae339aa7ac 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -40,6 +40,37 @@ const ERC7579_EXEC_TYPE_TRY = '01'; const log = createModuleLogger(projectLogger, 'eip-7702'); +const KEYRING_TYPES_SUPPORTING_7702 = ['HD Key Tree', 'Simple Key Pair']; + +/** + * Check whether a given account's keyring supports EIP-7702 authorization + * signing. + * + * Looks up the account's keyring via `KeyringController:getState` and returns + * `true` only when the keyring type is in the supported list. + * Falls back to `true` when the keyring cannot be resolved. + * + * @param messenger - Controller messenger. + * @param account - The account address to check. + * @returns Whether the account supports EIP-7702. + */ +export function doesAccountSupportEIP7702( + messenger: TransactionControllerMessenger, + account: string, +): boolean { + const { keyrings } = messenger.call('KeyringController:getState'); + const keyring = keyrings.find( + (k: { type: string; accounts: string[] }) => + k.accounts.some( + (a: string) => a.toLowerCase() === account.toLowerCase(), + ), + ); + + return keyring + ? KEYRING_TYPES_SUPPORTING_7702.includes(keyring.type) + : true; +} + /** * Determine if a chain supports EIP-7702 using LaunchDarkly feature flag. * diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ca00df549f5..4950743854e 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^104.0.0` to `^104.1.0` ([#8509](https://github.com/MetaMask/core/pull/8509)) +### Fixed + +- **BREAKING:** Fix mUSD conversion for hardware wallets on EIP-7702 chains by gating relay and Across 7702 paths on the account keyring type via `KeyringController:getState` ([#8388](https://github.com/MetaMask/core/pull/8388)) + - `AccountSupports7702Callback` type export has been removed. Use the `accountSupports7702` util from `utils/7702` instead. + - The `TransactionPayControllerMessenger` now requires `KeyringController:getState` permission (previously only needed in the publish hook). + ## [19.2.1] ### Fixed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 7f669956e50..4bf9953bb58 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -37,6 +37,7 @@ describe('TransactionPayController', () => { const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; + let getKeyringControllerStateMock: jest.Mock; /** * Create a TransactionPayController. @@ -53,7 +54,21 @@ describe('TransactionPayController', () => { beforeEach(() => { jest.resetAllMocks(); - messenger = getMessengerMock({ skipRegister: true }).messenger; + const mocks = getMessengerMock({ skipRegister: true }); + messenger = mocks.messenger; + getKeyringControllerStateMock = mocks.getKeyringControllerStateMock; + + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0x1234567890123456789012345678901234567891'], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); updateQuotesMock.mockResolvedValue(true); }); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index a8b739d451b..0f940caac53 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -33,6 +33,7 @@ describe('TransactionPayPublishHook', () => { const { messenger, getControllerStateMock, + getKeyringControllerStateMock, getTransactionControllerStateMock, updateTransactionMock, } = getMessengerMock(); @@ -51,6 +52,17 @@ describe('TransactionPayPublishHook', () => { beforeEach(() => { jest.resetAllMocks(); + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + hook = new TransactionPayPublishHook({ isSmartTransaction: isSmartTransactionMock, messenger, @@ -81,6 +93,7 @@ describe('TransactionPayPublishHook', () => { expect(executeMock).toHaveBeenCalledWith( expect.objectContaining({ + accountSupports7702: true, quotes: [QUOTE_MOCK, QUOTE_MOCK], }), ); @@ -141,6 +154,42 @@ describe('TransactionPayPublishHook', () => { expect(updateTransactionMock).not.toHaveBeenCalled(); }); + it('defaults to accountSupports7702 true when keyring not found', async () => { + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [], + }); + + await runHook(); + + expect(executeMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: true, + }), + ); + }); + + it('sets accountSupports7702 false for hardware wallet keyring', async () => { + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'Ledger Hardware', + accounts: ['0xabc'], + metadata: { id: 'ledger', name: 'Ledger' }, + }, + ], + }); + + await runHook(); + + expect(executeMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: false, + }), + ); + }); + it('throws errors from submit', async () => { executeMock.mockRejectedValue(new Error('Test error')); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index 14244237fdb..4988a742df8 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -9,6 +9,7 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../types'; +import { accountSupports7702 } from '../utils/7702'; import { getStrategyByName } from '../utils/strategy'; import { updateTransaction } from '../utils/transaction'; @@ -81,8 +82,10 @@ export class TransactionPayPublishHook { ); const strategy = getStrategyByName(quotes[0].strategy); + const from = transactionMeta.txParams.from as Hex; return await strategy.execute({ + accountSupports7702: accountSupports7702(this.#messenger, from), isSmartTransaction: this.#isSmartTransaction, quotes, messenger: this.#messenger, 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 2b5e411625f..69f22f1fe07 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 @@ -217,6 +217,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -230,6 +231,7 @@ describe('Across Quotes', () => { it('filters out requests with zero target amount', async () => { const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -246,6 +248,7 @@ describe('Across Quotes', () => { it('filters out non-max requests with missing target amount', async () => { const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -265,6 +268,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -278,6 +282,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -296,6 +301,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -316,6 +322,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -352,6 +359,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -371,6 +379,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -395,6 +404,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -437,6 +447,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -463,6 +474,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -486,6 +498,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -513,6 +526,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -539,6 +553,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -642,6 +657,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -679,6 +695,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -729,6 +746,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -749,6 +767,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -801,6 +820,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -825,6 +845,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -844,6 +865,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -857,6 +879,7 @@ describe('Across Quotes', () => { it('throws when destination flow is not transfer-style', async () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -873,6 +896,7 @@ describe('Across Quotes', () => { it('throws when txParams include authorization list', async () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -899,6 +923,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -919,6 +944,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -940,6 +966,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -963,6 +990,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -986,6 +1014,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1010,6 +1039,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [request], transaction: TRANSACTION_META_MOCK, @@ -1027,6 +1057,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1046,6 +1077,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1063,6 +1095,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1097,6 +1130,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1144,6 +1178,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1205,6 +1240,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1253,6 +1289,43 @@ describe('Across Quotes', () => { ); }); + it('re-estimates individually when batch returns 7702 but account does not support it', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: undefined, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: false, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toHaveLength(2); + expect(estimateGasMock).toHaveBeenCalledTimes(2); + }); + it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => { const estimateQuoteGasLimitsSpy = jest.spyOn( quoteGasUtils, @@ -1282,6 +1355,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1314,6 +1388,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1338,6 +1413,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1370,6 +1446,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1437,6 +1514,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1471,6 +1549,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1512,6 +1591,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1534,12 +1614,13 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }); - expect(result[0].targetAmount.raw).toBe('150'); + expect(result[0].targetAmount.usd).toBe('0.0003'); expect(result[0].dust.usd).toBe('0'); expect(result[0].fees.provider.usd).toBe('0'); expect(result[0].fees.provider.fiat).toBe('0'); @@ -1559,6 +1640,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1578,14 +1660,13 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }); - expect(result[0].targetAmount.raw).toBe( - QUOTE_REQUEST_MOCK.targetAmountMinimum, - ); + expect(result[0].targetAmount.usd).toBe('0.000246'); }); it('handles missing target amount minimum for max amount requests', async () => { @@ -1604,12 +1685,13 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [request], transaction: TRANSACTION_META_MOCK, }); - expect(result[0].targetAmount.raw).toBe('0'); + expect(result[0].targetAmount.usd).toBe('0'); }); it('uses from address as recipient when no transfer data', async () => { @@ -1618,6 +1700,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1638,6 +1721,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1662,6 +1746,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1684,6 +1769,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1704,6 +1790,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1724,6 +1811,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1741,6 +1829,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1766,6 +1855,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1791,6 +1881,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index 962302cf373..ccaf01f1981 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -36,6 +36,7 @@ const QUOTE_MOCK: TransactionPayQuote = { dust: { usd: '0', fiat: '0' }, estimatedDuration: 0, fees: { + metaMask: { usd: '0', fiat: '0' }, provider: { usd: '0', fiat: '0' }, sourceNetwork: { estimate: { usd: '0', fiat: '0', human: '0', raw: '0' }, @@ -93,7 +94,7 @@ const QUOTE_MOCK: TransactionPayQuote = { targetTokenAddress: '0xdef' as Hex, }, sourceAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, - targetAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, + targetAmount: { usd: '0', fiat: '0' }, strategy: TransactionPayStrategy.Across, }; @@ -190,6 +191,7 @@ describe('Across Submit', () => { it('submits a batch when approvals exist', async () => { await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [QUOTE_MOCK], transaction: TRANSACTION_META_MOCK, @@ -229,6 +231,7 @@ describe('Across Submit', () => { } as unknown as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [batchGasQuote], transaction: TRANSACTION_META_MOCK, @@ -259,6 +262,41 @@ describe('Across Submit', () => { ); }); + it('submits batch without 7702 when quote is7702 is false', async () => { + const nonIs7702Quote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [ + { estimate: 21000, max: 21000 }, + { estimate: 22000, max: 22000 }, + ], + is7702: false, + }, + }, + } as unknown as TransactionPayQuote; + + await submitAcrossQuotes({ + accountSupports7702: true, + messenger, + quotes: [nonIs7702Quote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: true, + disableHook: false, + disableSequential: false, + gasLimit7702: undefined, + }), + ); + expect(addTransactionMock).not.toHaveBeenCalled(); + }); + it('submits a single transaction when no approvals', async () => { const noApprovalQuote = { ...QUOTE_MOCK, @@ -272,6 +310,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -301,6 +340,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingBatchGasQuote], transaction: TRANSACTION_META_MOCK, @@ -324,6 +364,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -354,6 +395,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -384,6 +426,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -426,6 +469,7 @@ describe('Across Submit', () => { ]); await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -455,6 +499,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -509,6 +554,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -564,6 +610,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -626,6 +673,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -689,6 +737,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -712,6 +761,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -738,6 +788,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -757,6 +808,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -775,6 +827,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -805,6 +858,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -839,6 +893,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -871,6 +926,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -900,6 +956,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingSwapGasQuote], transaction: TRANSACTION_META_MOCK, @@ -923,6 +980,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingApprovalGasQuote], transaction: TRANSACTION_META_MOCK, @@ -949,6 +1007,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -982,6 +1041,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [decimalGasQuote], transaction: TRANSACTION_META_MOCK, @@ -1016,6 +1076,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quoteWithApproval], transaction: TRANSACTION_META_MOCK, @@ -1047,6 +1108,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quoteWithoutValue], transaction: TRANSACTION_META_MOCK, @@ -1115,6 +1177,7 @@ describe('Across Submit', () => { }); await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quote1, quote2], transaction: TRANSACTION_META_MOCK, @@ -1141,6 +1204,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts index ed1e824313c..37c807c4003 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts @@ -53,6 +53,7 @@ describe('BridgeStrategy', () => { describe('getQuotes', () => { it('returns result from util', async () => { const result = new BridgeStrategy().getQuotes({ + accountSupports7702: true, messenger: {} as TransactionPayControllerMessenger, requests: [], transaction: {} as TransactionMeta, @@ -86,6 +87,7 @@ describe('BridgeStrategy', () => { describe('execute', () => { it('calls util', async () => { await new BridgeStrategy().execute({ + accountSupports7702: true, isSmartTransaction: () => false, quotes: [QUOTE_MOCK], messenger: {} as TransactionPayControllerMessenger, diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts index ec7a12a130b..e69a73072ac 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts @@ -143,6 +143,7 @@ describe('Bridge Quotes Utils', () => { }); request = { + accountSupports7702: true, requests: [QUOTE_REQUEST_1_MOCK, QUOTE_REQUEST_2_MOCK], messenger, transaction: TRANSACTION_META_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index ec26912193c..959631c6019 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -35,7 +35,8 @@ const log = createModuleLogger(projectLogger, 'fiat-strategy'); export async function getFiatQuotes( request: PayStrategyGetQuotesRequest, ): Promise[]> { - const { fiatPaymentMethod, messenger, transaction } = request; + const { accountSupports7702, fiatPaymentMethod, messenger, transaction } = + request; const transactionId = transaction.id; const state = messenger.call('TransactionPayController:getState'); @@ -76,6 +77,7 @@ export async function getFiatQuotes( } const relayQuotes = await getRelayQuotes({ + accountSupports7702, messenger, requests: [relayRequest], transaction, 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 4432974b027..9318482e2f9 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 @@ -231,6 +231,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -249,6 +250,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -287,6 +289,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -308,6 +311,28 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.originGasOverhead).toBeUndefined(); + }); + + it('omits originGasOverhead when account does not support 7702 even on EIP-7702 chain with relay execute enabled', async () => { + isRelayExecuteEnabledMock.mockReturnValue(true); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: false, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -326,6 +351,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -346,6 +372,7 @@ describe('Relay Quotes Utils', () => { it('throws if isMaxAmount is true and transaction includes data', async () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: { @@ -366,6 +393,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -412,6 +440,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -436,6 +465,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -455,6 +485,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -478,6 +509,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -530,6 +562,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -554,6 +587,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -582,6 +616,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -614,6 +649,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -638,6 +674,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -679,6 +716,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -696,6 +734,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -716,6 +755,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -745,6 +785,7 @@ describe('Relay Quotes Utils', () => { const refundTo = '0xsafe000000000000000000000000000000000001' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -770,6 +811,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -806,6 +848,7 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -830,6 +873,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -865,6 +909,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -926,6 +971,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -985,6 +1031,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1019,6 +1066,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1058,6 +1106,7 @@ describe('Relay Quotes Utils', () => { estimateGasMock.mockRejectedValue(new Error('Estimation failed')); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1100,6 +1149,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1132,6 +1182,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1165,6 +1216,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1210,6 +1262,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1255,6 +1308,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1290,6 +1344,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1324,6 +1379,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1358,6 +1414,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1379,6 +1436,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1403,6 +1461,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1436,6 +1495,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1459,6 +1519,7 @@ describe('Relay Quotes Utils', () => { getTokenBalanceMock.mockReturnValue('0'); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1502,6 +1563,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1547,6 +1609,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1573,6 +1636,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockRejectedValue(new Error('Simulation failed')); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1604,6 +1668,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1643,6 +1708,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1665,6 +1731,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1679,6 +1746,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1700,6 +1768,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1721,6 +1790,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1738,6 +1808,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1768,6 +1839,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1815,6 +1887,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1833,6 +1906,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1863,6 +1937,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1919,6 +1994,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1938,6 +2014,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1985,6 +2062,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2009,6 +2087,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -2052,6 +2131,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2085,6 +2165,7 @@ describe('Relay Quotes Utils', () => { ]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2117,6 +2198,7 @@ describe('Relay Quotes Utils', () => { calculateGasFeeTokenCostMock.mockReturnValue(undefined); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2151,6 +2233,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2181,6 +2264,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2217,6 +2301,7 @@ describe('Relay Quotes Utils', () => { isEIP7702ChainMock.mockReturnValue(true); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [lineaQuoteRequest], transaction: TRANSACTION_META_MOCK, @@ -2252,6 +2337,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2272,6 +2358,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2286,6 +2373,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2306,6 +2394,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2331,6 +2420,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2350,6 +2440,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2368,6 +2459,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2385,6 +2477,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2404,6 +2497,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2421,6 +2515,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2451,6 +2546,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -2468,6 +2564,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -2491,6 +2588,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2507,6 +2605,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2523,6 +2622,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2542,6 +2642,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [arbitrumToHyperliquidRequest], transaction: TRANSACTION_META_MOCK, @@ -2574,6 +2675,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [postQuoteRequest], transaction: TRANSACTION_META_MOCK, @@ -2605,6 +2707,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [polygonToHyperliquidRequest], transaction: TRANSACTION_META_MOCK, @@ -2633,6 +2736,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [polygonTargetRequest], transaction: TRANSACTION_META_MOCK, @@ -2663,6 +2767,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2694,6 +2799,7 @@ describe('Relay Quotes Utils', () => { estimateGasMock.mockRejectedValue(new Error('Estimation failed')); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2720,6 +2826,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2751,6 +2858,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2799,6 +2907,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2832,6 +2941,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2847,6 +2957,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2868,6 +2979,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2891,6 +3003,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2913,6 +3026,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2937,6 +3051,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2971,6 +3086,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3006,6 +3122,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3041,6 +3158,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3075,6 +3193,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_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 e8f04bb3d9a..6b62bf8e506 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -221,7 +221,10 @@ async function getSingleQuote( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const useExactInput = isMaxAmount || request.isPostQuote; + const { accountSupports7702: supports7702 } = fullRequest; + const useExecute = + supports7702 && isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, sourceChainId); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 8e640262811..534bbcd90e1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -25,7 +25,7 @@ import { } from '../../utils/transaction'; import { RELAY_STATUS_URL } from './constants'; import { submitRelayQuotes } from './relay-submit'; -import type { RelayQuote } from './types'; +import type { RelayQuote, RelayTransactionStep } from './types'; jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); @@ -51,16 +51,18 @@ const TRANSACTION_META_MOCK = { hash: TRANSACTION_HASH_MOCK, } as TransactionMeta; -const ORIGINAL_QUOTE_MOCK = { +const ORIGINAL_QUOTE_MOCK: RelayQuote & { steps: RelayTransactionStep[] } = { details: { currencyIn: { currency: { chainId: 1, + decimals: 6, }, }, currencyOut: { currency: { chainId: 2, + decimals: 6, }, }, }, @@ -68,7 +70,17 @@ const ORIGINAL_QUOTE_MOCK = { gasLimits: [21000, 21000], is7702: false, }, - request: {}, + request: { + amount: '1', + destinationChainId: 2, + destinationCurrency: '0xdef' as Hex, + originChainId: 1, + originCurrency: '0xabc' as Hex, + recipient: FROM_MOCK, + slippageTolerance: '100', + tradeType: 'EXPECTED_OUTPUT', + user: FROM_MOCK, + }, steps: [ { id: 'swap', @@ -76,6 +88,10 @@ const ORIGINAL_QUOTE_MOCK = { requestId: REQUEST_ID_MOCK, items: [ { + check: { + endpoint: '/test', + method: 'GET', + }, data: { chainId: 1, data: '0x1234' as Hex, @@ -91,7 +107,7 @@ const ORIGINAL_QUOTE_MOCK = { ], }, ], -} as RelayQuote; +}; const STATUS_RESPONSE_MOCK = { status: 'success', @@ -102,6 +118,7 @@ const STATUS_RESPONSE_MOCK = { const SOURCE_AMOUNT_RAW_MOCK = '1000000'; const REQUEST_MOCK: PayStrategyExecuteRequest = { + accountSupports7702: true, quotes: [ { fees: { @@ -448,6 +465,33 @@ describe('Relay Submit Utils', () => { ); }); + it('does not add authorization list when quote is7702 is false', async () => { + request.quotes[0].original.metamask.is7702 = false; + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.request = { + authorizationList: [ + { + address: '0xabc' as Hex, + chainId: 1, + nonce: 2, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: 1, + }, + ], + } as never; + + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorizationList: expect.anything(), + }), + expect.anything(), + ); + }); + it('adds transaction batch if multiple params', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], @@ -461,6 +505,8 @@ describe('Relay Submit Utils', () => { disableHook: false, disableSequential: false, from: FROM_MOCK, + gasFeeToken: undefined, + gasLimit7702: undefined, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: ORIGIN_METAMASK, overwriteUpgrade: true, @@ -855,6 +901,9 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ + disable7702: true, + disableHook: false, + disableSequential: false, from: FROM_MOCK, gasFeeToken: undefined, networkClientId: NETWORK_CLIENT_ID_MOCK, @@ -972,6 +1021,7 @@ describe('Relay Submit Utils', () => { disableHook: false, disableSequential: false, gasLimit7702: undefined, + overwriteUpgrade: true, transactions: [ expect.objectContaining({ params: expect.objectContaining({ @@ -998,10 +1048,8 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ - disable7702: false, - disableHook: true, - disableSequential: true, gasLimit7702: '0x31955', + overwriteUpgrade: true, transactions: [ expect.objectContaining({ params: expect.objectContaining({ @@ -1034,10 +1082,8 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ - disable7702: false, - disableHook: true, - disableSequential: true, gasLimit7702: '0xa410', + overwriteUpgrade: true, transactions: [ expect.objectContaining({ params: expect.objectContaining({ @@ -1054,6 +1100,98 @@ describe('Relay Submit Utils', () => { ); }); + it('uses 7702 mode based on quote metadata regardless of account support', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [21000, 22000]; + request.quotes[0].original.metamask.is7702 = true; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + overwriteUpgrade: true, + gasLimit7702: '0x5208', + }), + ); + expect(addTransactionMock).not.toHaveBeenCalled(); + }); + + it('omits per-transaction gas when entry is missing in batch submission', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [21000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + gas: '0x5208', + }), + }), + expect.objectContaining({ + params: expect.objectContaining({ + gas: undefined, + }), + }), + ], + }), + ); + }); + + it('waits for on-chain confirmation with multiple transactions', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [21000, 22000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(waitForTransactionConfirmedMock).toHaveBeenCalled(); + }); + + it('uses addTransactionBatch with multiple transactions', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.gasLimits = [21000, 22000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).not.toHaveBeenCalled(); + }); + + it('waits for on-chain confirmation with multiple transactions in 7702 mode', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + }); + + request.quotes[0].original.metamask.is7702 = true; + + await submitRelayQuotes(request); + + expect(waitForTransactionConfirmedMock).toHaveBeenCalled(); + }); + + it('waits for on-chain confirmation with single transaction', async () => { + await submitRelayQuotes(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(waitForTransactionConfirmedMock).toHaveBeenCalled(); + }); + it('adds transaction batch without gasLimit7702 when multiple gas limits', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], @@ -1070,6 +1208,7 @@ describe('Relay Submit Utils', () => { disableHook: false, disableSequential: false, gasLimit7702: undefined, + overwriteUpgrade: true, transactions: [ expect.objectContaining({ params: expect.objectContaining({ diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts index 0cd4996615f..33f9f5ac247 100644 --- a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts @@ -18,6 +18,7 @@ describe('TestStrategy', () => { describe('getQuotes', () => { it('returns quote', async () => { const quotesPromise = new TestStrategy().getQuotes({ + accountSupports7702: true, messenger: {} as TransactionPayControllerMessenger, requests: [REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -83,6 +84,7 @@ describe('TestStrategy', () => { describe('execute', () => { it('resolves', async () => { const executePromise = new TestStrategy().execute({ + accountSupports7702: true, isSmartTransaction: () => false, messenger: {} as TransactionPayControllerMessenger, quotes: [QUOTE_MOCK], diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 7ebda275cde..cf09614a154 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -6,6 +6,7 @@ import type { BridgeStatusControllerGetStateAction, BridgeStatusControllerSubmitTxAction, } from '@metamask/bridge-status-controller'; +import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; import type { MessengerActions, MessengerEvents, @@ -131,6 +132,19 @@ export function getMessengerMock({ const getAssetsControllerStateMock = jest.fn(); + const getKeyringControllerStateMock: jest.MockedFn< + KeyringControllerGetStateAction['handler'] + > = jest.fn().mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0x1234567890123456789012345678901234567891'], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + const messenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); @@ -252,6 +266,11 @@ export function getMessengerMock({ ); } + messenger.registerActionHandler( + 'KeyringController:getState', + getKeyringControllerStateMock, + ); + const publish = messenger.publish.bind(messenger); return { @@ -269,6 +288,7 @@ export function getMessengerMock({ getDelegationTransactionMock, getGasFeeControllerStateMock, getGasFeeTokensMock, + getKeyringControllerStateMock, getNetworkClientByIdMock, getRemoteFeatureFlagControllerStateMock, getStrategyMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 320f07327dc..c962d059fb7 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -12,7 +12,11 @@ import type { BridgeControllerActions } from '@metamask/bridge-controller'; import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; import type { GasFeeControllerActions } from '@metamask/gas-fee-controller'; -import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerSignTypedMessageAction, + KeyringTypes, +} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; @@ -47,6 +51,7 @@ export type AllowedActions = | BridgeStatusControllerActions | CurrencyRateControllerActions | GasFeeControllerActions + | KeyringControllerGetStateAction | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction @@ -129,6 +134,15 @@ export type TransactionPayControllerMessenger = Messenger< TransactionPayControllerEvents | AllowedEvents >; +/** + * Keyring types that support EIP-7702 authorization signing. + * Hardware wallets, snap keyrings, and money keyrings do not support 7702. + */ +export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ + 'HD Key Tree', + 'Simple Key Pair', +]; + /** Options for the TransactionPayController. */ export type TransactionPayControllerOptions = { /** Callback to convert a transaction into a redeem delegation. */ @@ -424,6 +438,9 @@ export type TransactionPayQuote = { /** Request to get quotes for a transaction. */ export type PayStrategyGetQuotesRequest = { + /** Whether the account supports EIP-7702 authorization signing. */ + accountSupports7702: boolean; + /** Selected fiat payment method ID, if applicable. */ fiatPaymentMethod?: string; @@ -439,6 +456,9 @@ export type PayStrategyGetQuotesRequest = { /** Request to submit quotes for a transaction. */ export type PayStrategyExecuteRequest = { + /** Whether the account supports EIP-7702 authorization signing. */ + accountSupports7702: boolean; + /** Callback to determine if the transaction is a smart transaction. */ isSmartTransaction: (chainId: Hex) => boolean; diff --git a/packages/transaction-pay-controller/src/utils/7702.ts b/packages/transaction-pay-controller/src/utils/7702.ts new file mode 100644 index 00000000000..277bda810bf --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/7702.ts @@ -0,0 +1,28 @@ +import type { TransactionPayControllerMessenger } from '../types'; +import { KEYRING_TYPES_SUPPORTING_7702 } from '../types'; + +/** + * Check whether a given account supports EIP-7702 authorization signing. + * + * Looks up the account's keyring via `KeyringController:getState` and returns + * `true` only when the keyring type is in the supported list (HD Key Tree, + * Simple Key Pair). Hardware wallets, snap keyrings, and other types return + * `false`. Falls back to `true` when the keyring cannot be resolved. + * + * @param messenger - Controller messenger used to call KeyringController. + * @param account - The account address to check. + * @returns Whether the account supports EIP-7702. + */ +export function accountSupports7702( + messenger: TransactionPayControllerMessenger, + account: string, +): boolean { + const { keyrings } = messenger.call('KeyringController:getState'); + const keyring = keyrings.find((k) => + k.accounts.some((a) => a.toLowerCase() === account.toLowerCase()), + ); + + return keyring + ? (KEYRING_TYPES_SUPPORTING_7702 as string[]).includes(keyring.type) + : true; +} diff --git a/packages/transaction-pay-controller/src/utils/quote-gas.ts b/packages/transaction-pay-controller/src/utils/quote-gas.ts index 128154ca037..94e77287b4c 100644 --- a/packages/transaction-pay-controller/src/utils/quote-gas.ts +++ b/packages/transaction-pay-controller/src/utils/quote-gas.ts @@ -6,6 +6,7 @@ import { BigNumber } from 'bignumber.js'; import type { TransactionPayControllerMessenger } from '..'; import { projectLogger } from '../logger'; +import { accountSupports7702 } from './7702'; import { getGasBuffer } from './feature-flags'; import { estimateGasLimit } from './gas'; @@ -53,8 +54,15 @@ export async function estimateQuoteGasLimits({ const useBatch = transactions.length > 1; if (useBatch) { + const result = await estimateQuoteGasLimitsBatch( + transactions, + messenger, + fallbackGas, + fallbackOnSimulationFailure, + ); + return { - ...(await estimateQuoteGasLimitsBatch(transactions, messenger)), + ...result, usedBatch: true, }; } @@ -74,6 +82,8 @@ export async function estimateQuoteGasLimits({ async function estimateQuoteGasLimitsBatch( transactions: QuoteGasTransaction[], messenger: TransactionPayControllerMessenger, + fallbackGas?: { estimate: number; max: number }, + fallbackOnSimulationFailure?: boolean, ): Promise<{ batchGasLimit?: QuoteGasLimit; gasLimits: QuoteGasLimit[]; @@ -125,6 +135,41 @@ async function estimateQuoteGasLimitsBatch( 0, ); const is7702 = bufferedGasLimits.length === 1; + + // If the batch returned a combined 7702 gas limit but the account cannot + // sign EIP-7702 authorizations (e.g. hardware wallet), re-estimate each + // transaction individually so callers never see is7702=true. + const supports7702 = accountSupports7702( + messenger, + firstTransaction.from, + ); + + if (is7702 && !supports7702) { + const individualResults = await Promise.all( + transactions.map((transaction) => + estimateQuoteGasLimitSingle({ + fallbackGas, + fallbackOnSimulationFailure: fallbackOnSimulationFailure ?? false, + messenger, + transaction, + }), + ), + ); + + return { + gasLimits: individualResults.map((res) => res.gasLimits[0]), + is7702: false, + totalGasEstimate: individualResults.reduce( + (acc, res) => acc + res.totalGasEstimate, + 0, + ), + totalGasLimit: individualResults.reduce( + (acc, res) => acc + res.totalGasLimit, + 0, + ), + }; + } + const batchGasLimit = is7702 ? bufferedGasLimits[0] : undefined; return { diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 04d55c153e7..7a16c0bfe79 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -480,6 +480,7 @@ describe('Quotes Utils', () => { await run(); expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, messenger, requests: [ { @@ -499,6 +500,31 @@ describe('Quotes Utils', () => { }); }); + it('gets quotes with no minimum if allowUnderMinimum is true', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + tokens: [ + { + ...TRANSACTION_DATA_MOCK.tokens?.[0], + allowUnderMinimum: true, + } as TransactionPayRequiredToken, + ], + }, + }); + + expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, + messenger, + requests: [ + expect.objectContaining({ + targetAmountMinimum: '0', + }), + ], + transaction: TRANSACTION_META_MOCK, + }); + }); + it('resolves strategies via getStrategiesByName', async () => { await run(); @@ -522,6 +548,7 @@ describe('Quotes Utils', () => { }); expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, messenger, requests: [ expect.objectContaining({ @@ -560,6 +587,20 @@ describe('Quotes Utils', () => { ); }); + it('always passes batch transactions regardless of 7702 support', async () => { + await run(); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject( + expect.objectContaining({ + batchTransactions: [BATCH_TRANSACTION_MOCK], + batchTransactionsOptions: {}, + }), + ); + }); + it('updates metrics in metadata', async () => { await run(); @@ -872,24 +913,27 @@ describe('Quotes Utils', () => { transactionData: POST_QUOTE_TRANSACTION_DATA, }); - expect(getQuotesMock).toHaveBeenCalledWith({ - messenger, - requests: [ - { - from: TRANSACTION_META_MOCK.txParams.from, - isMaxAmount: false, - isPostQuote: true, - sourceBalanceRaw: SOURCE_TOKEN_MOCK.balanceRaw, - sourceChainId: SOURCE_TOKEN_MOCK.chainId, - sourceTokenAddress: SOURCE_TOKEN_MOCK.address, - sourceTokenAmount: '10000000', - targetAmountMinimum: '0', - targetChainId: DESTINATION_TOKEN_MOCK.chainId, - targetTokenAddress: DESTINATION_TOKEN_MOCK.address, - }, - ], - transaction: TRANSACTION_META_MOCK, - }); + expect(getQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: true, + messenger, + requests: [ + { + from: TRANSACTION_META_MOCK.txParams.from, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: SOURCE_TOKEN_MOCK.balanceRaw, + sourceChainId: SOURCE_TOKEN_MOCK.chainId, + sourceTokenAddress: SOURCE_TOKEN_MOCK.address, + sourceTokenAmount: '10000000', + targetAmountMinimum: '0', + targetChainId: DESTINATION_TOKEN_MOCK.chainId, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + }, + ], + transaction: TRANSACTION_META_MOCK, + }), + ); }); it('includes refundTo in post-quote request when set in transaction data', async () => { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index e90c6dfe8a4..f2f54ef38d5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -17,6 +17,7 @@ import type { TransactionPaymentToken, UpdateTransactionDataCallback, } from '../types'; +import { accountSupports7702 } from './7702'; import { getStrategiesByName, getStrategyByName } from './strategy'; import { computeTokenAmounts, @@ -104,9 +105,12 @@ export async function updateQuotes( transactionId, }); + const supports7702 = accountSupports7702(messenger, from); + const { batchTransactions, quotes } = await getQuotes( transaction, requests, + supports7702, getStrategies, messenger, transactionData.fiatPayment?.selectedPaymentMethodId, @@ -164,7 +168,7 @@ function syncTransaction({ totals, transactionId, }: { - batchTransactions: BatchTransaction[]; + batchTransactions: BatchTransaction[] | undefined; isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; paymentToken: TransactionPaymentToken | undefined; @@ -183,7 +187,7 @@ function syncTransaction({ }, (tx: TransactionMeta) => { tx.batchTransactions = batchTransactions; - tx.batchTransactionsOptions = {}; + tx.batchTransactionsOptions = batchTransactions?.length ? {} : undefined; tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, @@ -469,6 +473,7 @@ async function refreshPaymentTokenBalance({ * * @param transaction - Transaction metadata. * @param requests - Quote requests. + * @param accountSupports7702 - Whether the account supports EIP-7702. * @param getStrategies - Callback to get ordered strategy names for a transaction. * @param messenger - Controller messenger. * @param fiatPaymentMethod - Selected fiat payment method ID, if applicable. @@ -477,6 +482,7 @@ async function refreshPaymentTokenBalance({ async function getQuotes( transaction: TransactionMeta, requests: QuoteRequest[], + accountSupports7702: boolean, getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[], messenger: TransactionPayControllerMessenger, fiatPaymentMethod?: string, @@ -503,6 +509,7 @@ async function getQuotes( } const request = { + accountSupports7702, fiatPaymentMethod, messenger, requests,