diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index fdbfd537fd5..7a296728aa9 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -11,16 +11,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Expose `TransactionController:wipeTransactions` method through `TransactionController` messenger ([#8592](https://github.com/MetaMask/core/pull/8592)) +### Changed + +- `estimateGasBatch` now skips the EIP-7702 path when the account's keyring does not support it, falling back to per-transaction gas estimation ([#8388](https://github.com/MetaMask/core/pull/8388)) +- `doesAccountSupportEIP7702` now returns `false` instead of `true` when the account is not found in any keyring ([#8388](https://github.com/MetaMask/core/pull/8388)) + ## [64.4.0] ### Changed - Snapshot `txParamsOriginal` in `updateEditableParams` when `containerTypes` are first applied ([#8546](https://github.com/MetaMask/core/pull/8546)) - Add `requiresAuthorizationList` to `TransactionController:estimateGasBatch` results when EIP-7702 batch gas estimation requires a first-time account upgrade ([#8577](https://github.com/MetaMask/core/pull/8577)) +- **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 ### Fixed - Gas estimation for EIP-7702 transactions now supports any upgrade-with-data transaction, not only self-targeted ones ([#8467](https://github.com/MetaMask/core/pull/8467)) +- Fix batch transaction signing so each transaction is signed sequentially, preventing remaining hardware wallet prompts from appearing after a rejection ([#8388](https://github.com/MetaMask/core/pull/8388)) ## [64.3.0] diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 05b164a58fb..646842e68f2 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, @@ -492,6 +495,7 @@ export type AllowedActions = | AccountsControllerGetSelectedAccountAction | AccountsControllerGetStateAction | ApprovalControllerAddRequestAction + | KeyringControllerGetStateAction | KeyringControllerSignEip7702AuthorizationAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts index 870d9b46cab..e6527ffd736 100644 --- a/packages/transaction-controller/src/utils/batch.test.ts +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -32,6 +32,7 @@ import { } from './batch'; import { ERROR_MESSGE_PUBLIC_KEY, + doesAccountSupportEIP7702, doesChainSupportEIP7702, generateEIP7702BatchTransaction, isAccountUpgradedToEIP7702, @@ -68,9 +69,34 @@ const GAS_TOTAL_MOCK = '0x100000'; const VALUE_MOCK = '0x1234'; const MAX_FEE_PER_GAS_MOCK = '0x2'; const MAX_PRIORITY_FEE_PER_GAS_MOCK = '0x1'; +type StateChangeEntry = { + handler: (value: unknown) => void; + selector?: (state: TransactionControllerState) => unknown; +}; + +const stateChangeEntries: StateChangeEntry[] = []; + const MESSENGER_MOCK = { call: jest.fn(), + subscribe: jest.fn( + ( + _event: string, + handler: (value: unknown) => void, + selector?: (state: TransactionControllerState) => unknown, + ) => { + stateChangeEntries.push({ handler, selector }); + }, + ), + unsubscribe: jest.fn((_event: string, handler: (value: unknown) => void) => { + const index = stateChangeEntries.findIndex( + (entry) => entry.handler === handler, + ); + if (index !== -1) { + stateChangeEntries.splice(index, 1); + } + }), } as unknown as TransactionControllerMessenger; + const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; const PUBLIC_KEY_MOCK = '0x112233'; const BATCH_ID_MOCK = '0x654321'; @@ -275,6 +301,7 @@ function createGasFeeFlowMock(): jest.Mocked { } describe('Batch Utils', () => { + const doesAccountSupportEIP7702Mock = jest.mocked(doesAccountSupportEIP7702); const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702); const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); const validateBatchRequestMock = jest.mocked(validateBatchRequest); @@ -334,12 +361,48 @@ describe('Batch Utils', () => { const estimateGasMock = jest.fn(); + const SIGNATURES_BY_INDEX = [ + TRANSACTION_SIGNATURE_MOCK, + TRANSACTION_SIGNATURE_2_MOCK, + ]; + + function mockAddTransactionWithAutoSign( + ...returnValues: { + transactionMeta: TransactionMeta; + result: Promise; + }[] + ): void { + let callIndex = 0; + for (const returnValue of returnValues) { + const index = callIndex; + addTransactionMock.mockImplementationOnce((_params, options) => { + const signature = + SIGNATURES_BY_INDEX[index] ?? SIGNATURES_BY_INDEX[0]; + options + .publishHook?.(returnValue.transactionMeta, signature) + .catch(() => { + // Intentionally empty + }); + return Promise.resolve(returnValue); + }); + callIndex += 1; + } + } + beforeEach(() => { jest.resetAllMocks(); + stateChangeEntries.length = 0; mockMessengerNetworkCalls(); addTransactionMock = jest.fn(); - getTransactionMock = jest.fn(); + getTransactionMock = jest.fn().mockImplementation( + (id: string) => + ({ + id, + status: TransactionStatus.signed, + txParams: {}, + }) as unknown as TransactionMeta, + ); updateTransactionMock = jest.fn(); publishTransactionMock = jest.fn(); getPendingTransactionTrackerMock = jest.fn(); @@ -381,6 +444,7 @@ describe('Batch Utils', () => { gasLimits: [GAS_TOTAL_MOCK], }); + doesAccountSupportEIP7702Mock.mockReturnValue(true); doesChainSupportEIP7702Mock.mockReturnValue(true); signTransactionMock.mockResolvedValue(TRANSACTION_SIGNATURE_3_MOCK); @@ -475,21 +539,23 @@ describe('Batch Utils', () => { state: 'approved', }); - addTransactionMock - .mockResolvedValueOnce({ + mockAddTransactionWithAutoSign( + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_MOCK, }, result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ + }, + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_2_MOCK, }, result: Promise.resolve(''), - }); + }, + ); + addTransactionBatch({ ...request, publishBatchHook, @@ -952,6 +1018,16 @@ describe('Batch Utils', () => { ); }); + it('skips 7702 path when account does not support EIP-7702', async () => { + doesAccountSupportEIP7702Mock.mockReturnValue(false); + + await expect(addTransactionBatch(request)).rejects.toThrow( + rpcErrors.internal("Can't process batch"), + ); + + expect(isAccountUpgradedToEIP7702Mock).not.toHaveBeenCalled(); + }); + it('throws if no public key', async () => { await expect( addTransactionBatch({ ...request, publicKeyEIP7702: undefined }), @@ -1285,21 +1361,22 @@ describe('Batch Utils', () => { it('returns provided batch ID', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); - addTransactionMock - .mockResolvedValueOnce({ + mockAddTransactionWithAutoSign( + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_MOCK, }, result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ + }, + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_2_MOCK, }, result: Promise.resolve(''), - }); + }, + ); publishBatchHook.mockResolvedValue({ results: [ @@ -1324,26 +1401,6 @@ describe('Batch Utils', () => { await flushPromises(); - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, - ); - - publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, - ).catch(() => { - // Intentionally empty - }); - - publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); - - await flushPromises(); - const result = await resultPromise; expect(result.batchId).toBe(BATCH_ID_CUSTOM_MOCK); @@ -1358,10 +1415,19 @@ describe('Batch Utils', () => { it('adds each nested transaction', async () => { const publishBatchHook = jest.fn(); - addTransactionMock.mockResolvedValueOnce({ - transactionMeta: TRANSACTION_META_MOCK, - result: Promise.resolve(''), - }); + mockAddTransactionWithAutoSign( + { + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }, + { + transactionMeta: { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_2_MOCK, + }, + result: Promise.resolve(''), + }, + ); addTransactionBatch({ ...request, @@ -1439,21 +1505,22 @@ describe('Batch Utils', () => { it('calls publish batch hook', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); - addTransactionMock - .mockResolvedValueOnce({ + mockAddTransactionWithAutoSign( + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_MOCK, }, result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ + }, + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_2_MOCK, }, result: Promise.resolve(''), - }); + }, + ); publishBatchHook.mockResolvedValue({ results: [ @@ -1476,26 +1543,6 @@ describe('Batch Utils', () => { await flushPromises(); - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, - ); - - publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, - ).catch(() => { - // Intentionally empty - }); - - publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); - - await flushPromises(); - expect(publishBatchHook).toHaveBeenCalledTimes(1); expect(publishBatchHook).toHaveBeenCalledWith( PUBLISH_BATCH_HOOK_PARAMS, @@ -1504,22 +1551,40 @@ describe('Batch Utils', () => { it('resolves individual publish hooks with transaction hashes from publish batch hook', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); + const publishHookPromises: (Promise | undefined)[] = []; - addTransactionMock - .mockResolvedValueOnce({ - transactionMeta: { - ...TRANSACTION_META_MOCK, - id: TRANSACTION_ID_MOCK, - }, - result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ - transactionMeta: { - ...TRANSACTION_META_MOCK, - id: TRANSACTION_ID_2_MOCK, - }, - result: Promise.resolve(''), + const txMeta1 = { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_MOCK, + }; + const txMeta2 = { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_2_MOCK, + }; + + const metas = [txMeta1, txMeta2] as const; + const signatures = [ + TRANSACTION_SIGNATURE_MOCK, + TRANSACTION_SIGNATURE_2_MOCK, + ] as const; + + for (let idx = 0; idx < metas.length; idx += 1) { + const capturedIdx = idx; + addTransactionMock.mockImplementationOnce((_params, options) => { + const hookPromise = options.publishHook?.( + metas[capturedIdx] as TransactionMeta, + signatures[capturedIdx], + ); + publishHookPromises[capturedIdx] = hookPromise; + hookPromise?.catch(() => { + // Intentionally empty + }); + return Promise.resolve({ + transactionMeta: metas[capturedIdx] as TransactionMeta, + result: Promise.resolve(''), + }); }); + } publishBatchHook.mockResolvedValue({ results: [ @@ -1542,31 +1607,11 @@ describe('Batch Utils', () => { await flushPromises(); - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, - ); - - const publishHookPromise1 = publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, - ).catch(() => { - // Intentionally empty - }); - - const publishHookPromise2 = publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); - - await flushPromises(); - - expect(await publishHookPromise1).toStrictEqual({ + expect(await publishHookPromises[0]).toStrictEqual({ transactionHash: TRANSACTION_HASH_MOCK, }); - expect(await publishHookPromise2).toStrictEqual({ + expect(await publishHookPromises[1]).toStrictEqual({ transactionHash: TRANSACTION_HASH_2_MOCK, }); }); @@ -1795,6 +1840,13 @@ describe('Batch Utils', () => { }); getTransactionMock + .mockReturnValueOnce({ + id: TRANSACTION_ID_MOCK, + status: TransactionStatus.signed, + txParams: { + nonce: NONCE_MOCK, + }, + } as unknown as TransactionMeta) .mockReturnValueOnce({ txParams: { nonce: NONCE_MOCK, @@ -1871,21 +1923,22 @@ describe('Batch Utils', () => { }); it('throws if publish batch hook does not return result', async () => { - addTransactionMock - .mockResolvedValueOnce({ + mockAddTransactionWithAutoSign( + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_MOCK, }, result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ + }, + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_2_MOCK, }, result: Promise.resolve(''), - }); + }, + ); const publishBatchHookMock = jest.fn().mockResolvedValue(undefined); sequentialPublishBatchHookMock.mockReturnValue({ @@ -1904,26 +1957,6 @@ describe('Batch Utils', () => { await flushPromises(); - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, - ); - - publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, - ).catch(() => { - // Intentionally empty - }); - - publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); - - await flushPromises(); - await expect(resultPromise).rejects.toThrow( 'Publish batch hook did not return a result', ); @@ -1931,22 +1964,40 @@ describe('Batch Utils', () => { it('rejects individual publish hooks if batch hook throws', async () => { const publishBatchHook: jest.MockedFn = jest.fn(); + const publishHookPromises: (Promise | undefined)[] = []; - addTransactionMock - .mockResolvedValueOnce({ - transactionMeta: { - ...TRANSACTION_META_MOCK, - id: TRANSACTION_ID_MOCK, - }, - result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ - transactionMeta: { - ...TRANSACTION_META_MOCK, - id: TRANSACTION_ID_2_MOCK, - }, - result: Promise.resolve(''), + const txMeta1 = { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_MOCK, + }; + const txMeta2 = { + ...TRANSACTION_META_MOCK, + id: TRANSACTION_ID_2_MOCK, + }; + + const metas = [txMeta1, txMeta2] as const; + const signatures = [ + TRANSACTION_SIGNATURE_MOCK, + TRANSACTION_SIGNATURE_2_MOCK, + ] as const; + + for (let idx = 0; idx < metas.length; idx += 1) { + const capturedIdx = idx; + addTransactionMock.mockImplementationOnce((_params, options) => { + const hookPromise = options.publishHook?.( + metas[capturedIdx] as TransactionMeta, + signatures[capturedIdx], + ); + publishHookPromises[capturedIdx] = hookPromise; + hookPromise?.catch(() => { + // Intentionally empty + }); + return Promise.resolve({ + transactionMeta: metas[capturedIdx] as TransactionMeta, + result: Promise.resolve(''), + }); }); + } publishBatchHook.mockImplementationOnce(() => { throw new Error(ERROR_MESSAGE_MOCK); @@ -1966,32 +2017,12 @@ describe('Batch Utils', () => { await flushPromises(); - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, + await expect(publishHookPromises[0]).rejects.toThrow( + ERROR_MESSAGE_MOCK, ); - - const publishHookPromise1 = publishHooks[0]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_MOCK, + await expect(publishHookPromises[1]).rejects.toThrow( + ERROR_MESSAGE_MOCK, ); - - publishHookPromise1?.catch(() => { - // Intentionally empty - }); - - const publishHookPromise2 = publishHooks[1]?.( - TRANSACTION_META_MOCK, - TRANSACTION_SIGNATURE_2_MOCK, - ); - - publishHookPromise2?.catch(() => { - // Intentionally empty - }); - - await flushPromises(); - - await expect(publishHookPromise1).rejects.toThrow(ERROR_MESSAGE_MOCK); - await expect(publishHookPromise2).rejects.toThrow(ERROR_MESSAGE_MOCK); }); it('rejects individual publish hooks if add transaction throws', async () => { @@ -2048,21 +2079,22 @@ describe('Batch Utils', () => { sequentialPublishBatchHook = jest.fn(); - addTransactionMock - .mockResolvedValueOnce({ + mockAddTransactionWithAutoSign( + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_MOCK, }, result: Promise.resolve(''), - }) - .mockResolvedValueOnce({ + }, + { transactionMeta: { ...TRANSACTION_META_MOCK, id: TRANSACTION_ID_2_MOCK, }, result: Promise.resolve(''), - }); + }, + ); }); const setupSequentialPublishBatchHookMock = ( @@ -2074,21 +2106,6 @@ describe('Batch Utils', () => { }; const executePublishHooks = async (): Promise => { - const publishHooks = addTransactionMock.mock.calls.map( - ([, options]) => options.publishHook, - ); - - for (const [index, publishHook] of publishHooks.entries()) { - publishHook?.( - TRANSACTION_META_MOCK, - index === 0 - ? TRANSACTION_SIGNATURE_MOCK - : TRANSACTION_SIGNATURE_2_MOCK, - ).catch(() => { - // Intentionally empty - }); - } - await flushPromises(); }; diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 708f8bef792..625fc6108c0 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) { @@ -452,6 +458,72 @@ async function addTransactionBatchWith7702( }; } +/** + * Wait for a transaction to reach one of the specified statuses. + * Checks the current state first to avoid race conditions, then subscribes to + * state changes for ongoing updates. + * + * @param transactionId - The ID of the transaction to monitor. + * @param targetStatuses - The statuses that indicate success. + * @param request - The batch request containing messenger and getTransaction. + */ +function waitForTransactionStatus( + transactionId: string, + targetStatuses: TransactionStatus[], + request: Pick, +): Promise { + const { getTransaction, messenger } = request; + const failureStatuses = [ + TransactionStatus.failed, + TransactionStatus.rejected, + ]; + + return new Promise((resolve, reject) => { + const checkStatus = ( + tx?: TransactionMeta, + unsubscribe?: () => void, + ): boolean => { + if (targetStatuses.includes(tx?.status as TransactionStatus)) { + unsubscribe?.(); + resolve(); + return true; + } + + if (failureStatuses.includes(tx?.status as TransactionStatus)) { + unsubscribe?.(); + reject( + new Error( + tx?.error?.message ?? `Transaction ${transactionId} ${tx?.status}`, + ), + ); + return true; + } + + return false; + }; + + const initialTx = getTransaction(transactionId); + + if (checkStatus(initialTx)) { + return; + } + + const handler = (tx?: TransactionMeta): void => { + const unsubscribe = (): void => + messenger.unsubscribe('TransactionController:stateChange', handler); + + checkStatus(tx, unsubscribe); + }; + + messenger.subscribe( + 'TransactionController:stateChange', // eslint-disable-line no-restricted-syntax + handler, + (state: TransactionControllerState) => + state.transactions.find((tx) => tx.id === transactionId), + ); + }); +} + /** * Process a batch transaction using a publish batch hook. * @@ -555,6 +627,16 @@ async function addTransactionBatchWithHook( hookTransactions.push(hookTransaction); index += 1; + + await waitForTransactionStatus( + String(hookTransaction.id), + [ + TransactionStatus.signed, + TransactionStatus.submitted, + TransactionStatus.confirmed, + ], + request, + ); } const { signedTransactions } = await collectHook.ready(); @@ -678,7 +760,10 @@ async function processTransactionWithHook( } publishHook(transactionMeta, signedTransaction) - .then(onPublish) + .then((hookResult) => { + onPublish?.(hookResult); + return undefined; + }) .catch(() => { // Intentionally empty }); diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 2dcce540fde..b23f9ac2179 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -8,13 +8,17 @@ import type { NetworkClientId } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import { remove0x } from '@metamask/utils'; -import type { KeyringControllerSignEip7702AuthorizationAction } from '../../../keyring-controller/src'; +import type { + KeyringControllerGetStateAction, + KeyringControllerSignEip7702AuthorizationAction, +} from '../../../keyring-controller/src'; import type { TransactionControllerMessenger } from '../TransactionController'; import { TransactionStatus } from '../types'; import type { AuthorizationList } from '../types'; import type { TransactionMeta } from '../types'; import { DELEGATION_PREFIX, + doesAccountSupportEIP7702, doesChainSupportEIP7702, generateEIP7702BatchTransaction, getDelegationAddress, @@ -95,6 +99,10 @@ describe('EIP-7702 Utils', () => { getEIP7702ContractAddresses, ); + let getKeyringStateMock: jest.MockedFn< + KeyringControllerGetStateAction['handler'] + >; + let signAuthorizationMock: jest.MockedFn< KeyringControllerSignEip7702AuthorizationAction['handler'] >; @@ -104,19 +112,29 @@ describe('EIP-7702 Utils', () => { rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); + getKeyringStateMock = jest.fn().mockReturnValue({ + isUnlocked: true, + keyrings: [], + }); + signAuthorizationMock = jest .fn() .mockResolvedValue(AUTHORIZATION_SIGNATURE_MOCK); const keyringControllerMessenger = new Messenger< 'KeyringController', - KeyringControllerSignEip7702AuthorizationAction, + | KeyringControllerGetStateAction + | KeyringControllerSignEip7702AuthorizationAction, never, typeof rootMessenger >({ namespace: 'KeyringController', parent: rootMessenger, }); + keyringControllerMessenger.registerActionHandler( + 'KeyringController:getState', + getKeyringStateMock, + ); keyringControllerMessenger.registerActionHandler( 'KeyringController:signEip7702Authorization', signAuthorizationMock, @@ -128,7 +146,10 @@ describe('EIP-7702 Utils', () => { }); rootMessenger.delegate({ messenger: controllerMessenger, - actions: ['KeyringController:signEip7702Authorization'], + actions: [ + 'KeyringController:getState', + 'KeyringController:signEip7702Authorization', + ], }); }); @@ -270,6 +291,93 @@ describe('EIP-7702 Utils', () => { }); }); + describe('doesAccountSupportEIP7702', () => { + it('returns true for HD Key Tree keyring', () => { + getKeyringStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: [ADDRESS_MOCK], + metadata: { id: 'hd', name: 'HD Key Tree' }, + }, + ], + }); + + expect(doesAccountSupportEIP7702(controllerMessenger, ADDRESS_MOCK)).toBe( + true, + ); + }); + + it('returns true for Simple Key Pair keyring', () => { + getKeyringStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'Simple Key Pair', + accounts: [ADDRESS_MOCK], + metadata: { id: 'simple', name: 'Simple Key Pair' }, + }, + ], + }); + + expect(doesAccountSupportEIP7702(controllerMessenger, ADDRESS_MOCK)).toBe( + true, + ); + }); + + it('returns false for unsupported keyring type', () => { + getKeyringStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'Ledger Hardware', + accounts: [ADDRESS_MOCK], + metadata: { id: 'ledger', name: 'Ledger Hardware' }, + }, + ], + }); + + expect(doesAccountSupportEIP7702(controllerMessenger, ADDRESS_MOCK)).toBe( + false, + ); + }); + + it('returns false when account is not found in any keyring', () => { + getKeyringStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'Ledger Hardware', + accounts: [ADDRESS_2_MOCK], + metadata: { id: 'ledger', name: 'Ledger Hardware' }, + }, + ], + }); + + expect(doesAccountSupportEIP7702(controllerMessenger, ADDRESS_MOCK)).toBe( + false, + ); + }); + + it('matches account addresses case-insensitively', () => { + getKeyringStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: [ADDRESS_MOCK.toUpperCase()], + metadata: { id: 'hd', name: 'HD Key Tree' }, + }, + ], + }); + + expect(doesAccountSupportEIP7702(controllerMessenger, ADDRESS_MOCK)).toBe( + true, + ); + }); + }); + describe('isAccountUpgradedToEIP7702', () => { it('returns true if delegation matches feature flag', async () => { getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_2_MOCK]); diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 5410997e04a..36e5eb1ce1d 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -40,6 +40,33 @@ 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. + * Returns `false` 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'); + + return keyrings.some( + (k: { type: string; accounts: string[] }) => + KEYRING_TYPES_SUPPORTING_7702.includes(k.type) && + k.accounts.some((a: string) => a.toLowerCase() === account.toLowerCase()), + ); +} + /** * Determine if a chain supports EIP-7702 using LaunchDarkly feature flag. * diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 184441bd039..b453dd0976c 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -77,6 +77,17 @@ const MESSENGER_MOCK = { }; } + if (action === 'KeyringController:getState') { + return { + keyrings: [ + { + type: 'HD Key Tree', + accounts: [FROM_MOCK], + }, + ], + }; + } + return { remoteFeatureFlags: {}, }; @@ -94,6 +105,17 @@ function mockMessengerCall(): void { }; } + if (action === 'KeyringController:getState') { + return { + keyrings: [ + { + type: 'HD Key Tree', + accounts: [FROM_MOCK], + }, + ], + }; + } + return { remoteFeatureFlags: {}, }; @@ -1335,6 +1357,56 @@ describe('gas', () => { expect(result.gasLimits[1]).toBe(500000); expect(result.totalGasLimit).toBe(600000); }); + + it('skips EIP-7702 path when account does not support it', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + jest.mocked(MESSENGER_MOCK.call).mockImplementation((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { + configuration: { chainId: CHAIN_ID_MOCK }, + provider: {}, + }; + } + + if (action === 'KeyringController:getState') { + return { + keyrings: [ + { + type: 'Ledger Hardware', + accounts: [FROM_MOCK], + }, + ], + }; + } + + return { + remoteFeatureFlags: {}, + }; + }); + + simulateTransactionsMock.mockResolvedValue( + SIMULATED_TRANSACTIONS_RESPONSE_MOCK, + ); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_MOCK, + }); + + expect(generateEIP7702BatchTransactionMock).not.toHaveBeenCalled(); + expect(result.gasLimits).toHaveLength(2); + }); }); describe('simulateGasBatch', () => { diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index dd4a952a5ff..1650bcb26f7 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -23,7 +23,11 @@ import type { TransactionMeta, TransactionParams, } from '../types'; -import { DELEGATION_PREFIX, generateEIP7702BatchTransaction } from './eip7702'; +import { + DELEGATION_PREFIX, + doesAccountSupportEIP7702, + generateEIP7702BatchTransaction, +} from './eip7702'; import { getGasEstimateBuffer, getGasEstimateFallback } from './feature-flags'; import { getChainId, rpcRequest } from './provider'; @@ -216,7 +220,12 @@ export async function estimateGasBatch({ chainIds: [chainId], }); - const chainResult = is7702Result.find((result) => result.chainId === chainId); + const accountSupports7702 = doesAccountSupportEIP7702(messenger, from); + + const chainResult = accountSupports7702 + ? is7702Result.find((result) => result.chainId === chainId) + : undefined; + const isUpgradeRequired = Boolean(chainResult && !chainResult.isSupported); if (isUpgradeRequired && !chainResult?.upgradeContractAddress) { diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 246fdd355a1..533547c6610 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fall back from Across to later pay strategies when Across quotes would require a first-time EIP-7702 authorization list ([#8577](https://github.com/MetaMask/core/pull/8577)) +- **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)) + - The `TransactionPayControllerMessenger` now requires `KeyringController:getState` permission. ## [19.3.0] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 1fd9d21d1bd..eba792cae24 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..cc114686191 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 false when keyring not found', async () => { + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [], + }); + + await runHook(); + + expect(executeMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: false, + }), + ); + }); + + 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 0af7ac0fdfe..53d281b2f39 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, @@ -1206,6 +1241,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1255,7 +1291,7 @@ describe('Across Quotes', () => { ); }); - it('omits the authorization-list flag when a combined batch does not require one', async () => { + it('omits requiresAuthorizationList when batch estimation does not include it', async () => { estimateGasBatchMock.mockResolvedValue({ totalGasLimit: 51000, gasLimits: [51000], @@ -1276,20 +1312,47 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, }); - expect(result[0].original.metamask).toStrictEqual({ - gasLimits: [ - { - estimate: 51000, - max: 51000, - }, - ], - is7702: true, + expect(result[0].original.metamask.is7702).toBe(true); + expect( + result[0].original.metamask.requiresAuthorizationList, + ).toBeUndefined(); + }); + + it('returns per-transaction gas limits when account does not support 7702', async () => { + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 42000, + gasLimits: [21000, 21000], + }); + + 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); }); it('throws when the shared gas estimator marks a quote as 7702 without a combined gas limit', async () => { @@ -1321,6 +1384,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1353,6 +1417,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1377,6 +1442,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1409,6 +1475,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1476,6 +1543,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1510,6 +1578,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1551,6 +1620,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1573,6 +1643,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1598,6 +1669,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1617,6 +1689,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1643,6 +1716,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [request], transaction: TRANSACTION_META_MOCK, @@ -1657,6 +1731,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1677,6 +1752,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1701,6 +1777,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1723,6 +1800,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1743,6 +1821,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1763,6 +1842,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1780,6 +1860,7 @@ describe('Across Quotes', () => { await expect( getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1805,6 +1886,7 @@ describe('Across Quotes', () => { } as Response); await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -1830,6 +1912,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 12b6fabcd98..b6247e09966 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, @@ -272,6 +275,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -301,6 +305,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingBatchGasQuote], transaction: TRANSACTION_META_MOCK, @@ -324,6 +329,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -354,6 +360,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -384,6 +391,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: { @@ -426,6 +434,7 @@ describe('Across Submit', () => { ]); await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -455,6 +464,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -509,6 +519,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -564,6 +575,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -626,6 +638,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -735,6 +748,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -758,6 +772,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -784,6 +799,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -822,6 +838,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -840,6 +857,7 @@ describe('Across Submit', () => { } as Response); const result = await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -870,6 +888,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -904,6 +923,7 @@ describe('Across Submit', () => { } as Response); const resultPromise = submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [buildDepositQuote()], transaction: TRANSACTION_META_MOCK, @@ -936,6 +956,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -965,6 +986,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingSwapGasQuote], transaction: TRANSACTION_META_MOCK, @@ -988,6 +1010,7 @@ describe('Across Submit', () => { await expect( submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [missingApprovalGasQuote], transaction: TRANSACTION_META_MOCK, @@ -1014,6 +1037,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [noApprovalQuote], transaction: TRANSACTION_META_MOCK, @@ -1047,6 +1071,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [decimalGasQuote], transaction: TRANSACTION_META_MOCK, @@ -1081,6 +1106,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quoteWithApproval], transaction: TRANSACTION_META_MOCK, @@ -1112,6 +1138,7 @@ describe('Across Submit', () => { } as TransactionPayQuote; await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quoteWithoutValue], transaction: TRANSACTION_META_MOCK, @@ -1180,6 +1207,7 @@ describe('Across Submit', () => { }); await submitAcrossQuotes({ + accountSupports7702: true, messenger, quotes: [quote1, quote2], transaction: TRANSACTION_META_MOCK, @@ -1206,6 +1234,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..3ea0a32fb2b 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 @@ -186,12 +186,24 @@ describe('Relay Quotes Utils', () => { findNetworkClientIdByChainIdMock, getDelegationTransactionMock, getGasFeeTokensMock, + getKeyringControllerStateMock, getRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0x1234567890123456789012345678901234567891'], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + getTokenFiatRateMock.mockReturnValue({ usdRate: '2.0', fiatRate: '4.0', @@ -231,6 +243,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -249,6 +262,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -287,6 +301,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -308,6 +323,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 +363,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -346,6 +384,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 +405,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -412,6 +452,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -436,6 +477,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -455,6 +497,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -478,6 +521,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -530,6 +574,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -554,6 +599,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -582,6 +628,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: { @@ -614,6 +661,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -638,6 +686,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -679,6 +728,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -696,6 +746,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -716,6 +767,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -745,6 +797,7 @@ describe('Relay Quotes Utils', () => { const refundTo = '0xsafe000000000000000000000000000000000001' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -770,6 +823,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -806,6 +860,7 @@ describe('Relay Quotes Utils', () => { } as TransactionMeta; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -830,6 +885,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -865,6 +921,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -926,6 +983,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -985,6 +1043,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1019,6 +1078,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1058,6 +1118,7 @@ describe('Relay Quotes Utils', () => { estimateGasMock.mockRejectedValue(new Error('Estimation failed')); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1100,6 +1161,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1132,6 +1194,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1165,6 +1228,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1210,6 +1274,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1255,6 +1320,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1290,6 +1356,7 @@ describe('Relay Quotes Utils', () => { const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1324,6 +1391,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1358,6 +1426,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1379,6 +1448,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1403,6 +1473,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1436,6 +1507,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1459,6 +1531,7 @@ describe('Relay Quotes Utils', () => { getTokenBalanceMock.mockReturnValue('0'); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1502,6 +1575,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1547,6 +1621,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1573,6 +1648,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockRejectedValue(new Error('Simulation failed')); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1604,6 +1680,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1643,6 +1720,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -1665,6 +1743,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1679,6 +1758,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1700,6 +1780,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1721,6 +1802,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1738,6 +1820,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1768,6 +1851,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1798,6 +1882,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1815,6 +1900,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1833,6 +1919,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1863,6 +1950,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1919,6 +2007,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -1938,6 +2027,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 +2075,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 +2100,7 @@ describe('Relay Quotes Utils', () => { getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -2052,6 +2144,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 +2178,7 @@ describe('Relay Quotes Utils', () => { ]); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2117,6 +2211,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 +2246,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 +2277,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2217,6 +2314,7 @@ describe('Relay Quotes Utils', () => { isEIP7702ChainMock.mockReturnValue(true); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [lineaQuoteRequest], transaction: TRANSACTION_META_MOCK, @@ -2252,6 +2350,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2272,6 +2371,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2286,6 +2386,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2306,6 +2407,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2331,6 +2433,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2350,6 +2453,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2368,6 +2472,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2385,6 +2490,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [HL_REQUEST], transaction: TRANSACTION_META_MOCK, @@ -2404,6 +2510,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2421,6 +2528,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2451,6 +2559,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 +2577,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [ { @@ -2491,6 +2601,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2507,6 +2618,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2523,6 +2635,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2542,6 +2655,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [arbitrumToHyperliquidRequest], transaction: TRANSACTION_META_MOCK, @@ -2574,6 +2688,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [postQuoteRequest], transaction: TRANSACTION_META_MOCK, @@ -2605,6 +2720,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [polygonToHyperliquidRequest], transaction: TRANSACTION_META_MOCK, @@ -2633,6 +2749,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [polygonTargetRequest], transaction: TRANSACTION_META_MOCK, @@ -2663,6 +2780,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2694,6 +2812,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 +2839,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2751,6 +2871,7 @@ describe('Relay Quotes Utils', () => { }); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2799,6 +2920,7 @@ describe('Relay Quotes Utils', () => { }); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2832,6 +2954,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2847,6 +2970,7 @@ describe('Relay Quotes Utils', () => { } as never); const result = await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2868,6 +2992,7 @@ describe('Relay Quotes Utils', () => { } as never); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2891,6 +3016,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2913,6 +3039,7 @@ describe('Relay Quotes Utils', () => { await expect( getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2937,6 +3064,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -2971,6 +3099,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3006,6 +3135,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3041,6 +3171,7 @@ describe('Relay Quotes Utils', () => { getGasBufferMock.mockReturnValue(1.5); await getRelayQuotes({ + accountSupports7702: true, messenger, requests: [QUOTE_REQUEST_MOCK], transaction: TRANSACTION_META_MOCK, @@ -3075,6 +3206,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 3aeaa9de628..afc32821fff 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 @@ -102,6 +102,7 @@ const STATUS_RESPONSE_MOCK = { const SOURCE_AMOUNT_RAW_MOCK = '1000000'; const REQUEST_MOCK: PayStrategyExecuteRequest = { + accountSupports7702: true, quotes: [ { fees: { @@ -859,7 +860,6 @@ describe('Relay Submit Utils', () => { gasFeeToken: undefined, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: ORIGIN_METAMASK, - overwriteUpgrade: true, requireApproval: false, transactions: [ { @@ -968,7 +968,6 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ - disable7702: true, disableHook: false, disableSequential: false, gasLimit7702: undefined, @@ -1066,7 +1065,6 @@ describe('Relay Submit Utils', () => { expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ - disable7702: true, disableHook: false, disableSequential: false, gasLimit7702: undefined, 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 175668f3827..7c6171125b4 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 @@ -136,6 +141,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. */ @@ -438,6 +452,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; @@ -453,6 +470,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..00354371d23 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/7702.ts @@ -0,0 +1,27 @@ +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`. Returns `false` 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'); + + return keyrings.some( + (k) => + (KEYRING_TYPES_SUPPORTING_7702 as string[]).includes(k.type) && + k.accounts.some((a) => a.toLowerCase() === account.toLowerCase()), + ); +} diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 2d2030acff4..67aff2e6a8b 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -101,7 +101,8 @@ const BATCH_TRANSACTION_MOCK = { } as BatchTransaction; describe('Quotes Utils', () => { - const { messenger, getControllerStateMock } = getMessengerMock(); + const { messenger, getControllerStateMock, getKeyringControllerStateMock } = + getMessengerMock(); const updateTransactionDataMock = jest.fn(); const getStrategyByNameMock = jest.mocked(getStrategyByName); const getStrategiesByNameMock = jest.mocked(getStrategiesByName); @@ -137,6 +138,17 @@ describe('Quotes Utils', () => { jest.resetAllMocks(); jest.clearAllTimers(); + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: ['0xabc'], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + getStrategiesMock.mockReturnValue([TransactionPayStrategy.Test]); getStrategyByNameMock.mockReturnValue({ @@ -580,6 +592,7 @@ describe('Quotes Utils', () => { await run(); expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, messenger, requests: [ { @@ -622,6 +635,7 @@ describe('Quotes Utils', () => { }); expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, messenger, requests: [ expect.objectContaining({ @@ -660,6 +674,21 @@ describe('Quotes Utils', () => { ); }); + it('sets batchTransactionsOptions to empty object when there are no batch transactions', async () => { + getBatchTransactionsMock.mockResolvedValue([]); + await run(); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject( + expect.objectContaining({ + batchTransactions: [], + batchTransactionsOptions: {}, + }), + ); + }); + it('updates metrics in metadata', async () => { await run(); @@ -973,6 +1002,7 @@ describe('Quotes Utils', () => { }); expect(getQuotesMock).toHaveBeenCalledWith({ + accountSupports7702: true, messenger, requests: [ { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index ec0b7bf927a..b45853949bb 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 { checkStrategyQuoteSupport, checkStrategySupport, @@ -110,9 +111,12 @@ export async function updateQuotes( transactionId, }); + const supports7702 = accountSupports7702(messenger, from); + const { batchTransactions, quotes } = await getQuotes( transaction, requests, + supports7702, getStrategies, messenger, transactionData.fiatPayment?.selectedPaymentMethodId, @@ -475,6 +479,7 @@ async function refreshPaymentTokenBalance({ * * @param transaction - Transaction metadata. * @param requests - Quote requests. + * @param isAccountEIP7702Compatible - 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. @@ -483,6 +488,7 @@ async function refreshPaymentTokenBalance({ async function getQuotes( transaction: TransactionMeta, requests: QuoteRequest[], + isAccountEIP7702Compatible: boolean, getStrategies: (transaction: TransactionMeta) => TransactionPayStrategy[], messenger: TransactionPayControllerMessenger, fiatPaymentMethod?: string, @@ -509,6 +515,7 @@ async function getQuotes( } const request = { + accountSupports7702: isAccountEIP7702Compatible, fiatPaymentMethod, messenger, requests,