feat(evm): compute intrinsic gas limit for send on evm#14801
feat(evm): compute intrinsic gas limit for send on evm#14801Moustafa-Koterba wants to merge 3 commits intodevelopfrom
Conversation
dc1e556 to
888dea6
Compare
There was a problem hiding this comment.
Pull request overview
This PR implements dynamic computation of the intrinsic gas limit for EVM transactions, replacing the previous static 21,000 gas limit with a formula that accounts for transaction data: 21000 + 4 * zero_bytes + 16 * non_zero_bytes. This aligns with the Ethereum Yellow Paper specification for intrinsic gas costs.
Changes:
- Adds
computeIntrinsicGasLimitfunction to calculate gas based on call data - Updates
validateIntentto use computed intrinsic gas limits instead of static values - Modifies
getCallDatato return empty buffer when recipient is missing (preventing invalid ERC20 data generation)
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
libs/coin-modules/coin-evm/src/logic/computeGasLimit.ts |
New file implementing intrinsic gas limit calculation based on callData byte counts |
libs/coin-modules/coin-evm/src/logic/computeGasLimit.test.ts |
Test suite for intrinsic gas computation with various ERC20 transfer scenarios |
libs/coin-modules/coin-evm/src/logic/validateIntent.ts |
Updated gas validation to use computed intrinsic limits; changed jest.restoreAllMocks to jest.clearAllMocks |
libs/coin-modules/coin-evm/src/logic/validateIntent.test.ts |
Added mock setup for computeIntrinsicGasLimit and updated test cases |
libs/coin-modules/coin-evm/src/logic/common.ts |
Exported getCallData and added !intent.recipient guard |
libs/coin-modules/coin-evm/tsconfig.json |
Added newline at end of file (formatting) |
.changeset/eleven-countries-pull.md |
Changeset documenting the feature addition |
| return isNative(intent.asset) || !intent.recipient | ||
| ? Buffer.from([]) | ||
| : getErc20Data(intent.recipient, intent.amount); |
There was a problem hiding this comment.
Do we have cases with falsy recipients (like "") ?
There was a problem hiding this comment.
as discussed IRL, it happens sometimes on Ledger Wallet, when starting a send
| }), | ||
| ); | ||
|
|
||
| expect(computeGasLimitModule.computeIntrinsicGasLimit).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
It is not useful to know which internal function is called. We don't want to test the implementation
| expect(computeGasLimitModule.computeIntrinsicGasLimit).toHaveBeenCalledTimes(1); |
There was a problem hiding this comment.
as discussed IRL, need to be discussed with the team
There was a problem hiding this comment.
We never discussed that. We only talked about mocking dependencies, not about expecting dependencies are called X amount of times. Because this info is not crucial for the test itself, it means we are testing the implementation ... which is in any case not what we want.
748c3ed
|
francois-guerin-ledger
left a comment
There was a problem hiding this comment.
We must not check if recipient is empty string an silently failing here. Instead, we could have another PR
| } as unknown as TransactionIntent<MemoNotSupported, BufferTxData>; | ||
|
|
||
| const result = getCallData(intent); | ||
| expect(result).toEqual(intent.data.value); |
There was a problem hiding this comment.
Using some part of the function input as output expectation is not a good practice. Imagine getCallData mutates intent, before returning the result. Values will be equal indeed, but it won't be what you did input due to the internal mutation.
Best practice is always work with static values when dealing with tests
| it("should return empty buffer for native asset", () => { | ||
| const intent = { | ||
| asset: { | ||
| type: "native", | ||
| }, | ||
| } as unknown as TransactionIntent<MemoNotSupported, BufferTxData>; | ||
|
|
||
| const result = getCallData(intent); | ||
| expect(result).toEqual(Buffer.from([])); | ||
| }); | ||
|
|
||
| it("should return empty buffer for non native asset and no recipient", () => { | ||
| const intent = { | ||
| asset: { | ||
| type: "erc20", | ||
| }, | ||
| } as unknown as TransactionIntent<MemoNotSupported, BufferTxData>; | ||
|
|
||
| const result = getCallData(intent); | ||
| expect(result).toEqual(Buffer.from([])); | ||
| }); | ||
|
|
||
| it("should return empty buffer for non native asset with recipient and no amount", () => { | ||
| const intent = { | ||
| asset: { | ||
| type: "erc20", | ||
| }, | ||
| recipient: "0x66c4371aE8FFeD2ec1c2EBbbcCfb7E494181E1E3", | ||
| } as unknown as TransactionIntent<MemoNotSupported, BufferTxData>; | ||
|
|
||
| const result = getCallData(intent); | ||
| expect(result).toEqual(Buffer.from([])); | ||
| }); |
There was a problem hiding this comment.
Use it.each to be more compact
d688df6 to
a380206
Compare
| ), | ||
| }, | ||
| } as unknown as TransactionIntent<MemoNotSupported, BufferTxData>; | ||
| const expectedResult = Buffer.from(intent.data.value); |
There was a problem hiding this comment.
intent.data.value is already a buffer. Also you are still using the input as output expectation
| intent.amount === undefined || | ||
| intent.amount === null |
There was a problem hiding this comment.
intent.amount is a bigint, how can it be undefined or null ? Your previous version was good, because it covers 0n.
a380206 to
6fc7a62
Compare
| (computeGasLimitModule.computeIntrinsicGasLimit as jest.Mock).mockImplementation( | ||
| jest.requireActual("./computeGasLimit").computeIntrinsicGasLimit, | ||
| ); | ||
| // ... other setup |
There was a problem hiding this comment.
The comment // ... other setup appears to be a placeholder left in from a template or draft. It should be removed before merging.
| // ... other setup |
| }); | ||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| jest.clearAllMocks(); |
There was a problem hiding this comment.
Switching from jest.restoreAllMocks() to jest.clearAllMocks() in afterEach is a regression. jest.restoreAllMocks() also restores the original implementation of jest.spyOn mocks (e.g., the ledgerNode.getTransactionCount spy set up in beforeEach), whereas jest.clearAllMocks() only resets call counts and instances but does not restore them. This means spy mocks set up with jest.spyOn will persist across tests in an unrestored state, potentially causing interference between tests.
| jest.clearAllMocks(); | |
| jest.restoreAllMocks(); |
| export function computeIntrinsicGasLimit(defaultGasLimit: bigint, callData: Buffer): bigint { | ||
| let intrinsicGasLimit = defaultGasLimit; | ||
| for (let index = 0; index < callData.length; index++) { | ||
| intrinsicGasLimit += callData[index] === 0 ? 4n : 16n; | ||
| } | ||
|
|
||
| return intrinsicGasLimit; | ||
| } |
There was a problem hiding this comment.
The function lacks a docstring. Given that this implements a specific EVM spec formula (intrinsic gas = 21,000 + 4 × zero bytes + 16 × non-zero bytes), a brief comment linking to the spec and describing the parameters and return value would improve maintainability.
|


✅ Checklist
npx changesetwas attached.📝 Description
We were using a static value for the default minimal gas needed for sending ETH. This task aims to improve this behaviour and be compliant with https://github.com/wolflo/evm-opcodes/blob/main/gas.md#a0-0-intrinsic-gas. The following formula has been implemented:
For basic ethereum, the gas limit stays at 21 000
For ethereum tokens, the above formula is used
❓ Context
🧐 Checklist for the PR Reviewers