diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index e777270690..ef8d3d62b0 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -570,18 +570,12 @@ "packages/transaction-controller/src/TransactionController.test.ts": { "@typescript-eslint/no-unused-vars": 1, "import-x/namespace": 1, - "import-x/order": 4, - "jsdoc/tag-lines": 1, "promise/always-return": 2 }, "packages/transaction-controller/src/TransactionController.ts": { "jsdoc/check-tag-names": 35, "jsdoc/require-returns": 5 }, - "packages/transaction-controller/src/TransactionControllerIntegration.test.ts": { - "import-x/order": 4, - "jsdoc/tag-lines": 1 - }, "packages/transaction-controller/src/api/accounts-api.test.ts": { "import-x/order": 1, "jsdoc/tag-lines": 1 diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 1ce358fd59..e749501968 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support atomic batch transactions ([#5306](https://github.com/MetaMask/core/pull/5306)) + - Add methods: + - `addTransactionBatch` + - `isAtomicBatchSupported` + - Add `batch` to `TransactionType`. + - Add `nestedTransactions` to `TransactionMeta`. + - Add new types: + - `BatchTransactionParams` + - `TransactionBatchSingleRequest` + - `TransactionBatchRequest` + - `TransactionBatchResult` + - Add dependency on `@metamask/remote-feature-flag-controller:^1.4.0`. + +### Changed + +- **BREAKING:** Support atomic batch transactions ([#5306](https://github.com/MetaMask/core/pull/5306)) + - Require `AccountsController:getState` action permission in messenger. + - Require `RemoteFeatureFlagController:getState` action permission in messenger. + ## [45.1.0] ### Added diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 8072dc420d..3c7ff210cf 100644 --- a/packages/transaction-controller/jest.config.js +++ b/packages/transaction-controller/jest.config.js @@ -18,7 +18,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 91.76, - functions: 94.62, + functions: 93.72, lines: 96.83, statements: 96.82, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 74b65af67e..cb1b03c157 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -58,6 +58,7 @@ "@metamask/eth-query": "^4.0.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/nonce-tracker": "^6.0.0", + "@metamask/remote-feature-flag-controller": "^1.4.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.1.0", "async-mutex": "^0.5.0", @@ -96,7 +97,8 @@ "@metamask/approval-controller": "^7.0.0", "@metamask/eth-block-tracker": ">=9", "@metamask/gas-fee-controller": "^22.0.0", - "@metamask/network-controller": "^22.0.0" + "@metamask/network-controller": "^22.0.0", + "@metamask/remote-feature-flag-controller": "^1.3.0" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index d788cf471d..a0386500e8 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -33,13 +33,6 @@ import { createDeferredPromise } from '@metamask/utils'; import assert from 'assert'; import * as uuidModule from 'uuid'; -import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; -import { FakeProvider } from '../../../tests/fake-provider'; -import { flushPromises } from '../../../tests/helpers'; -import { - buildCustomNetworkClientConfiguration, - buildMockGetNetworkClientById, -} from '../../network-controller/tests/helpers'; import { getAccountAddressRelationship } from './api/accounts-api'; import { CHAIN_IDS } from './constants'; import { DefaultGasFeeFlow } from './gas-flows/DefaultGasFeeFlow'; @@ -79,6 +72,7 @@ import { TransactionType, WalletDevice, } from './types'; +import { addTransactionBatch } from './utils/batch'; import { addGasBuffer, estimateGas, updateGas } from './utils/gas'; import { updateGasFees } from './utils/gas-fees'; import { getGasFeeFlow } from './utils/gas-flow'; @@ -92,6 +86,13 @@ import { updatePostTransactionBalance, updateSwapsTransaction, } from './utils/swaps'; +import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; +import { FakeProvider } from '../../../tests/fake-provider'; +import { flushPromises } from '../../../tests/helpers'; +import { + buildCustomNetworkClientConfiguration, + buildMockGetNetworkClientById, +} from '../../network-controller/tests/helpers'; type UnrestrictedMessenger = Messenger< TransactionControllerActions | AllowedActions, @@ -111,6 +112,7 @@ jest.mock('./helpers/IncomingTransactionHelper'); jest.mock('./helpers/MethodDataHelper'); jest.mock('./helpers/MultichainTrackingHelper'); jest.mock('./helpers/PendingTransactionTracker'); +jest.mock('./utils/batch'); jest.mock('./utils/gas'); jest.mock('./utils/gas-fees'); jest.mock('./utils/gas-flow'); @@ -273,6 +275,7 @@ function buildMockBlockTracker( /** * Builds a mock gas fee flow. + * * @returns The mocked gas fee flow. */ function buildMockGasFeeFlow(): jest.Mocked { @@ -485,6 +488,7 @@ describe('TransactionController', () => { const getAccountAddressRelationshipMock = jest.mocked( getAccountAddressRelationship, ); + const addTransactionBatchMock = jest.mocked(addTransactionBatch); const methodDataHelperClassMock = jest.mocked(MethodDataHelper); let mockEthQuery: EthQuery; @@ -635,6 +639,7 @@ describe('TransactionController', () => { 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', 'AccountsController:getSelectedAccount', + 'AccountsController:getState', ], allowedEvents: [], }); @@ -645,6 +650,11 @@ describe('TransactionController', () => { mockGetSelectedAccount, ); + unrestrictedMessenger.registerActionHandler( + 'AccountsController:getState', + () => ({}) as never, + ); + const controller = new TransactionController({ ...otherOptions, messenger: restrictedMessenger, @@ -1369,7 +1379,7 @@ describe('TransactionController', () => { const mockOrigin = 'origin'; const mockSecurityAlertResponse = { // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention + result_type: 'Malicious', reason: 'blur_farming', description: @@ -1568,6 +1578,7 @@ describe('TransactionController', () => { deviceConfirmedOn: undefined, id: expect.any(String), isFirstTimeInteraction: undefined, + nestedTransactions: undefined, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: undefined, securityAlertResponse: undefined, @@ -4163,8 +4174,6 @@ describe('TransactionController', () => { const key = 'testKey'; const value = 123; - // TODO: Replace `any` with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any incomingTransactionHelperClassMock.mock.calls[0][0].updateCache( (cache) => { cache[key] = value; @@ -4464,24 +4473,18 @@ describe('TransactionController', () => { txParams: { ...TRANSACTION_META_MOCK.txParams, nonce: '0x1' }, }; - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention const duplicate_1 = { ...confirmed, id: 'testId2', status: TransactionStatus.submitted, }; - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention const duplicate_2 = { ...duplicate_1, id: 'testId3', status: TransactionStatus.approved, }; - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention const duplicate_3 = { ...duplicate_1, id: 'testId4', @@ -5103,8 +5106,6 @@ describe('TransactionController', () => { controller.updateSecurityAlertResponse(transactionMeta.id, { reason: 'NA', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention result_type: 'Benign', }); @@ -5126,8 +5127,6 @@ describe('TransactionController', () => { // @ts-expect-error Intentionally passing invalid input controller.updateSecurityAlertResponse(undefined, { reason: 'NA', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention result_type: 'Benign', }), ).toThrow( @@ -5194,8 +5193,6 @@ describe('TransactionController', () => { expect(() => controller.updateSecurityAlertResponse('456', { reason: 'NA', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention result_type: 'Benign', }), ).toThrow( @@ -6075,4 +6072,26 @@ describe('TransactionController', () => { ); }); }); + + describe('addTransactionBatch', () => { + it('invokes util', async () => { + const { controller } = setupController(); + + await controller.addTransactionBatch({ + from: ACCOUNT_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + transactions: [ + { + params: { + to: ACCOUNT_2_MOCK, + data: '0x123456', + value: '0x123', + }, + }, + ], + }); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 54e763c107..5981547458 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1,5 +1,8 @@ import type { TypedTransaction } from '@ethereumjs/tx'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + AccountsControllerGetSelectedAccountAction, + AccountsControllerGetStateAction, +} from '@metamask/accounts-controller'; import type { AcceptResultCallbacks, AddApprovalRequest, @@ -39,6 +42,7 @@ import type { Transaction as NonceTrackerTransaction, } from '@metamask/nonce-tracker'; import { NonceTracker } from '@metamask/nonce-tracker'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import { errorCodes, rpcErrors, providerErrors } from '@metamask/rpc-errors'; import type { Hex } from '@metamask/utils'; import { add0x, hexToNumber } from '@metamask/utils'; @@ -84,6 +88,9 @@ import type { GasPriceValue, FeeMarketEIP1559Values, SubmitHistoryEntry, + TransactionBatchRequest, + TransactionBatchResult, + BatchTransactionParams, } from './types'; import { TransactionEnvelopeType, @@ -91,6 +98,7 @@ import { TransactionStatus, SimulationErrorCode, } from './types'; +import { addTransactionBatch, isAtomicBatchSupported } from './utils/batch'; import type { KeyringControllerSignAuthorization } from './utils/eip7702'; import { signAuthorizationList } from './utils/eip7702'; import { validateConfirmedExternalTransaction } from './utils/external-transactions'; @@ -338,10 +346,12 @@ const controllerName = 'TransactionController'; */ export type AllowedActions = | AccountsControllerGetSelectedAccountAction + | AccountsControllerGetStateAction | AddApprovalRequest | KeyringControllerSignAuthorization | NetworkControllerFindNetworkClientIdByChainIdAction - | NetworkControllerGetNetworkClientByIdAction; + | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction; /** * The external events available to the {@link TransactionController}. @@ -951,6 +961,38 @@ export class TransactionController extends BaseController< return this.#methodDataHelper.lookup(fourBytePrefix, networkClientId); } + /** + * Add a batch of transactions to be submitted after approval. + * + * @param request - Request object containing the transactions to add. + * @returns Result object containing the generated batch ID. + */ + async addTransactionBatch( + request: TransactionBatchRequest, + ): Promise { + return await addTransactionBatch({ + addTransaction: this.addTransaction.bind(this), + getChainId: this.#getChainId.bind(this), + getEthQuery: (networkClientId) => this.#getEthQuery({ networkClientId }), + messenger: this.messagingSystem, + request, + }); + } + + /** + * Determine which chains support atomic batch transactions with the given account address. + * + * @param address - The address of the account to check. + * @returns The supported chain IDs. + */ + async isAtomicBatchSupported(address: Hex): Promise { + return isAtomicBatchSupported({ + address, + getEthQuery: (chainId) => this.#getEthQuery({ chainId }), + messenger: this.messagingSystem, + }); + } + /** * Add a new unapproved transaction to state. Parameters will be validated, a * unique transaction id will be generated, and gas and gasPrice will be calculated @@ -961,6 +1003,7 @@ export class TransactionController extends BaseController< * @param options.actionId - Unique ID to prevent duplicate requests. * @param options.deviceConfirmedOn - An enum to indicate what device confirmed the transaction. * @param options.method - RPC method that requested the transaction. + * @param options.nestedTransactions - Params for any nested transactions encoded in the data. * @param options.origin - The origin of the transaction request, such as a dApp hostname. * @param options.requireApproval - Whether the transaction requires approval by the user, defaults to true unless explicitly disabled. * @param options.securityAlertResponse - Response from security validator. @@ -979,6 +1022,7 @@ export class TransactionController extends BaseController< actionId?: string; deviceConfirmedOn?: WalletDevice; method?: string; + nestedTransactions?: BatchTransactionParams[]; networkClientId: NetworkClientId; origin?: string; requireApproval?: boolean | undefined; @@ -998,6 +1042,7 @@ export class TransactionController extends BaseController< actionId, deviceConfirmedOn, method, + nestedTransactions, networkClientId, origin, requireApproval, @@ -1022,13 +1067,16 @@ export class TransactionController extends BaseController< : await this.getPermittedAccounts?.(origin); const selectedAddress = this.#getSelectedAccount().address; + const internalAccounts = this.#getInternalAccounts(); await validateTransactionOrigin({ from: txParams.from, + internalAccounts, origin, permittedAddresses, selectedAddress, txParams, + type, }); const isEIP1559Compatible = @@ -1063,6 +1111,7 @@ export class TransactionController extends BaseController< deviceConfirmedOn, id: random(), isFirstTimeInteraction: undefined, + nestedTransactions, networkClientId, origin, securityAlertResponse, @@ -2534,7 +2583,7 @@ export class TransactionController extends BaseController< const rawTx = await this.#trace( { name: 'Sign', parentContext: traceContext }, - () => this.signTransaction(transactionMeta, transactionMeta.txParams), + () => this.signTransaction(transactionMeta), ); if (!this.beforePublish(transactionMeta)) { @@ -3122,8 +3171,9 @@ export class TransactionController extends BaseController< private async signTransaction( transactionMeta: TransactionMeta, - txParams: TransactionParams, ): Promise { + const { txParams } = transactionMeta; + log('Signing transaction', txParams); const { authorizationList, from } = txParams; @@ -3176,6 +3226,7 @@ export class TransactionController extends BaseController< const transactionMetaWithRsv = { ...this.updateTransactionMetaRSV(transactionMetaFromHook, signedTx), status: TransactionStatus.signed as const, + txParams: finalTxParams, }; this.updateTransaction( @@ -3709,6 +3760,14 @@ export class TransactionController extends BaseController< return this.messagingSystem.call('AccountsController:getSelectedAccount'); } + #getInternalAccounts(): string[] { + const state = this.messagingSystem.call('AccountsController:getState'); + + return Object.values(state.internalAccounts?.accounts ?? {}) + .filter((account) => account.type === 'eip155:eoa') + .map((account) => account.address); + } + #updateSubmitHistory(transactionMeta: TransactionMeta, hash: string): void { const { chainId, networkClientId, origin, rawTx, txParams } = transactionMeta; diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index a4e88c28f6..1441936db3 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -1,5 +1,5 @@ import type { TypedTransaction } from '@ethereumjs/tx'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { AccountsControllerActions } from '@metamask/accounts-controller'; import type { ApprovalControllerActions, ApprovalControllerEvents, @@ -28,6 +28,14 @@ import type { SinonFakeTimers } from 'sinon'; import { useFakeTimers } from 'sinon'; import { v4 as uuidV4 } from 'uuid'; +import type { + TransactionControllerActions, + TransactionControllerEvents, + TransactionControllerOptions, +} from './TransactionController'; +import { TransactionController } from './TransactionController'; +import type { InternalAccount } from './types'; +import { TransactionStatus, TransactionType } from './types'; import { advanceTime } from '../../../tests/helpers'; import { mockNetwork } from '../../../tests/mock-network'; import { @@ -46,14 +54,6 @@ import { buildEthSendRawTransactionRequestMock, buildEthGetTransactionReceiptRequestMock, } from '../tests/JsonRpcRequestMocks'; -import type { - TransactionControllerActions, - TransactionControllerEvents, - TransactionControllerOptions, -} from './TransactionController'; -import { TransactionController } from './TransactionController'; -import type { InternalAccount } from './types'; -import { TransactionStatus, TransactionType } from './types'; jest.mock('uuid', () => { const actual = jest.requireActual('uuid'); @@ -68,7 +68,7 @@ type UnrestrictedMessenger = Messenger< | NetworkControllerActions | ApprovalControllerActions | TransactionControllerActions - | AccountsControllerGetSelectedAccountAction, + | AccountsControllerActions, | NetworkControllerEvents | ApprovalControllerEvents | TransactionControllerEvents @@ -118,6 +118,7 @@ const BLOCK_TRACKER_POLLING_INTERVAL = 30000; /** * Builds the Infura network client configuration. + * * @param network - The Infura network type. * @returns The network client configuration. */ @@ -186,6 +187,7 @@ const setupController = async ( 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', 'AccountsController:getSelectedAccount', + 'AccountsController:getState', ], allowedEvents: ['NetworkController:stateChange'], }); @@ -199,6 +201,11 @@ const setupController = async ( mockGetSelectedAccount, ); + unrestrictedMessenger.registerActionHandler( + 'AccountsController:getState', + () => ({}) as never, + ); + const options: TransactionControllerOptions = { disableHistory: false, disableSendFlowHistory: false, @@ -261,7 +268,6 @@ describe('TransactionController Integration', () => { transactionController.destroy(); }); - // eslint-disable-next-line jest/no-disabled-tests it('should fail all approved transactions in state', async () => { mockNetwork({ networkClientConfiguration: buildInfuraNetworkClientConfiguration( @@ -799,7 +805,6 @@ describe('TransactionController Integration', () => { }); describe('when transactions are added concurrently with different networkClientIds but on the same chainId', () => { - // eslint-disable-next-line jest/no-disabled-tests it('should add each transaction with consecutive nonces', async () => { const goerliNetworkClientConfiguration = buildInfuraNetworkClientConfiguration(InfuraNetworkType.goerli); @@ -922,7 +927,6 @@ describe('TransactionController Integration', () => { }); describe('when transactions are added concurrently with the same networkClientId', () => { - // eslint-disable-next-line jest/no-disabled-tests it('should add each transaction with consecutive nonces', async () => { mockNetwork({ networkClientConfiguration: buildInfuraNetworkClientConfiguration( diff --git a/packages/transaction-controller/src/constants.ts b/packages/transaction-controller/src/constants.ts index cc769bf341..7d8391930c 100644 --- a/packages/transaction-controller/src/constants.ts +++ b/packages/transaction-controller/src/constants.ts @@ -83,3 +83,23 @@ export const ABI_SIMULATION_ERC721_LEGACY = [ type: 'event', }, ]; + +export const ABI_IERC7821 = [ + { + type: 'function', + name: 'execute', + inputs: [ + { name: 'mode', type: 'bytes32', internalType: 'ModeCode' }, + { name: 'executionData', type: 'bytes', internalType: 'bytes' }, + ], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'supportsExecutionMode', + inputs: [{ name: 'mode', type: 'bytes32', internalType: 'ModeCode' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, +]; diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index dcac9daa70..9b83ae4401 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -32,6 +32,7 @@ export { export type { Authorization, AuthorizationList, + BatchTransactionParams, DappSuggestedGasFees, DefaultGasEstimates, FeeMarketEIP1559Values, @@ -52,6 +53,8 @@ export type { SimulationError, SimulationToken, SimulationTokenBalanceChange, + TransactionBatchRequest, + TransactionBatchResult, TransactionError, TransactionHistory, TransactionHistoryEntry, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 5d7ec9d589..7258eb16f4 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -217,6 +217,12 @@ type TransactionMetaBase = { */ layer1GasFee?: Hex; + /** + * Parameters for any nested transactions encoded in the data. + * For example, in an atomic batch transaction via EIP-7702. + */ + nestedTransactions?: BatchTransactionParams[]; + /** * The ID of the network client used by the transaction. */ @@ -538,6 +544,12 @@ export enum WalletDevice { * The type of the transaction. */ export enum TransactionType { + /** + * A batch transaction that includes multiple nested transactions. + * Introduced in EIP-7702. + */ + batch = 'batch', + /** * A transaction that bridges tokens to a different chain through Metamask Bridge. */ @@ -1386,3 +1398,54 @@ export type Authorization = { * Introduced in EIP-7702. */ export type AuthorizationList = Authorization[]; + +/** + * The parameters of a transaction within an atomic batch. + */ +export type BatchTransactionParams = { + /** Data used to invoke a function on the target smart contract or EOA. */ + data?: Hex; + + /** Address of the target contract or EOA. */ + to?: Hex; + + /** Native balance to transfer with the transaction. */ + value?: Hex; +}; + +/** + * Specification for a single transaction within a batch request. + */ +export type TransactionBatchSingleRequest = { + /** Parameters of the single transaction. */ + params: BatchTransactionParams; +}; + +/** + * Request to submit a batch of transactions. + * Currently only atomic batches are supported via EIP-7702. + */ +export type TransactionBatchRequest = { + /** Address of the account to submit the transaction batch. */ + from: Hex; + + /** ID of the network client to submit the transaction. */ + networkClientId: NetworkClientId; + + /** Origin of the request, such as a dApp hostname or `ORIGIN_METAMASK` if internal. */ + origin?: string; + + /** Whether an approval request should be created to require confirmation from the user. */ + requireApproval?: boolean; + + /** Transactions to be submitted as part of the batch. */ + transactions: TransactionBatchSingleRequest[]; +}; + +/** + * Result from submitting a transaction batch. + */ +export type TransactionBatchResult = { + /** ID of the batch to locate related transactions. */ + batchId: string; +}; diff --git a/packages/transaction-controller/src/utils/batch.test.ts b/packages/transaction-controller/src/utils/batch.test.ts new file mode 100644 index 0000000000..7e89af2077 --- /dev/null +++ b/packages/transaction-controller/src/utils/batch.test.ts @@ -0,0 +1,299 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import { addTransactionBatch, isAtomicBatchSupported } from './batch'; +import { + doesChainSupportEIP7702, + generateEIP7702BatchTransaction, + isAccountUpgradedToEIP7702, +} from './eip7702'; +import { + getEIP7702SupportedChains, + getEIP7702UpgradeContractAddress, +} from './feature-flags'; +import { + TransactionEnvelopeType, + type TransactionControllerMessenger, + type TransactionMeta, +} from '..'; + +jest.mock('./eip7702'); +jest.mock('./feature-flags'); + +type AddBatchTransactionOptions = Parameters[0]; + +const CHAIN_ID_MOCK = '0x123'; +const CHAIN_ID_2_MOCK = '0xabc'; +const FROM_MOCK = '0x1234567890123456789012345678901234567890'; +const CONTRACT_ADDRESS_MOCK = '0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'; +const TO_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef'; +const DATA_MOCK = '0xabcdef'; +const VALUE_MOCK = '0x1234'; +const MESSENGER_MOCK = {} as TransactionControllerMessenger; +const NETWORK_CLIENT_ID_MOCK = 'testNetworkClientId'; +const BATCH_ID_MOCK = 'testBatchId'; +const GET_ETH_QUERY_MOCK = jest.fn(); + +const TRANSACTION_META_MOCK = { + id: BATCH_ID_MOCK, +} as TransactionMeta; + +describe('Batch Utils', () => { + const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702); + const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); + + const isAccountUpgradedToEIP7702Mock = jest.mocked( + isAccountUpgradedToEIP7702, + ); + + const getEIP7702UpgradeContractAddressMock = jest.mocked( + getEIP7702UpgradeContractAddress, + ); + + const generateEIP7702BatchTransactionMock = jest.mocked( + generateEIP7702BatchTransaction, + ); + + describe('addTransactionBatch', () => { + let addTransactionMock: jest.MockedFn< + AddBatchTransactionOptions['addTransaction'] + >; + + let getChainIdMock: jest.MockedFunction< + AddBatchTransactionOptions['getChainId'] + >; + + let request: AddBatchTransactionOptions; + + beforeEach(() => { + jest.resetAllMocks(); + addTransactionMock = jest.fn(); + getChainIdMock = jest.fn(); + + request = { + addTransaction: addTransactionMock, + getChainId: getChainIdMock, + getEthQuery: GET_ETH_QUERY_MOCK, + messenger: MESSENGER_MOCK, + request: { + from: FROM_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + requireApproval: true, + transactions: [ + { + params: { + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + }, + { + params: { + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + }, + ], + }, + }; + }); + + it('adds generated EIP-7702 transaction', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(true); + + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: true, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce({ + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }); + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + { + from: FROM_MOCK, + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + expect.objectContaining({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + requireApproval: true, + }), + ); + }); + + it('uses type 4 transaction if not upgraded', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(true); + + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: false, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce({ + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }); + + getEIP7702UpgradeContractAddressMock.mockReturnValueOnce( + CONTRACT_ADDRESS_MOCK, + ); + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + { + from: FROM_MOCK, + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + type: TransactionEnvelopeType.setCode, + authorizationList: [{ address: CONTRACT_ADDRESS_MOCK }], + }, + expect.objectContaining({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + requireApproval: true, + }), + ); + }); + + it('passes nested transactions to add transaction', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(true); + + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: true, + }); + + addTransactionMock.mockResolvedValueOnce({ + transactionMeta: TRANSACTION_META_MOCK, + result: Promise.resolve(''), + }); + + generateEIP7702BatchTransactionMock.mockReturnValueOnce({ + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }); + + await addTransactionBatch(request); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + nestedTransactions: [ + { + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + { + to: TO_MOCK, + data: DATA_MOCK, + value: VALUE_MOCK, + }, + ], + }), + ); + }); + + it('throws if chain not supported', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(false); + + await expect(addTransactionBatch(request)).rejects.toThrow( + rpcErrors.internal('Chain does not support EIP-7702'), + ); + }); + + it('throws if account upgraded to unsupported contract', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(true); + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: CONTRACT_ADDRESS_MOCK, + isSupported: false, + }); + + await expect(addTransactionBatch(request)).rejects.toThrow( + rpcErrors.internal('Account upgraded to unsupported contract'), + ); + }); + + it('throws if account not upgraded and no upgrade address', async () => { + doesChainSupportEIP7702Mock.mockReturnValueOnce(true); + + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + delegationAddress: undefined, + isSupported: false, + }); + + getEIP7702UpgradeContractAddressMock.mockReturnValueOnce(undefined); + + await expect(addTransactionBatch(request)).rejects.toThrow( + rpcErrors.internal('Upgrade contract address not found'), + ); + }); + }); + + describe('isAtomicBatchSupported', () => { + it('includes feature flag chains if not upgraded or upgraded to supported contract', async () => { + getEIP7702SupportedChainsMock.mockReturnValueOnce([ + CHAIN_ID_MOCK, + CHAIN_ID_2_MOCK, + ]); + + isAccountUpgradedToEIP7702Mock + .mockResolvedValueOnce({ + isSupported: false, + delegationAddress: undefined, + }) + .mockResolvedValueOnce({ + isSupported: true, + delegationAddress: CONTRACT_ADDRESS_MOCK, + }); + + const result = await isAtomicBatchSupported({ + address: FROM_MOCK, + getEthQuery: GET_ETH_QUERY_MOCK, + messenger: MESSENGER_MOCK, + }); + + expect(result).toStrictEqual([CHAIN_ID_MOCK, CHAIN_ID_2_MOCK]); + }); + + it('excludes chain if upgraded to different contract', async () => { + getEIP7702SupportedChainsMock.mockReturnValueOnce([CHAIN_ID_MOCK]); + + isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({ + isSupported: false, + delegationAddress: CONTRACT_ADDRESS_MOCK, + }); + + const result = await isAtomicBatchSupported({ + address: FROM_MOCK, + getEthQuery: GET_ETH_QUERY_MOCK, + messenger: MESSENGER_MOCK, + }); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts new file mode 100644 index 0000000000..26655758e0 --- /dev/null +++ b/packages/transaction-controller/src/utils/batch.ts @@ -0,0 +1,157 @@ +import type EthQuery from '@metamask/eth-query'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { + doesChainSupportEIP7702, + generateEIP7702BatchTransaction, + isAccountUpgradedToEIP7702, +} from './eip7702'; +import { + getEIP7702SupportedChains, + getEIP7702UpgradeContractAddress, +} from './feature-flags'; +import type { TransactionController, TransactionControllerMessenger } from '..'; +import { projectLogger } from '../logger'; +import { + TransactionEnvelopeType, + type TransactionBatchRequest, + type TransactionBatchResult, + type TransactionParams, + TransactionType, +} from '../types'; + +type AddTransactionBatchRequest = { + addTransaction: TransactionController['addTransaction']; + getChainId: (networkClientId: string) => Hex; + getEthQuery: (networkClientId: string) => EthQuery; + messenger: TransactionControllerMessenger; + request: TransactionBatchRequest; +}; + +type IsAtomicBatchSupportedRequest = { + address: Hex; + getEthQuery: (chainId: Hex) => EthQuery; + messenger: TransactionControllerMessenger; +}; + +const log = createModuleLogger(projectLogger, 'batch'); + +/** + * Add a batch transaction. + * + * @param request - The request object including the user request and necessary callbacks. + * @returns The batch result object including the batch ID. + */ +export async function addTransactionBatch( + request: AddTransactionBatchRequest, +): Promise { + const { + addTransaction, + getChainId, + messenger, + request: userRequest, + } = request; + + const { from, networkClientId, requireApproval, transactions } = userRequest; + + log('Adding', userRequest); + + const chainId = getChainId(networkClientId); + const ethQuery = request.getEthQuery(networkClientId); + const isChainSupported = doesChainSupportEIP7702(chainId, messenger); + + if (!isChainSupported) { + log('Chain does not support EIP-7702', chainId); + throw rpcErrors.internal('Chain does not support EIP-7702'); + } + + const { delegationAddress, isSupported } = await isAccountUpgradedToEIP7702( + from, + chainId, + messenger, + ethQuery, + ); + + log('Account', { delegationAddress, isSupported }); + + if (!isSupported && delegationAddress) { + log('Account upgraded to unsupported contract', from, delegationAddress); + throw rpcErrors.internal('Account upgraded to unsupported contract'); + } + + const nestedTransactions = transactions.map((tx) => tx.params); + const batchParams = generateEIP7702BatchTransaction(from, nestedTransactions); + + const txParams: TransactionParams = { + from, + ...batchParams, + }; + + if (!isSupported) { + const upgradeContractAddress = getEIP7702UpgradeContractAddress( + chainId, + messenger, + ); + + if (!upgradeContractAddress) { + throw rpcErrors.internal('Upgrade contract address not found'); + } + + txParams.type = TransactionEnvelopeType.setCode; + txParams.authorizationList = [{ address: upgradeContractAddress }]; + } + + log('Adding batch transaction', txParams, networkClientId); + + const { transactionMeta, result } = await addTransaction(txParams, { + nestedTransactions, + networkClientId, + requireApproval, + type: TransactionType.batch, + }); + + const batchId = transactionMeta.id; + + // Wait for the transaction to be published. + await result; + + return { + batchId, + }; +} + +/** + * Determine which chains support atomic batch transactions for the given account. + * + * @param request - The request object including the account address and necessary callbacks. + * @returns The chain IDs that support atomic batch transactions. + */ +export async function isAtomicBatchSupported( + request: IsAtomicBatchSupportedRequest, +): Promise { + const { address, getEthQuery, messenger } = request; + + const chainIds7702 = getEIP7702SupportedChains(messenger); + const chainIds: Hex[] = []; + + for (const chainId of chainIds7702) { + const ethQuery = getEthQuery(chainId); + + const { isSupported, delegationAddress } = await isAccountUpgradedToEIP7702( + address, + chainId, + messenger, + ethQuery, + ); + + if (!delegationAddress || isSupported) { + chainIds.push(chainId); + } + } + + log('Atomic batch supported chains', chainIds); + + return chainIds; +} diff --git a/packages/transaction-controller/src/utils/eip7702.test.ts b/packages/transaction-controller/src/utils/eip7702.test.ts index 6db398b3ff..720368db88 100644 --- a/packages/transaction-controller/src/utils/eip7702.test.ts +++ b/packages/transaction-controller/src/utils/eip7702.test.ts @@ -1,10 +1,49 @@ +import { query } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { Hex } from '@metamask/utils'; +import { remove0x } from '@metamask/utils'; + import type { KeyringControllerSignAuthorization } from './eip7702'; -import { signAuthorizationList } from './eip7702'; +import { + DELEGATION_PREFIX, + doesChainSupportEIP7702, + generateEIP7702BatchTransaction, + isAccountUpgradedToEIP7702, + signAuthorizationList, +} from './eip7702'; +import { + getEIP7702ContractAddresses, + getEIP7702SupportedChains, +} from './feature-flags'; import { Messenger } from '../../../base-controller/src'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { AuthorizationList } from '../types'; import { TransactionStatus, type TransactionMeta } from '../types'; +jest.mock('../utils/feature-flags'); + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + query: jest.fn(), +})); + +const CHAIN_ID_MOCK = '0xab12'; +const CHAIN_ID_2_MOCK = '0x456'; +const ADDRESS_MOCK = '0x1234567890123456789012345678901234567890'; +const ADDRESS_2_MOCK = '0x0987654321098765432109876543210987654321'; +const ADDRESS_3_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const ETH_QUERY_MOCK = {} as EthQuery; + +const DATA_MOCK = + '0xe9ae5c530100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000009876543210987654321098765432109876543210000000000000000000000000000000000000000000000000000000000005678000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000021234000000000000000000000000000000000000000000000000000000000000000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd000000000000000000000000000000000000000000000000000000000000def0000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000029abc000000000000000000000000000000000000000000000000000000000000'; + +const DATA_EMPTY_MOCK = + '0xe9ae5c5301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000'; + +const DATA_MISSING_PROPS_MOCK = + '0xe9ae5c5301000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000'; + const AUTHORIZATION_SIGNATURE_MOCK = '0xf85c827a6994663f3ad617193148711d28f5334ee4ed070166028080a040e292da533253143f134643a03405f1af1de1d305526f44ed27e62061368d4ea051cfb0af34e491aa4d6796dececf95569088322e116c4b2f312bb23f20699269'; @@ -12,7 +51,7 @@ const AUTHORIZATION_SIGNATURE_2_MOCK = '0x82d5b4845dfc808802480749c30b0e02d6d7817061ba141d2d1dcd520f9b65c59d0b985134dc2958a9981ce3b5d1061176313536e6da35852cfae41404f53ef31b624206f3bc543ca6710e02d58b909538d6e2445cea94dfd39737fbc0b3'; const TRANSACTION_META_MOCK: TransactionMeta = { - chainId: '0x1', + chainId: CHAIN_ID_MOCK, id: '123-456', networkClientId: 'network-client-id', status: TransactionStatus.unapproved, @@ -26,33 +65,48 @@ const TRANSACTION_META_MOCK: TransactionMeta = { const AUTHORIZATION_LIST_MOCK: AuthorizationList = [ { address: '0x1234567890123456789012345678901234567890', - chainId: '0x123', + chainId: CHAIN_ID_2_MOCK, nonce: '0x456', }, ]; describe('EIP-7702 Utils', () => { - let baseMessenger: Messenger; + let baseMessenger: Messenger< + | KeyringControllerSignAuthorization + | RemoteFeatureFlagControllerGetStateAction, + never + >; + + const getCodeMock = jest.mocked(query); let controllerMessenger: TransactionControllerMessenger; + + const getEIP7702SupportedChainsMock = jest.mocked(getEIP7702SupportedChains); + + const getEIP7702ContractAddressesMock = jest.mocked( + getEIP7702ContractAddresses, + ); + let signAuthorizationMock: jest.MockedFn< KeyringControllerSignAuthorization['handler'] >; beforeEach(() => { - baseMessenger = new Messenger(); + jest.resetAllMocks(); + + baseMessenger = new Messenger(); signAuthorizationMock = jest .fn() .mockResolvedValue(AUTHORIZATION_SIGNATURE_MOCK); baseMessenger.registerActionHandler( - 'KeyringController:signAuthorization', + 'KeyringController:signEip7702AuthorizationMessage', signAuthorizationMock, ); controllerMessenger = baseMessenger.getRestricted({ name: 'TransactionController', - allowedActions: ['KeyringController:signAuthorization'], + allowedActions: ['KeyringController:signEip7702AuthorizationMessage'], allowedEvents: [], }); }); @@ -172,4 +226,196 @@ describe('EIP-7702 Utils', () => { expect(result?.[0]?.nonce).toBe('0x'); }); }); + + describe('doesChainSupportEIP7702', () => { + it('returns true if chain ID in feature flag list', () => { + getEIP7702SupportedChainsMock.mockReturnValue([ + CHAIN_ID_2_MOCK, + CHAIN_ID_MOCK, + ]); + + expect(doesChainSupportEIP7702(CHAIN_ID_MOCK, controllerMessenger)).toBe( + true, + ); + }); + + it('returns false if chain ID not in feature flag list', () => { + getEIP7702SupportedChainsMock.mockReturnValue([CHAIN_ID_2_MOCK]); + + expect(doesChainSupportEIP7702(CHAIN_ID_MOCK, controllerMessenger)).toBe( + false, + ); + }); + + it('returns true if chain ID in feature flag list with alternate case', () => { + getEIP7702SupportedChainsMock.mockReturnValue([ + CHAIN_ID_2_MOCK, + CHAIN_ID_MOCK.toUpperCase() as Hex, + ]); + + expect(doesChainSupportEIP7702(CHAIN_ID_MOCK, controllerMessenger)).toBe( + true, + ); + }); + }); + + describe('isAccountUpgradedToEIP7702', () => { + it('returns true if delegation matches feature flag', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_2_MOCK]); + + getCodeMock.mockResolvedValueOnce( + `${DELEGATION_PREFIX}${remove0x(ADDRESS_2_MOCK)}`, + ); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: ADDRESS_2_MOCK, + isSupported: true, + }); + }); + + it('returns true if delegation matches feature flag with alternate case', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ + ADDRESS_3_MOCK.toUpperCase() as Hex, + ]); + + getCodeMock.mockResolvedValueOnce( + `${DELEGATION_PREFIX}${remove0x(ADDRESS_3_MOCK)}`, + ); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK.toUpperCase() as Hex, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: ADDRESS_3_MOCK, + isSupported: true, + }); + }); + + it('returns false if delegation does not match feature flag', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_3_MOCK]); + + getCodeMock.mockResolvedValueOnce( + `${DELEGATION_PREFIX}${remove0x(ADDRESS_2_MOCK)}`, + ); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: ADDRESS_2_MOCK, + isSupported: false, + }); + }); + + it('returns false if empty code', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_3_MOCK]); + + getCodeMock.mockResolvedValueOnce('0x'); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: undefined, + isSupported: false, + }); + }); + + it('returns false if no code', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_3_MOCK]); + + getCodeMock.mockResolvedValueOnce(undefined); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: undefined, + isSupported: false, + }); + }); + + it('returns false if not delegation code', async () => { + getEIP7702ContractAddressesMock.mockReturnValue([ADDRESS_3_MOCK]); + + getCodeMock.mockResolvedValueOnce( + '0x1234567890123456789012345678901234567890123456789012345678901234567890', + ); + + expect( + await isAccountUpgradedToEIP7702( + ADDRESS_MOCK, + CHAIN_ID_MOCK, + controllerMessenger, + ETH_QUERY_MOCK, + ), + ).toStrictEqual({ + delegationAddress: undefined, + isSupported: false, + }); + }); + }); + + describe('generateEIP7702BatchTransaction', () => { + it('generates a batch transaction', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [ + { + data: '0x1234', + to: ADDRESS_2_MOCK, + value: '0x5678', + }, + { + data: '0x9abc', + to: ADDRESS_3_MOCK, + value: '0xdef0', + }, + ]); + + expect(result).toStrictEqual({ + data: DATA_MOCK, + to: ADDRESS_MOCK, + }); + }); + + it('includes empty data if no transaction', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, []); + + expect(result).toStrictEqual({ + data: DATA_EMPTY_MOCK, + to: ADDRESS_MOCK, + }); + }); + + it('supports missing properties', () => { + const result = generateEIP7702BatchTransaction(ADDRESS_MOCK, [{}, {}]); + + expect(result).toStrictEqual({ + data: DATA_MISSING_PROPS_MOCK, + to: ADDRESS_MOCK, + }); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/eip7702.ts b/packages/transaction-controller/src/utils/eip7702.ts index 67b2964394..78854100eb 100644 --- a/packages/transaction-controller/src/utils/eip7702.ts +++ b/packages/transaction-controller/src/utils/eip7702.ts @@ -1,9 +1,18 @@ -import { toHex } from '@metamask/controller-utils'; -import { createModuleLogger, type Hex } from '@metamask/utils'; +import { defaultAbiCoder } from '@ethersproject/abi'; +import { Contract } from '@ethersproject/contracts'; +import { query, toHex } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import { createModuleLogger, type Hex, add0x } from '@metamask/utils'; +import { + getEIP7702ContractAddresses, + getEIP7702SupportedChains, +} from './feature-flags'; +import { ABI_IERC7821 } from '../constants'; import { projectLogger } from '../logger'; import type { TransactionControllerMessenger } from '../TransactionController'; import type { + BatchTransactionParams, Authorization, AuthorizationList, TransactionMeta, @@ -16,12 +25,121 @@ export type KeyringControllerAuthorization = [ ]; export type KeyringControllerSignAuthorization = { - type: 'KeyringController:signAuthorization'; - handler: (authorization: KeyringControllerAuthorization) => Promise; + type: 'KeyringController:signEip7702AuthorizationMessage'; + handler: (authorization: { + chainId: number; + contractAddress: string; + from: string; + nonce: number; + }) => Promise; }; +export const DELEGATION_PREFIX = '0xef0100'; +export const BATCH_FUNCTION_NAME = 'execute'; +export const CALLS_SIGNATURE = '(address,uint256,bytes)[]'; + const log = createModuleLogger(projectLogger, 'eip-7702'); +/** + * Determine if a chain supports EIP-7702 using LaunchDarkly feature flag. + * + * @param chainId - Hexadecimal ID of the chain. + * @param messenger - Messenger instance. + * @returns True if the chain supports EIP-7702. + */ +export function doesChainSupportEIP7702( + chainId: Hex, + messenger: TransactionControllerMessenger, +) { + const supportedChains = getEIP7702SupportedChains(messenger); + + return supportedChains.some( + (supportedChainId) => + supportedChainId.toLowerCase() === chainId.toLowerCase(), + ); +} + +/** + * Determine if an account has been upgraded to a supported EIP-7702 contract. + * + * @param address - The EOA address to check. + * @param chainId - The chain ID. + * @param messenger - The messenger instance. + * @param ethQuery - The EthQuery instance to communicate with the blockchain. + * @returns An object with the results of the check. + */ +export async function isAccountUpgradedToEIP7702( + address: Hex, + chainId: Hex, + messenger: TransactionControllerMessenger, + ethQuery: EthQuery, +) { + const contractAddresses = getEIP7702ContractAddresses(chainId, messenger); + const code = await query(ethQuery, 'eth_getCode', [address]); + const normalizedCode = add0x(code?.toLowerCase?.() ?? ''); + + const hasDelegation = + code?.length === 48 && normalizedCode.startsWith(DELEGATION_PREFIX); + + const delegationAddress = hasDelegation + ? add0x(normalizedCode.slice(DELEGATION_PREFIX.length)) + : undefined; + + const isSupported = Boolean( + delegationAddress && + contractAddresses.some( + (contract) => + contract.toLowerCase() === delegationAddress.toLowerCase(), + ), + ); + + return { + delegationAddress, + isSupported, + }; +} + +/** + * Generate an EIP-7702 batch transaction. + * + * @param from - The sender address. + * @param transactions - The transactions to batch. + * @returns The batch transaction. + */ +export function generateEIP7702BatchTransaction( + from: Hex, + transactions: BatchTransactionParams[], +): BatchTransactionParams { + const erc7821Contract = Contract.getInterface(ABI_IERC7821); + + const calls = transactions.map((transaction) => { + const { data, to, value } = transaction; + + return [ + to ?? '0x0000000000000000000000000000000000000000', + value ?? '0x0', + data ?? '0x', + ]; + }); + + // Single batch mode, no opData. + const mode = '0x01'.padEnd(66, '0'); + + const callData = defaultAbiCoder.encode([CALLS_SIGNATURE], [calls]); + + const data = erc7821Contract.encodeFunctionData(BATCH_FUNCTION_NAME, [ + mode, + callData, + ]) as Hex; + + log('Transaction data', data); + + return { + data, + to: from, + }; +} + /** * Sign an authorization list. * @@ -83,13 +201,20 @@ async function signAuthorization( index, ); + const { txParams } = transactionMeta; + const { from } = txParams; const { address, chainId, nonce } = finalAuthorization; const chainIdDecimal = parseInt(chainId, 16); const nonceDecimal = parseInt(nonce, 16); const signature = await messenger.call( - 'KeyringController:signAuthorization', - [chainIdDecimal, address, nonceDecimal], + 'KeyringController:signEip7702AuthorizationMessage', + { + chainId: chainIdDecimal, + contractAddress: address, + from, + nonce: nonceDecimal, + }, ); const r = signature.slice(0, 66) as Hex; diff --git a/packages/transaction-controller/src/utils/feature-flags.test.ts b/packages/transaction-controller/src/utils/feature-flags.test.ts new file mode 100644 index 0000000000..5b04855286 --- /dev/null +++ b/packages/transaction-controller/src/utils/feature-flags.test.ts @@ -0,0 +1,153 @@ +import { Messenger } from '@metamask/base-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionControllerFeatureFlags } from './feature-flags'; +import { + FEATURE_FLAG_EIP_7702, + getEIP7702ContractAddresses, + getEIP7702SupportedChains, + getEIP7702UpgradeContractAddress, +} from './feature-flags'; +import type { TransactionControllerMessenger } from '..'; + +const CHAIN_ID_MOCK = '0x123' as Hex; +const CHAIN_ID_2_MOCK = '0xabc' as Hex; +const ADDRESS_MOCK = '0x1234567890abcdef1234567890abcdef12345678' as Hex; +const ADDRESS_2_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; + +describe('Feature Flags Utils', () => { + let baseMessenger: Messenger< + RemoteFeatureFlagControllerGetStateAction, + never + >; + + let controllerMessenger: TransactionControllerMessenger; + + let getFeatureFlagsMock: jest.MockedFn< + RemoteFeatureFlagControllerGetStateAction['handler'] + >; + + /** + * Mocks the feature flags returned by the remote feature flag controller. + * + * @param featureFlags - The feature flags to mock. + */ + function mockFeatureFlags( + featureFlags: Partial< + TransactionControllerFeatureFlags['confirmations-eip-7702'] + >, + ) { + getFeatureFlagsMock.mockReturnValue({ + cacheTimestamp: 0, + remoteFeatureFlags: { + [FEATURE_FLAG_EIP_7702]: featureFlags, + } as TransactionControllerFeatureFlags, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + getFeatureFlagsMock = jest.fn(); + + baseMessenger = new Messenger(); + + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getFeatureFlagsMock, + ); + + controllerMessenger = baseMessenger.getRestricted({ + name: 'TransactionController', + allowedActions: ['RemoteFeatureFlagController:getState'], + allowedEvents: [], + }); + }); + + describe('getEIP7702SupportedChains', () => { + it('returns value from remote feature flag controller', () => { + mockFeatureFlags({ + supportedChains: [CHAIN_ID_MOCK, CHAIN_ID_2_MOCK], + }); + + expect(getEIP7702SupportedChains(controllerMessenger)).toStrictEqual([ + CHAIN_ID_MOCK, + CHAIN_ID_2_MOCK, + ]); + }); + + it('returns empty array if undefined', () => { + mockFeatureFlags({}); + expect(getEIP7702SupportedChains(controllerMessenger)).toStrictEqual([]); + }); + }); + + describe('getEIP7702ContractAddresses', () => { + it('returns value from remote feature flag controller', () => { + mockFeatureFlags({ + contractAddresses: { + [CHAIN_ID_MOCK]: [ADDRESS_MOCK, ADDRESS_2_MOCK], + }, + }); + + expect( + getEIP7702ContractAddresses(CHAIN_ID_MOCK, controllerMessenger), + ).toStrictEqual([ADDRESS_MOCK, ADDRESS_2_MOCK]); + }); + + it('returns empty array if undefined', () => { + mockFeatureFlags({}); + + expect( + getEIP7702ContractAddresses(CHAIN_ID_MOCK, controllerMessenger), + ).toStrictEqual([]); + }); + + it('returns empty array if chain ID not found', () => { + mockFeatureFlags({ + contractAddresses: { + [CHAIN_ID_2_MOCK]: [ADDRESS_MOCK, ADDRESS_2_MOCK], + }, + }); + + expect( + getEIP7702ContractAddresses(CHAIN_ID_MOCK, controllerMessenger), + ).toStrictEqual([]); + }); + }); + + describe('getEIP7702UpgradeContractAddress', () => { + it('returns first contract address for chain', () => { + mockFeatureFlags({ + contractAddresses: { + [CHAIN_ID_MOCK]: [ADDRESS_MOCK, ADDRESS_2_MOCK], + }, + }); + + expect( + getEIP7702UpgradeContractAddress(CHAIN_ID_MOCK, controllerMessenger), + ).toStrictEqual(ADDRESS_MOCK); + }); + + it('returns undefined if no contract addresses', () => { + mockFeatureFlags({}); + + expect( + getEIP7702UpgradeContractAddress(CHAIN_ID_MOCK, controllerMessenger), + ).toBeUndefined(); + }); + + it('returns undefined if empty contract addresses', () => { + mockFeatureFlags({ + contractAddresses: { + [CHAIN_ID_MOCK]: [], + }, + }); + + expect( + getEIP7702UpgradeContractAddress(CHAIN_ID_MOCK, controllerMessenger), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/feature-flags.ts b/packages/transaction-controller/src/utils/feature-flags.ts new file mode 100644 index 0000000000..a7661814da --- /dev/null +++ b/packages/transaction-controller/src/utils/feature-flags.ts @@ -0,0 +1,81 @@ +import { createModuleLogger, type Hex } from '@metamask/utils'; + +import { projectLogger } from '../logger'; +import type { TransactionControllerMessenger } from '../TransactionController'; + +export const FEATURE_FLAG_EIP_7702 = 'confirmations-eip-7702'; + +export type TransactionControllerFeatureFlags = { + [FEATURE_FLAG_EIP_7702]: { + contractAddresses: Record; + supportedChains: Hex[]; + upgradeContractAddress: Hex; + }; +}; + +const log = createModuleLogger(projectLogger, 'feature-flags'); + +/** + * Retrieves the supported EIP-7702 chains. + * + * @param messenger - The controller messenger instance. + * @returns The supported chains. + */ +export function getEIP7702SupportedChains( + messenger: TransactionControllerMessenger, +): Hex[] { + const featureFlags = getFeatureFlags(messenger); + return featureFlags?.[FEATURE_FLAG_EIP_7702]?.supportedChains ?? []; +} + +/** + * Retrieves the supported EIP-7702 contract addresses for a given chain ID. + * + * @param chainId - The chain ID. + * @param messenger - The controller messenger instance. + * @returns The supported contract addresses. + */ +export function getEIP7702ContractAddresses( + chainId: Hex, + messenger: TransactionControllerMessenger, +): Hex[] { + const featureFlags = getFeatureFlags(messenger); + + return ( + featureFlags?.[FEATURE_FLAG_EIP_7702]?.contractAddresses?.[ + chainId.toLowerCase() as Hex + ] ?? [] + ); +} + +/** + * Retrieves the EIP-7702 upgrade contract address. + * + * @param chainId - The chain ID. + * @param messenger - The controller messenger instance. + * @returns The upgrade contract address. + */ +export function getEIP7702UpgradeContractAddress( + chainId: Hex, + messenger: TransactionControllerMessenger, +): Hex | undefined { + return getEIP7702ContractAddresses(chainId, messenger)?.[0]; +} + +/** + * Retrieves the relevant feature flags from the remote feature flag controller. + * + * @param messenger - The messenger instance. + * @returns The feature flags. + */ +function getFeatureFlags( + messenger: TransactionControllerMessenger, +): TransactionControllerFeatureFlags { + const featureFlags = messenger.call( + 'RemoteFeatureFlagController:getState', + ).remoteFeatureFlags; + + log('Retrieved feature flags', featureFlags); + + return featureFlags as TransactionControllerFeatureFlags; +} diff --git a/packages/transaction-controller/src/utils/validation.test.ts b/packages/transaction-controller/src/utils/validation.test.ts index 91da8d9cc6..80a50ad4ea 100644 --- a/packages/transaction-controller/src/utils/validation.test.ts +++ b/packages/transaction-controller/src/utils/validation.test.ts @@ -6,7 +6,7 @@ import { validateTransactionOrigin, validateTxParams, } from './validation'; -import { TransactionEnvelopeType } from '../types'; +import { TransactionEnvelopeType, TransactionType } from '../types'; import type { TransactionParams } from '../types'; const FROM_MOCK = '0x1678a085c290ebd122dc42cba69373b5953b831d'; @@ -539,7 +539,7 @@ describe('validation', () => { ); }); - it.each(['chainId', 'nonce', 'r', 's', 'yParity'])( + it.each(['chainId', 'nonce', 'r', 's'])( 'throws if %s provided but not hexadecimal', (property) => { expect(() => @@ -561,6 +561,26 @@ describe('validation', () => { ); }, ); + + it('throws if yParity is not 0x or 0x1', () => { + expect(() => + validateTxParams({ + authorizationList: [ + { + address: FROM_MOCK, + yParity: '0x2' as never, + }, + ], + from: FROM_MOCK, + to: TO_MOCK, + type: TransactionEnvelopeType.setCode, + }), + ).toThrow( + rpcErrors.invalidParams( + `Invalid transaction params: yParity must be '0x' or '0x1'. got: 0x2`, + ), + ); + }); }); }); @@ -621,7 +641,7 @@ describe('validation', () => { ).toBeUndefined(); }); - it('throw if external and type 4', async () => { + it('throws if external and type 4', async () => { await expect( validateTransactionOrigin({ from: FROM_MOCK, @@ -639,7 +659,7 @@ describe('validation', () => { ); }); - it('throw if external and authorization list provided', async () => { + it('throws if external and authorization list provided', async () => { await expect( validateTransactionOrigin({ from: FROM_MOCK, @@ -648,7 +668,7 @@ describe('validation', () => { selectedAddress: '0x123', txParams: { authorizationList: [], - from: FROM_MOCK, + from: TO_MOCK, } as TransactionParams, }), ).rejects.toThrow( @@ -657,6 +677,39 @@ describe('validation', () => { ), ); }); + + it('throws if external and to is internal account', async () => { + await expect( + validateTransactionOrigin({ + from: FROM_MOCK, + internalAccounts: [TO_MOCK], + origin: 'test-origin', + selectedAddress: '0x123', + txParams: { + to: TO_MOCK, + } as TransactionParams, + }), + ).rejects.toThrow( + rpcErrors.invalidParams( + 'External transactions to internal accounts are not supported', + ), + ); + }); + + it('does not throw if external and to is internal account but type is batch', async () => { + expect( + await validateTransactionOrigin({ + from: FROM_MOCK, + internalAccounts: [TO_MOCK], + origin: 'test-origin', + selectedAddress: '0x123', + txParams: { + to: TO_MOCK, + } as TransactionParams, + type: TransactionType.batch, + }), + ).toBeUndefined(); + }); }); describe('validateParamTo', () => { diff --git a/packages/transaction-controller/src/utils/validation.ts b/packages/transaction-controller/src/utils/validation.ts index caee53dc45..cab442cd7b 100644 --- a/packages/transaction-controller/src/utils/validation.ts +++ b/packages/transaction-controller/src/utils/validation.ts @@ -6,7 +6,11 @@ import { isStrictHexString, remove0x } from '@metamask/utils'; import { isEIP1559Transaction } from './utils'; import type { Authorization } from '../types'; -import { TransactionEnvelopeType, type TransactionParams } from '../types'; +import { + TransactionEnvelopeType, + TransactionType, + type TransactionParams, +} from '../types'; const TRANSACTION_ENVELOPE_TYPES_FEE_MARKET = [ TransactionEnvelopeType.feeMarket, @@ -25,28 +29,34 @@ type GasFieldsToValidate = * * @param options - Options bag. * @param options.from - The address from which the transaction is initiated. + * @param options.internalAccounts - The internal accounts added to the wallet. * @param options.origin - The origin or source of the transaction. * @param options.permittedAddresses - The permitted accounts for the given origin. * @param options.selectedAddress - The currently selected Ethereum address in the wallet. * @param options.txParams - The transaction parameters. + * @param options.type - The transaction type. * @throws Throws an error if the transaction is not permitted. */ export async function validateTransactionOrigin({ from, + internalAccounts, origin, permittedAddresses, selectedAddress, txParams, + type, }: { from: string; + internalAccounts?: string[]; origin?: string; permittedAddresses?: string[]; - selectedAddress: string; + selectedAddress?: string; txParams: TransactionParams; + type?: TransactionType; }) { const isInternal = origin === ORIGIN_METAMASK; const isExternal = origin && origin !== ORIGIN_METAMASK; - const { authorizationList, type } = txParams; + const { authorizationList, to, type: envelopeType } = txParams; if (isInternal && from !== selectedAddress) { throw rpcErrors.internal({ @@ -65,12 +75,24 @@ export async function validateTransactionOrigin({ if ( isExternal && - (authorizationList || type === TransactionEnvelopeType.setCode) + (authorizationList || envelopeType === TransactionEnvelopeType.setCode) ) { throw rpcErrors.invalidParams( 'External EIP-7702 transactions are not supported', ); } + + if ( + isExternal && + internalAccounts?.some( + (account) => account.toLowerCase() === to?.toLowerCase(), + ) && + type !== TransactionType.batch + ) { + throw rpcErrors.invalidParams( + 'External transactions to internal accounts are not supported', + ); + } } /** @@ -447,11 +469,19 @@ function validateAuthorization(authorization: Authorization) { ensureFieldIsValidHex(authorization, 'address'); validateHexLength(authorization.address, 20, 'address'); - for (const field of ['chainId', 'nonce', 'r', 's', 'yParity'] as const) { + for (const field of ['chainId', 'nonce', 'r', 's'] as const) { if (authorization[field]) { ensureFieldIsValidHex(authorization, field); } } + + const { yParity } = authorization; + + if (yParity && !['0x', '0x1'].includes(yParity)) { + throw rpcErrors.invalidParams( + `Invalid transaction params: yParity must be '0x' or '0x1'. got: ${yParity}`, + ); + } } /** diff --git a/packages/transaction-controller/tsconfig.build.json b/packages/transaction-controller/tsconfig.build.json index 97b770701d..716dda8820 100644 --- a/packages/transaction-controller/tsconfig.build.json +++ b/packages/transaction-controller/tsconfig.build.json @@ -11,7 +11,8 @@ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../gas-fee-controller/tsconfig.build.json" }, - { "path": "../network-controller/tsconfig.build.json" } + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/transaction-controller/tsconfig.json b/packages/transaction-controller/tsconfig.json index 338e2016b8..b839b37eed 100644 --- a/packages/transaction-controller/tsconfig.json +++ b/packages/transaction-controller/tsconfig.json @@ -10,7 +10,8 @@ { "path": "../base-controller" }, { "path": "../controller-utils" }, { "path": "../gas-fee-controller" }, - { "path": "../network-controller" } + { "path": "../network-controller" }, + { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index 9ca303c8fa..0b66770aa4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3889,7 +3889,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": +"@metamask/remote-feature-flag-controller@npm:^1.4.0, @metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": version: 0.0.0-use.local resolution: "@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller" dependencies: @@ -4180,6 +4180,7 @@ __metadata: "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^22.2.1" "@metamask/nonce-tracker": "npm:^6.0.0" + "@metamask/remote-feature-flag-controller": "npm:^1.4.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.1.0" "@types/bn.js": "npm:^5.1.5" @@ -4207,6 +4208,7 @@ __metadata: "@metamask/eth-block-tracker": ">=9" "@metamask/gas-fee-controller": ^22.0.0 "@metamask/network-controller": ^22.0.0 + "@metamask/remote-feature-flag-controller": ^1.3.0 languageName: unknown linkType: soft