Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Query layer 1 gas fee oracles via direct `eth_call` RPC requests instead of an ethers `Contract` backed by `Web3Provider` ([#9505](https://github.com/MetaMask/core/pull/9505))
- `Web3Provider` schedules its JSON-RPC dispatch with `setTimeout`, which never fires on React Native iOS when the timer pump is starved, blocking `addTransaction` indefinitely and preventing dapp confirmations from appearing ([MetaMask/metamask-mobile#32863](https://github.com/MetaMask/metamask-mobile/issues/32863))

## [69.0.0]

### Changed
Expand Down
1 change: 0 additions & 1 deletion packages/transaction-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
"@ethereumjs/util": "^9.1.0",
"@ethersproject/abi": "^5.7.0",
"@ethersproject/contracts": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
"@ethersproject/wallet": "^5.7.0",
"@metamask/accounts-controller": "^39.0.5",
"@metamask/approval-controller": "^9.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TransactionFactory } from '@ethereumjs/tx';
import type { TypedTransaction } from '@ethereumjs/tx';
import { Contract } from '@ethersproject/contracts';
import { Interface } from '@ethersproject/abi';
import type { Provider } from '@metamask/network-controller';
import { add0x } from '@metamask/utils';
import type { Hex } from '@metamask/utils';
Expand All @@ -10,14 +10,20 @@ import { CHAIN_IDS } from '../constants';
import type { TransactionControllerMessenger } from '../TransactionController';
import { TransactionStatus } from '../types';
import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
import { rpcRequest } from '../utils/provider';
import { bnFromHex, padHexToEvenLength } from '../utils/utils';
import { MantleLayer1GasFeeFlow } from './MantleLayer1GasFeeFlow';

jest.mock('@ethersproject/contracts', () => ({
Contract: jest.fn(),
}));
jest.mock('../utils/provider');

jest.mock('@ethersproject/providers');
const ORACLE_INTERFACE = new Interface([
'function getL1Fee(bytes _data)',
'function getOperatorFee(uint256 _gasUsed)',
'function tokenRatio()',
]);

const GET_L1_FEE_SELECTOR = ORACLE_INTERFACE.getSighash('getL1Fee');
const TOKEN_RATIO_SELECTOR = ORACLE_INTERFACE.getSighash('tokenRatio');

const TRANSACTION_PARAMS_MOCK = {
from: '0x123',
Expand All @@ -42,6 +48,8 @@ const TRANSACTION_META_TESTNET_MOCK: TransactionMeta = {
txParams: TRANSACTION_PARAMS_MOCK,
};

const MESSENGER_MOCK = {} as TransactionControllerMessenger;

const SERIALIZED_TRANSACTION_MOCK = '0x1234';
// L1 fee in ETH (returned by oracle)
const L1_FEE_MOCK = '0x0de0b6b3a7640000'; // 1e18 (1 ETH in wei)
Expand Down Expand Up @@ -69,37 +77,42 @@ function createMockTypedTransaction(
}

describe('MantleLayer1GasFeeFlow', () => {
const contractMock = jest.mocked(Contract);
const contractGetL1FeeMock: jest.MockedFn<() => Promise<BN>> = jest.fn();
const contractGetOperatorFeeMock: jest.MockedFn<() => Promise<BN>> =
jest.fn();
const contractTokenRatioMock: jest.MockedFn<() => Promise<BN>> = jest.fn();
const rpcRequestMock = jest.mocked(rpcRequest);
const getL1FeeMock: jest.MockedFn<() => Promise<unknown>> = jest.fn();
const getOperatorFeeMock: jest.MockedFn<() => Promise<unknown>> = jest.fn();
const tokenRatioMock: jest.MockedFn<() => Promise<unknown>> = jest.fn();

let request: Layer1GasFeeFlowRequest;

beforeEach(() => {
request = {
messenger: MESSENGER_MOCK,
provider: {} as Provider,
transactionMeta: TRANSACTION_META_MOCK,
};

contractMock.mockClear();
contractGetL1FeeMock.mockClear();
contractGetOperatorFeeMock.mockClear();
contractTokenRatioMock.mockClear();

contractGetL1FeeMock.mockResolvedValue(bnFromHex(L1_FEE_MOCK));
contractGetOperatorFeeMock.mockResolvedValue(new BN(0));
contractTokenRatioMock.mockResolvedValue(TOKEN_RATIO_MOCK);

// The base class creates a contract first (for getL1Fee/getOperatorFee),
// then transformOracleFee creates a second contract (for tokenRatio).
// Both use the same mock constructor.
contractMock.mockReturnValue({
getL1Fee: contractGetL1FeeMock,
getOperatorFee: contractGetOperatorFeeMock,
tokenRatio: contractTokenRatioMock,
} as unknown as Contract);
rpcRequestMock.mockClear();
getL1FeeMock.mockClear();
getOperatorFeeMock.mockClear();
tokenRatioMock.mockClear();

getL1FeeMock.mockResolvedValue(L1_FEE_MOCK);
getOperatorFeeMock.mockResolvedValue('0x0');
tokenRatioMock.mockResolvedValue(add0x(TOKEN_RATIO_MOCK.toString(16)));

rpcRequestMock.mockImplementation(async (args) => {
const { data } = (args.params as [{ data: Hex }, string])[0];

if (data.startsWith(GET_L1_FEE_SELECTOR)) {
return await getL1FeeMock();
}

if (data.startsWith(TOKEN_RATIO_SELECTOR)) {
return await tokenRatioMock();
}

return await getOperatorFeeMock();
});
});

describe('matchesTransaction', () => {
Expand Down Expand Up @@ -153,9 +166,7 @@ describe('MantleLayer1GasFeeFlow', () => {
},
};

contractGetOperatorFeeMock.mockResolvedValueOnce(
bnFromHex(OPERATOR_FEE_MOCK),
);
getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK);

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand All @@ -173,17 +184,15 @@ describe('MantleLayer1GasFeeFlow', () => {
bnFromHex(OPERATOR_FEE_MOCK),
);

expect(contractTokenRatioMock).toHaveBeenCalledTimes(1);
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(tokenRatioMock).toHaveBeenCalledTimes(1);
expect(getOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(response).toStrictEqual({
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
});
});

it('falls back to txParams.gas for operator fee when gasUsed is missing', async () => {
contractGetOperatorFeeMock.mockResolvedValueOnce(
bnFromHex(OPERATOR_FEE_MOCK),
);
getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK);

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand All @@ -201,9 +210,19 @@ describe('MantleLayer1GasFeeFlow', () => {
bnFromHex(OPERATOR_FEE_MOCK),
);

expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(
TRANSACTION_PARAMS_MOCK.gas,
expect(getOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(rpcRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: 'eth_call',
params: [
expect.objectContaining({
data: ORACLE_INTERFACE.encodeFunctionData('getOperatorFee', [
TRANSACTION_PARAMS_MOCK.gas,
]),
}),
'latest',
],
}),
);
expect(response).toStrictEqual({
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
Expand All @@ -223,9 +242,7 @@ describe('MantleLayer1GasFeeFlow', () => {
},
};

contractGetOperatorFeeMock.mockResolvedValueOnce(
bnFromHex(OPERATOR_FEE_MOCK),
);
getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK);

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand All @@ -243,8 +260,20 @@ describe('MantleLayer1GasFeeFlow', () => {
bnFromHex(OPERATOR_FEE_MOCK),
);

expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasLimit);
expect(getOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(rpcRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: 'eth_call',
params: [
expect.objectContaining({
data: ORACLE_INTERFACE.encodeFunctionData('getOperatorFee', [
gasLimit,
]),
}),
'latest',
],
}),
);
expect(response).toStrictEqual({
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
});
Expand Down Expand Up @@ -272,7 +301,7 @@ describe('MantleLayer1GasFeeFlow', () => {

const expectedL1FeeInMnt = bnFromHex(L1_FEE_MOCK).mul(TOKEN_RATIO_MOCK);

expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
expect(getOperatorFeeMock).not.toHaveBeenCalled();
expect(response).toStrictEqual({
layer1Fee: add0x(padHexToEvenLength(expectedL1FeeInMnt.toString(16))),
});
Expand All @@ -288,7 +317,7 @@ describe('MantleLayer1GasFeeFlow', () => {
},
};

contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert'));
getOperatorFeeMock.mockRejectedValueOnce(new Error('revert'));

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand All @@ -309,7 +338,25 @@ describe('MantleLayer1GasFeeFlow', () => {
});

it('throws if tokenRatio call fails', async () => {
contractTokenRatioMock.mockRejectedValue(new Error('error'));
tokenRatioMock.mockRejectedValue(new Error('error'));

jest
.spyOn(TransactionFactory, 'fromTxData')
.mockReturnValueOnce(
createMockTypedTransaction(
Buffer.from(SERIALIZED_TRANSACTION_MOCK, 'hex'),
),
);

const flow = new MantleLayer1GasFeeFlow();

await expect(flow.getLayer1Fee(request)).rejects.toThrow(
'Failed to get oracle layer 1 gas fee',
);
});

it('throws if tokenRatio call returns empty result', async () => {
tokenRatioMock.mockResolvedValue('0x');

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand Down Expand Up @@ -362,9 +409,7 @@ describe('MantleLayer1GasFeeFlow', () => {
},
};

contractGetOperatorFeeMock.mockResolvedValueOnce(
bnFromHex(OPERATOR_FEE_MOCK),
);
getOperatorFeeMock.mockResolvedValueOnce(OPERATOR_FEE_MOCK);

jest
.spyOn(TransactionFactory, 'fromTxData')
Expand All @@ -382,8 +427,8 @@ describe('MantleLayer1GasFeeFlow', () => {
bnFromHex(OPERATOR_FEE_MOCK),
);

expect(contractTokenRatioMock).toHaveBeenCalledTimes(1);
expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(tokenRatioMock).toHaveBeenCalledTimes(1);
expect(getOperatorFeeMock).toHaveBeenCalledTimes(1);
expect(response).toStrictEqual({
layer1Fee: add0x(padHexToEvenLength(expectedTotal.toString(16))),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Contract } from '@ethersproject/contracts';
import { Web3Provider } from '@ethersproject/providers';
import type { ExternalProvider } from '@ethersproject/providers';
import { Interface } from '@ethersproject/abi';
import type { Hex } from '@metamask/utils';
import type BN from 'bn.js';

import { CHAIN_IDS } from '../constants';
import type { TransactionControllerMessenger } from '../TransactionController';
import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
import { rpcRequest } from '../utils/provider';
import { toBN } from '../utils/utils';
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';

Expand All @@ -28,6 +27,8 @@ const TOKEN_RATIO_ABI = [
},
];

const TOKEN_RATIO_INTERFACE = new Interface(TOKEN_RATIO_ABI);

/**
* Mantle layer 1 gas fee flow.
*
Expand Down Expand Up @@ -60,18 +61,31 @@ export class MantleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
oracleFee: BN,
request: Layer1GasFeeFlowRequest,
): Promise<BN> {
const { provider, transactionMeta } = request;
const oracleAddress = this.getOracleAddressForChain(
transactionMeta.chainId,
);
const { messenger, transactionMeta } = request;
const { chainId, networkClientId } = transactionMeta;

const contract = new Contract(
oracleAddress,
TOKEN_RATIO_ABI,
new Web3Provider(provider as unknown as ExternalProvider),
);
const to = this.getOracleAddressForChain(chainId);
const data = TOKEN_RATIO_INTERFACE.encodeFunctionData(
'tokenRatio',
[],
) as Hex;

// Direct `eth_call` RPC request rather than an ethers `Contract` with
// `Web3Provider`, whose `setTimeout`-based dispatch never fires on React
// Native when the timer pump is starved (e.g. iOS display link freeze).
// See https://github.com/MetaMask/metamask-mobile/issues/32863
const result = await rpcRequest({
messenger,
networkClientId,
method: 'eth_call',
params: [{ to, data }, 'latest'],
});

if (typeof result !== 'string' || result === '0x') {
throw new Error('No value returned from token ratio contract');
}

const tokenRatio = toBN(await contract.tokenRatio());
const tokenRatio = toBN(result);
return oracleFee.mul(tokenRatio);
}
}
Loading