diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index d1bccf9fb33..64f82485329 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `gasFeeToken` property to `addTransaction` and `addTransactionBatch` methods ([#7123](https://github.com/MetaMask/core/pull/7123)) + - Also add optional `gasFeeToken` and `isGasFeeTokenIgnoredIfBalance` properties to `TransactionMeta`. + ## [61.2.0] ### Added diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 4a6ed4accc0..4b75427e9d2 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: 93.24, + functions: 92.76, lines: 96.83, statements: 96.82, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 22e5c16a5a1..ab734742f42 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -64,6 +64,7 @@ "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.8.1", "async-mutex": "^0.5.0", + "bignumber.js": "^9.1.2", "bn.js": "^5.2.1", "eth-method-registry": "^4.0.0", "fast-json-patch": "^3.1.1", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index e9c865d50e7..3591a166ed3 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -1693,10 +1693,12 @@ describe('TransactionController', () => { isFirstTimeInteraction: undefined, isGasFeeIncluded: undefined, isGasFeeSponsored: undefined, + isGasFeeTokenIgnoredIfBalance: false, nestedTransactions: undefined, networkClientId: NETWORK_CLIENT_ID_MOCK, origin: undefined, securityAlertResponse: undefined, + selectedGasFeeToken: undefined, sendFlowHistory: expect.any(Array), status: TransactionStatus.unapproved as const, time: expect.any(Number), diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index b89a718bafc..3fa98332ac1 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -137,7 +137,10 @@ import { import { validateConfirmedExternalTransaction } from './utils/external-transactions'; import { updateFirstTimeInteraction } from './utils/first-time-interaction'; import { addGasBuffer, estimateGas, updateGas } from './utils/gas'; -import { getGasFeeTokens } from './utils/gas-fee-tokens'; +import { + checkGasFeeTokenBeforePublish, + getGasFeeTokens, +} from './utils/gas-fee-tokens'; import { updateGasFees } from './utils/gas-fees'; import { getGasFeeFlow } from './utils/gas-flow'; import { @@ -1215,6 +1218,7 @@ export class TransactionController extends BaseController< batchId, deviceConfirmedOn, disableGasBuffer, + gasFeeToken, isGasFeeIncluded, isGasFeeSponsored, method, @@ -1315,6 +1319,7 @@ export class TransactionController extends BaseController< deviceConfirmedOn, disableGasBuffer, id: random(), + isGasFeeTokenIgnoredIfBalance: Boolean(gasFeeToken), isGasFeeIncluded, isGasFeeSponsored, isFirstTimeInteraction: undefined, @@ -1322,6 +1327,7 @@ export class TransactionController extends BaseController< networkClientId, origin, securityAlertResponse, + selectedGasFeeToken: gasFeeToken, status: TransactionStatus.unapproved as const, time: Date.now(), txParams, @@ -3114,6 +3120,20 @@ export class TransactionController extends BaseController< clearApprovingTransactionId = () => this.#approvingTransactionIds.delete(transactionId); + const { networkClientId } = transactionMeta; + const ethQuery = this.#getEthQuery({ networkClientId }); + + await checkGasFeeTokenBeforePublish({ + ethQuery, + fetchGasFeeTokens: async (tx) => + (await this.#getGasFeeTokens(tx)).gasFeeTokens, + transaction: transactionMeta, + updateTransaction: (txId, fn) => + this.#updateTransactionInternal({ transactionId: txId }, fn), + }); + + transactionMeta = this.#getTransactionOrThrow(transactionId); + const [nonce, releaseNonce] = await getNextNonce( transactionMeta, (address: string) => @@ -3165,9 +3185,6 @@ export class TransactionController extends BaseController< return ApprovalState.NotApproved; } - const { networkClientId } = transactionMeta; - const ethQuery = this.#getEthQuery({ networkClientId }); - let preTxBalance: string | undefined; const shouldUpdatePreTxBalance = transactionMeta.type === TransactionType.swap; @@ -4255,14 +4272,8 @@ export class TransactionController extends BaseController< }; } - const gasFeeTokensResponse = await getGasFeeTokens({ - chainId, - getSimulationConfig: this.#getSimulationConfig, - isEIP7702GasFeeTokensEnabled: this.#isEIP7702GasFeeTokensEnabled, - messenger: this.messenger, - publicKeyEIP7702: this.#publicKeyEIP7702, - transactionMeta, - }); + const gasFeeTokensResponse = await this.#getGasFeeTokens(transactionMeta); + gasFeeTokens = gasFeeTokensResponse?.gasFeeTokens ?? []; isGasFeeSponsored = gasFeeTokensResponse?.isGasFeeSponsored ?? false; } @@ -4637,4 +4648,17 @@ export class TransactionController extends BaseController< return { transactionHash }; } + + async #getGasFeeTokens(transaction: TransactionMeta) { + const { chainId } = transaction; + + return await getGasFeeTokens({ + chainId, + getSimulationConfig: this.#getSimulationConfig, + isEIP7702GasFeeTokensEnabled: this.#isEIP7702GasFeeTokensEnabled, + messenger: this.messenger, + publicKeyEIP7702: this.#publicKeyEIP7702, + transactionMeta: transaction, + }); + } } diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index b23a567785f..b41c9462b16 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -268,6 +268,9 @@ export type TransactionMeta = { /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; + /** Whether the `selectedGasFeeToken` is only used if the user has insufficient native balance. */ + isGasFeeTokenIgnoredIfBalance?: boolean; + /** Whether the intent of the transaction was achieved via an alternate route or chain. */ isIntentComplete?: boolean; @@ -1723,6 +1726,9 @@ export type TransactionBatchRequest = { /** Address of the account to submit the transaction batch. */ from: Hex; + /** Address of an ERC-20 token to pay for the gas fee, if the user has insufficient native balance. */ + gasFeeToken?: Hex; + /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; @@ -2061,6 +2067,9 @@ export type AddTransactionOptions = { /** Whether to disable the gas estimation buffer. */ disableGasBuffer?: boolean; + /** Address of an ERC-20 token to pay for the gas fee, if the user has insufficient native balance. */ + gasFeeToken?: Hex; + /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; diff --git a/packages/transaction-controller/src/utils/balance-changes.test.ts b/packages/transaction-controller/src/utils/balance-changes.test.ts index 9e92e619209..ed507af2796 100644 --- a/packages/transaction-controller/src/utils/balance-changes.test.ts +++ b/packages/transaction-controller/src/utils/balance-changes.test.ts @@ -282,7 +282,7 @@ function mockParseLog({ } } -describe('Simulation Utils', () => { +describe('Balance Change Utils', () => { const simulateTransactionsMock = jest.mocked(simulateTransactions); const queryMock = jest.mocked(query); diff --git a/packages/transaction-controller/src/utils/balance-changes.ts b/packages/transaction-controller/src/utils/balance-changes.ts index c8c01ddc243..a49404535a9 100644 --- a/packages/transaction-controller/src/utils/balance-changes.ts +++ b/packages/transaction-controller/src/utils/balance-changes.ts @@ -1,11 +1,12 @@ import type { Fragment, LogDescription, Result } from '@ethersproject/abi'; import { Interface } from '@ethersproject/abi'; -import { hexToBN, query, toHex } from '@metamask/controller-utils'; +import { hexToBN, toHex } from '@metamask/controller-utils'; import type EthQuery from '@metamask/eth-query'; import { abiERC20, abiERC721, abiERC1155 } from '@metamask/metamask-eth-abis'; import { createModuleLogger, type Hex } from '@metamask/utils'; import BN from 'bn.js'; +import { getNativeBalance } from './balance'; import { simulateTransactions } from '../api/simulation-api'; import type { SimulationResponseLog, @@ -726,11 +727,8 @@ async function baseRequest({ log('Required balance', requiredBalanceHex); - const currentBalanceHex = (await query(ethQuery, 'getBalance', [ - from, - 'latest', - ])) as Hex; - + const { balanceRaw } = await getNativeBalance(from, ethQuery); + const currentBalanceHex = toHex(balanceRaw); const currentBalanceBN = hexToBN(currentBalanceHex); log('Current balance', currentBalanceHex); diff --git a/packages/transaction-controller/src/utils/balance.test.ts b/packages/transaction-controller/src/utils/balance.test.ts new file mode 100644 index 00000000000..01800c54d78 --- /dev/null +++ b/packages/transaction-controller/src/utils/balance.test.ts @@ -0,0 +1,68 @@ +import { query, toHex } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; + +import { getNativeBalance, isNativeBalanceSufficientForGas } from './balance'; +import type { TransactionMeta } from '..'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + query: jest.fn(), +})); + +const ETH_QUERY_MOCK = {} as EthQuery; +const BALANCE_MOCK = '21000000000000'; + +const TRANSACTION_META_MOCK = { + txParams: { + from: '0x1234', + gas: toHex(21000), + maxFeePerGas: toHex(1000000000), // 1 Gwei + }, +} as TransactionMeta; + +describe('Balance Utils', () => { + const queryMock = jest.mocked(query); + + beforeEach(() => { + jest.resetAllMocks(); + + queryMock.mockResolvedValue(toHex(BALANCE_MOCK)); + }); + + describe('getNativeBalance', () => { + it('returns native balance', async () => { + const result = await getNativeBalance('0x1234', ETH_QUERY_MOCK); + + expect(result).toStrictEqual({ + balanceRaw: BALANCE_MOCK, + balanceHuman: '0.000021', + }); + }); + }); + + describe('isNativeBalanceSufficientForGas', () => { + it('returns true if balance is sufficient for gas', async () => { + const result = await isNativeBalanceSufficientForGas( + TRANSACTION_META_MOCK, + ETH_QUERY_MOCK, + ); + + expect(result).toBe(true); + }); + + it('returns false if balance is insufficient for gas', async () => { + const result = await isNativeBalanceSufficientForGas( + { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + gas: toHex(21001), + }, + }, + ETH_QUERY_MOCK, + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts new file mode 100644 index 00000000000..1a362d60c36 --- /dev/null +++ b/packages/transaction-controller/src/utils/balance.ts @@ -0,0 +1,52 @@ +import { query } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { TransactionMeta } from '..'; + +/** + * Get the native balance for an address. + * + * @param address - Address to get the balance for. + * @param ethQuery - EthQuery instance to use. + * @returns Balance in both human-readable and raw format. + */ +export async function getNativeBalance(address: Hex, ethQuery: EthQuery) { + const balanceRawHex = (await query(ethQuery, 'getBalance', [ + address, + 'latest', + ])) as Hex; + + const balanceRaw = new BigNumber(balanceRawHex).toString(10); + const balanceHuman = new BigNumber(balanceRaw).shiftedBy(-18).toString(10); + + return { + balanceHuman, + balanceRaw, + }; +} + +/** + * Determine if the native balance is sufficient to cover max gas cost. + * + * @param transaction - Transaction metadata. + * @param ethQuery - EthQuery instance. + * @returns True if the native balance is sufficient, false otherwise. + */ +export async function isNativeBalanceSufficientForGas( + transaction: TransactionMeta, + ethQuery: EthQuery, +): Promise { + const from = transaction.txParams.from as Hex; + + const gasCostRawValue = new BigNumber( + transaction.txParams.gas ?? '0x0', + ).multipliedBy( + transaction.txParams.maxFeePerGas ?? transaction.txParams.gasPrice ?? '0x0', + ); + + const { balanceRaw } = await getNativeBalance(from, ethQuery); + + return gasCostRawValue.isLessThanOrEqualTo(balanceRaw); +} diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 3e5ead32db8..b0b3385d3b7 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -289,6 +289,7 @@ async function addTransactionBatchWith7702( const { batchId: batchIdOverride, from, + gasFeeToken, networkClientId, origin, requireApproval, @@ -400,6 +401,7 @@ async function addTransactionBatchWith7702( const { result } = await addTransaction(txParams, { batchId, + gasFeeToken, isGasFeeIncluded: userRequest.isGasFeeIncluded, isGasFeeSponsored: userRequest.isGasFeeSponsored, nestedTransactions, diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts index febdf38b2c4..50579834418 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts @@ -1,10 +1,17 @@ +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; +import { isNativeBalanceSufficientForGas } from './balance'; import { doesChainSupportEIP7702 } from './eip7702'; import { getEIP7702UpgradeContractAddress } from './feature-flags'; import type { GetGasFeeTokensRequest } from './gas-fee-tokens'; -import { getGasFeeTokens } from './gas-fee-tokens'; +import { + checkGasFeeTokenBeforePublish, + getGasFeeTokens, +} from './gas-fee-tokens'; import type { + GasFeeToken, GetSimulationConfig, TransactionControllerMessenger, TransactionMeta, @@ -14,32 +21,41 @@ import { simulateTransactions } from '../api/simulation-api'; jest.mock('../api/simulation-api'); jest.mock('./eip7702'); jest.mock('./feature-flags'); +jest.mock('./balance'); const CHAIN_ID_MOCK = '0x1'; -const TOKEN_ADDRESS_1_MOCK = '0x1234567890abcdef1234567890abcdef12345678'; +const TOKEN_ADDRESS_1_MOCK = + '0x1234567890abcdef1234567890abcdef12345678' as Hex; const TOKEN_ADDRESS_2_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12'; const UPGRADE_CONTRACT_ADDRESS_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef'; +const TRANSACTION_META_MOCK = { + txParams: { + from: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + to: '0x1234567890abcdef1234567890abcdef1234567a', + value: '0x1000000000000000000', + data: '0x', + }, +} as TransactionMeta; + const REQUEST_MOCK: GetGasFeeTokensRequest = { chainId: CHAIN_ID_MOCK, isEIP7702GasFeeTokensEnabled: jest.fn().mockResolvedValue(true), getSimulationConfig: jest.fn(), messenger: {} as TransactionControllerMessenger, publicKeyEIP7702: '0x123', - transactionMeta: { - txParams: { - from: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', - to: '0x1234567890abcdef1234567890abcdef1234567a', - value: '0x1000000000000000000', - data: '0x', - }, - } as TransactionMeta, + transactionMeta: TRANSACTION_META_MOCK, }; describe('Gas Fee Tokens Utils', () => { const simulateTransactionsMock = jest.mocked(simulateTransactions); const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702); + + const isNativeBalanceSufficientForGasMock = jest.mocked( + isNativeBalanceSufficientForGas, + ); + const getEIP7702UpgradeContractAddressMock = jest.mocked( getEIP7702UpgradeContractAddress, ); @@ -50,6 +66,8 @@ describe('Gas Fee Tokens Utils', () => { getEIP7702UpgradeContractAddressMock.mockReturnValue( UPGRADE_CONTRACT_ADDRESS_MOCK, ); + + isNativeBalanceSufficientForGasMock.mockResolvedValue(false); }); describe('getGasFeeTokens', () => { @@ -376,4 +394,75 @@ describe('Gas Fee Tokens Utils', () => { ); }); }); + + describe('checkGasFeeTokenBeforePublish', () => { + let request: Parameters[0]; + + beforeEach(() => { + request = { + ethQuery: {} as EthQuery, + fetchGasFeeTokens: jest.fn(), + transaction: cloneDeep(TRANSACTION_META_MOCK), + updateTransaction: jest.fn(), + }; + }); + + it('throws if gas fee token not found in gas fee tokens', async () => { + request.transaction.isGasFeeTokenIgnoredIfBalance = true; + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.gasFeeTokens = []; + + await expect(checkGasFeeTokenBeforePublish(request)).rejects.toThrow( + 'Gas fee token not found and insufficient native balance', + ); + }); + + it('updates gas fee tokens if undefined', async () => { + request.transaction.isGasFeeTokenIgnoredIfBalance = true; + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.gasFeeTokens = undefined; + + jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([ + { + tokenAddress: TOKEN_ADDRESS_1_MOCK, + } as GasFeeToken, + ]); + + await checkGasFeeTokenBeforePublish(request); + + expect(request.fetchGasFeeTokens).toHaveBeenCalledTimes(1); + }); + + it('removes selected gas fee token if native balance sufficient', async () => { + request.transaction.isGasFeeTokenIgnoredIfBalance = true; + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.isExternalSign = true; + + isNativeBalanceSufficientForGasMock.mockResolvedValueOnce(true); + + await checkGasFeeTokenBeforePublish(request); + + jest + .mocked(request.updateTransaction) + .mock.calls[0][1](request.transaction); + + expect(request.transaction.selectedGasFeeToken).toBeUndefined(); + expect(request.transaction.isExternalSign).toBe(false); + }); + + it('does nothing if no selected gas fee token', async () => { + await checkGasFeeTokenBeforePublish(request); + + expect(request.updateTransaction).not.toHaveBeenCalled(); + }); + + it('does nothing if not ignoring gas fee token when native balance sufficient', async () => { + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.isGasFeeTokenIgnoredIfBalance = false; + + await checkGasFeeTokenBeforePublish(request); + + expect(request.updateTransaction).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index b317bec336e..73d10e6c86a 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -1,7 +1,9 @@ +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 { isNativeBalanceSufficientForGas } from './balance'; import { ERROR_MESSAGE_NO_UPGRADE_CONTRACT } from './batch'; import { ERROR_MESSGE_PUBLIC_KEY, doesChainSupportEIP7702 } from './eip7702'; import { getEIP7702UpgradeContractAddress } from './feature-flags'; @@ -115,6 +117,81 @@ export async function getGasFeeTokens({ } } +/** + * Check and update gas fee token selection before publishing a transaction. + * + * @param request - Request object. + * @param request.ethQuery - EthQuery instance. + * @param request.fetchGasFeeTokens - Function to fetch gas fee tokens. + * @param request.transaction - Transaction metadata. + * @param request.updateTransaction - Function to update the transaction. + */ +export async function checkGasFeeTokenBeforePublish({ + ethQuery, + fetchGasFeeTokens, + transaction, + updateTransaction, +}: { + ethQuery: EthQuery; + fetchGasFeeTokens: (transaction: TransactionMeta) => Promise; + transaction: TransactionMeta; + updateTransaction: ( + transactionId: string, + fn: (tx: TransactionMeta) => void, + ) => void; +}) { + const { gasFeeTokens, isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } = + transaction; + + if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) { + return; + } + + const hasNativeBalance = await isNativeBalanceSufficientForGas( + transaction, + ethQuery, + ); + + if (hasNativeBalance) { + log( + 'Ignoring gas fee token before publish due to sufficient native balance', + ); + + updateTransaction(transaction.id, (tx) => { + tx.isExternalSign = false; + tx.selectedGasFeeToken = undefined; + }); + + return; + } + + updateTransaction(transaction.id, (tx) => { + tx.isExternalSign = true; + }); + + let finalGasFeeTokens = gasFeeTokens; + + if (finalGasFeeTokens === undefined) { + const newGasFeeTokens = await fetchGasFeeTokens(transaction); + + updateTransaction(transaction.id, (tx) => { + tx.gasFeeTokens = newGasFeeTokens; + }); + + log('Updated gas fee tokens before publish', newGasFeeTokens); + + finalGasFeeTokens = newGasFeeTokens; + } + + if ( + !finalGasFeeTokens?.some( + (t) => t.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(), + ) + ) { + throw new Error('Gas fee token not found and insufficient native balance'); + } +} + /** * Extract gas fee tokens from a simulation response. * diff --git a/yarn.lock b/yarn.lock index 9f7926f4abd..257d3964e0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5086,6 +5086,7 @@ __metadata: "@types/jest": "npm:^27.4.1" "@types/node": "npm:^16.18.54" async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" eth-method-registry: "npm:^4.0.0"