diff --git a/packages/transaction-controller/jest.config.js b/packages/transaction-controller/jest.config.js index 4a6ed4accc0..b9ce3711df5 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: 93.09, lines: 96.83, statements: 96.82, }, diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 63438379f7e..82ec0d96a86 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..a2bbca798c7 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -119,6 +119,7 @@ import type { GetSimulationConfig, AddTransactionOptions, PublishHookResult, + GetGasFeeTokensRequest, } from './types'; import { GasFeeEstimateLevel, @@ -137,7 +138,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 { + getGasFeeTokens, + processGasFeeTokensNoApproval, +} from './utils/gas-fee-tokens'; import { updateGasFees } from './utils/gas-fees'; import { getGasFeeFlow } from './utils/gas-flow'; import { @@ -378,6 +382,11 @@ export type TransactionControllerEmulateTransactionUpdate = { handler: TransactionController['emulateTransactionUpdate']; }; +export type TransactionControllerGetGasFeeTokensAction = { + type: `${typeof controllerName}:getGasFeeTokens`; + handler: (request: GetGasFeeTokensRequest) => Promise; +}; + /** * The internal actions available to the TransactionController. */ @@ -386,6 +395,7 @@ export type TransactionControllerActions = | TransactionControllerAddTransactionBatchAction | TransactionControllerConfirmExternalTransactionAction | TransactionControllerEstimateGasAction + | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetNonceLockAction | TransactionControllerGetStateAction | TransactionControllerGetTransactionsAction @@ -1215,6 +1225,7 @@ export class TransactionController extends BaseController< batchId, deviceConfirmedOn, disableGasBuffer, + gasFeeToken, isGasFeeIncluded, isGasFeeSponsored, method, @@ -1315,6 +1326,7 @@ export class TransactionController extends BaseController< deviceConfirmedOn, disableGasBuffer, id: random(), + isGasFeeTokenIgnoredIfBalance: Boolean(gasFeeToken), isGasFeeIncluded, isGasFeeSponsored, isFirstTimeInteraction: undefined, @@ -1322,6 +1334,7 @@ export class TransactionController extends BaseController< networkClientId, origin, securityAlertResponse, + selectedGasFeeToken: gasFeeToken, status: TransactionStatus.unapproved as const, time: Date.now(), txParams, @@ -1406,6 +1419,15 @@ export class TransactionController extends BaseController< log('Error while updating first interaction properties', error); }); } else { + await processGasFeeTokensNoApproval({ + ethQuery, + fetchGasFeeTokens: async (tx) => + (await this.#getGasFeeTokens(tx)).gasFeeTokens, + transaction: addedTransactionMeta, + updateTransaction: (transactionId, fn) => + this.#updateTransactionInternal({ transactionId }, fn), + }); + log( 'Skipping simulation & first interaction update as approval not required', ); @@ -4255,14 +4277,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; } @@ -4477,6 +4493,11 @@ export class TransactionController extends BaseController< `${controllerName}:emulateTransactionUpdate`, this.emulateTransactionUpdate.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:getGasFeeTokens`, + this.#getGasFeeTokensAction.bind(this), + ); } #deleteTransaction(transactionId: string) { @@ -4637,4 +4658,34 @@ 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, + }); + } + + async #getGasFeeTokensAction(request: GetGasFeeTokensRequest) { + const { chainId, data, from, to, value } = request; + + const ethQuery = this.#getEthQuery({ chainId }); + const delegationAddress = await getDelegationAddress(from, ethQuery); + + const transactionMeta = { + chainId, + delegationAddress, + txParams: { data, from, to, value }, + } as TransactionMeta; + + const result = await this.#getGasFeeTokens(transactionMeta); + + return result.gasFeeTokens; + } } diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index b23a567785f..837dfcf8942 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; @@ -2106,3 +2115,11 @@ export type AddTransactionOptions = { /** Type of transaction to add, such as 'cancel' or 'swap'. */ type?: TransactionType; }; + +export type GetGasFeeTokensRequest = { + chainId: Hex; + data?: Hex; + from: Hex; + to: Hex; + value?: Hex; +}; 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..320fbe1e52b --- /dev/null +++ b/packages/transaction-controller/src/utils/balance.test.ts @@ -0,0 +1,33 @@ +import { query, toHex } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; + +import { getNativeBalance } from './balance'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + query: jest.fn(), +})); + +const ETH_QUERY_MOCK = {} as EthQuery; +const BALANCE_MOCK = '123456789123456789123456789'; + +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: '123456789.123456789123456789', + }); + }); + }); +}); diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts new file mode 100644 index 00000000000..50caaefbb04 --- /dev/null +++ b/packages/transaction-controller/src/utils/balance.ts @@ -0,0 +1,26 @@ +import { query } from '@metamask/controller-utils'; +import type EthQuery from '@metamask/eth-query'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +/** + * 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, + }; +} 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..9f78a03b698 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 { getNativeBalance } 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 { + getGasFeeTokens, + processGasFeeTokensNoApproval, +} from './gas-fee-tokens'; import type { + GasFeeToken, GetSimulationConfig, TransactionControllerMessenger, TransactionMeta, @@ -14,10 +21,14 @@ import { simulateTransactions } from '../api/simulation-api'; jest.mock('../api/simulation-api'); jest.mock('./eip7702'); jest.mock('./feature-flags'); +jest.mock('./balance'); +const ETH_QUERY_MOCK = {} as EthQuery; const CHAIN_ID_MOCK = '0x1'; const TOKEN_ADDRESS_1_MOCK = '0x1234567890abcdef1234567890abcdef12345678'; const TOKEN_ADDRESS_2_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12'; +const GAS_FEE_TOKEN_MOCK = { amount: '0x123' as Hex } as GasFeeToken; + const UPGRADE_CONTRACT_ADDRESS_MOCK = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef'; @@ -37,6 +48,13 @@ const REQUEST_MOCK: GetGasFeeTokensRequest = { } as TransactionMeta, }; +const TRANSACTION_META_MOCK = { + selectedGasFeeToken: '0x1234567890abcdef1234567890abcdef12345678' as Hex, + txParams: { + from: '0x456', + }, +} as TransactionMeta; + describe('Gas Fee Tokens Utils', () => { const simulateTransactionsMock = jest.mocked(simulateTransactions); const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702); @@ -376,4 +394,77 @@ describe('Gas Fee Tokens Utils', () => { ); }); }); + + describe('processGasFeeTokensNoApproval', () => { + const fetchGasFeeTokensMock = jest.fn(); + const updateTransactionMock = jest.fn(); + const getNativeBalanceMock = jest.mocked(getNativeBalance); + + let request: Parameters[0]; + + beforeEach(() => { + fetchGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + getNativeBalanceMock.mockResolvedValue({ + balanceRaw: '1000', + balanceHuman: '0', + }); + + request = { + ethQuery: ETH_QUERY_MOCK, + fetchGasFeeTokens: fetchGasFeeTokensMock, + transaction: cloneDeep(TRANSACTION_META_MOCK), + updateTransaction: updateTransactionMock, + }; + }); + + it('updates gas fee tokens if selected gas fee token', async () => { + await processGasFeeTokensNoApproval(request); + + const txDraft = {}; + updateTransactionMock.mock.calls[0][1](txDraft); + + expect(txDraft).toStrictEqual({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + }); + }); + + it('does nothing if no selected gas fee token', async () => { + request.transaction.selectedGasFeeToken = undefined; + + await processGasFeeTokensNoApproval(request); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + }); + + it('removes selected gas fee token if sufficient balance and isGasFeeTokenIgnoredIfBalance is true', async () => { + request.transaction.isGasFeeTokenIgnoredIfBalance = true; + request.transaction.txParams.gas = '0x2'; + request.transaction.txParams.gasPrice = '0x1f4'; + + await processGasFeeTokensNoApproval(request); + + const txDraft = {}; + updateTransactionMock.mock.calls[0][1](txDraft); + + expect(txDraft).toStrictEqual({ + selectedGasFeeToken: undefined, + }); + }); + + it('updates gas fee tokens if insufficient balance and isGasFeeTokenIgnoredIfBalance is true', async () => { + request.transaction.isGasFeeTokenIgnoredIfBalance = true; + request.transaction.txParams.gas = '0x1'; + request.transaction.txParams.maxFeePerGas = '0x3e9'; + + await processGasFeeTokensNoApproval(request); + + const txDraft = {}; + updateTransactionMock.mock.calls[0][1](txDraft); + + expect(txDraft).toStrictEqual({ + gasFeeTokens: [GAS_FEE_TOKEN_MOCK], + }); + }); + }); }); diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index b317bec336e..99cf82bdc45 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -1,7 +1,10 @@ +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 { BigNumber } from 'bignumber.js'; +import { getNativeBalance } 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 +118,81 @@ export async function getGasFeeTokens({ } } +/** + * Process gas fee tokens for a transaction without approval. + * + * @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 processGasFeeTokensNoApproval({ + ethQuery, + fetchGasFeeTokens, + transaction, + updateTransaction, +}: { + ethQuery: EthQuery; + fetchGasFeeTokens: (transaction: TransactionMeta) => Promise; + transaction: TransactionMeta; + updateTransaction: ( + transactionId: string, + fn: (tx: TransactionMeta) => void, + ) => void; +}) { + const { + id: transactionId, + isGasFeeTokenIgnoredIfBalance, + selectedGasFeeToken, + txParams: { from }, + } = transaction; + + if (!selectedGasFeeToken) { + return; + } + + if (isGasFeeTokenIgnoredIfBalance) { + const gasCostRawValue = new BigNumber( + transaction.txParams.gas ?? '0x0', + ).multipliedBy( + transaction.txParams.maxFeePerGas ?? + transaction.txParams.gasPrice ?? + '0x0', + ); + + const gasCostRaw = gasCostRawValue.toString(10); + + const { balanceRaw } = await getNativeBalance(from as Hex, ethQuery); + + if (gasCostRawValue.isLessThanOrEqualTo(balanceRaw)) { + log('Ignoring gas fee token as sufficient native balance', { + balanceRaw, + gasCostRaw, + }); + + updateTransaction(transactionId, (tx) => { + tx.selectedGasFeeToken = undefined; + }); + + return; + } + + log('Using gas fee token as insufficient native balance', { + balanceRaw, + gasCostRaw, + }); + } + + const gasFeeTokens = await fetchGasFeeTokens(transaction); + + log('Fetched gas fee tokens for transaction without approval', gasFeeTokens); + + updateTransaction(transactionId, (tx) => { + tx.gasFeeTokens = gasFeeTokens; + }); +} + /** * Extract gas fee tokens from a simulation response. * diff --git a/yarn.lock b/yarn.lock index 31a31d7499e..926d0ac6d57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5071,6 +5071,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"