Currently, we limit the memory consumption of the EVM in two ways: a quadratic memory expansion cost and a de-facto call stack depth limit enforced with the EIP-150 “63/64 rule”. These mechanisms are reasonably effective at their desired goal, but are both needlessly complicated and inefficient, punishing regular users too much and DoS attackers too little. This post proposes some alternative designs that better meet the key goal of limiting EVM memory usage.
Extending memory in a callframe to L chunks (so, L∗32 bytes) requires paying ⌊L2512⌋+3L gas.
This is charged in real time. For example, if memory is currently 16 chunks long, and a MSTORE
op fills bytes 682...713
, then memory is extended to 23 chunks, and (⌊232512⌋+3∗23)−(⌊162512⌋+3∗16)= (1+69)−(0+48)=22 extra memory expansion gas is charged. See implementation here.
Note that this is on a per-call-frame basis: if a call makes a child call, memory in the child call is initialized as empty and pricing for it starts from zero.
To prevent excessive memory consumption by having a contract recursively call itself and fill many callframes with a medium amount of memory where the quadratic costs do not yet seriously kick in for each frame, there is a limit on how many callframes can be made.
When a contract makes a call, the child is only allowed to receive up to 6364 of the remaining gas. This means that a call frame d levels deep can receive at most G∗(6364)d gas (where G is the transaction gas limit).
Given that making a call requires at least 114 gas (100 for the CALL
post-EIP-2929 plus 14 to fill the stack arguments), and a block can have at most 30 million gas, naively we can compute that the call stack depth cannot exceed log(11430000000)÷log(6364)=792 calls. If we take a more sophisticated calculation and include the gas cost of all those calls, this goes down to 528. In reality, 114 gas is almost certainly not enough and the true limit is under 500.
There is also an explicit call stack depth limit of 1024. The 63/64 mechanism was implemented to supersede it (the limit exists in code but is not reachable) both to reduce the de-facto limit to weaken DoS attacks and to prevent scenarios where malicious contracts self-call 1023 times before calling another contract, causing that contract to behave in an unexpected way.
With 30 million gas, you can fill 123169 chunks (3941408 bytes) of memory in a single callframe, as 123169 is the highest L that satisfies ⌊L2512⌋+3L≤30000000.
You could make many callframes and fill each callframe with N bytes. Here, the optimum is 88 callframes with 8374 chunks each, which would fill a total of 736912 chunks (23581184 bytes).
A more clever strategy is to put more memory into outer call frames, as inner call frames have a higher cost due to the repeated application of the 6364 rule. For example, you could fill the i’th call frame with k1∗(1+1k2)i bytes. The optimum here is k1=798 and k2=54, for a total of 166 calls filling 839602 chunks = 26867264 bytes (note: these numbers are slightly wrong due to using a simplified model, but at most by a couple percent).
Code for this model is here:
def get_max_chunks(chunks_per_depth, inv_growth_rate):
tot = 0
next_call = 0
depth = 0
while next_call < 30000000:
depth += 1
tot += chunks_per_depth
chunks_per_depth += chunks_per_depth // inv_growth_rate
next_call = int(
(next_call * 64/63) +
120 +
(3 * chunks_per_depth + chunks_per_depth**2 / 512)
)
return tot
The most important conclusion from all this is that the current memory pricing mechanism has weird dynamics and is dumb. We don’t even have a good clear way to calculate the max memory that can be used in a block.
Some major issues with the model in its current form are:
Parameter | Value | Notes |
---|---|---|
GAS_PER_MEMORY_BYTE |
1 | 30m bytes for a 30m gas transaction. ~12% higher than status quo theoretical max |
BASE_CALLFRAME_BYTES |
1024 | Assume an overhead of 1024 bytes per callframe. |
This function returns our estimate of the number of bytes required to store the current call frame in memory:
def get_callframe_byte_size(callframe: CallFrame):
return (
len(callframe.memory) +
32 * len(callframe.stack) +
BASE_CALLFRAME_BYTES
)
Add this code to quickly test code samples in python:
from dataclasses import dataclass
@dataclass
class CallFrame():
memory: bytes
stack: list
def integer_squareroot(x):
return int(x**0.5)
call_gas // GAS_PER_MEMORY_BYTE
bytes. Attempts to extend memory to greater than that limit exit with an error.gas - get_callframe_byte_size(parent_callframe) * GAS_PER_MEMORY_BYTE
gas.CALLDATACOPY
(so, 3 gas per 32 byte chunk)This creates two hard invariants:
30m / GAS_PER_MEMORY_BYTE
.30000000 // 1024 = 29296
We solve many key problems:
Because this proposal removes the quadratic rule, it is weaker than it could be in one way. Transactions that specify a low amount of gas get a smaller amount of memory that they can access: while a 3m gas transaction would get 3m bytes of memory instead of 1229792, a 100k gas transaction would get 100000 bytes instead off 205696. However, this is much less bad than it seems, because in the status quo the transaction has to pay for all this gas, whereas in this new proposal it does not.
max_memory
parameter; memory expansion beyond that value is forbidden. The outermost call frame gets max_memory = block.gaslimit // GAS_PER_MEMORY_BYTE
max_memory = parent_callframe.max_memory - get_callframe_byte_size(parent_callframe)
CALLDATACOPY
(so, 3 gas per 32 byte chunk)This gives us the same invariants as above, but ensures that each transaction gets 30m bytes.
The main weakness of this approach is that it adds another parameter to calls, and it brings back the security issue that motivated the replacement of call stack depth with the 63/64 rule in the first place: it means that gas would once again no longer be a sufficient guarantee of what computation a call frame can do. An attacker could expand to 29.9m memory in an outer call frame, with the goal of crashing inner call frames.
Parameter | Value | Notes |
---|---|---|
MEMORY_BYTES_PER_SQRT_GAS |
4096 | Theoretical max of 22434715 bytes in a transaction |
Remove the 63/64 rule, and charge 3 gas per 32 byte chunk for copying data when returning from a call. Set the max memory of a call frame to integer_squareroot(gas * MEMORY_BYTES_PER_SQRT_GAS**2)
. Set the max gas of a child based on the following formula:
def get_max_child_gas(current_gas, parent_callframe):
adjusted_sqrt_current_gas = integer_squareroot(
current_gas * MEMORY_BYTES_PER_SQRT_GAS**2
)
subtract_bytes = get_callframe_byte_size(parent_callframe)
if adjusted_sqrt_current_gas < subtract_bytes:
raise Exception("Parent call frame used more memory than allowed!")
adjusted_sqrt_remaining_gas = adjusted_sqrt_current_gas - subtract_bytes
return (
adjusted_sqrt_remaining_gas**2 //
MEMORY_BYTES_PER_SQRT_GAS**2
)
This is a version of proposal 1 that is more friendly to low-gas transactions: a 100k gas transaction would get 1295268 bytes (compare: 100k in proposal 1), and a 3m gas transaction would get 7094480 bytes (compare: 3m in proposal 1). It works by using sqrt(gas)
as the “tracker” of allowed remaining memory instead of gas
.
Its main weakness is simply the fact that it is somewhat less intuitive and more complicated; you have to think a bit harder to make sure that it actually satisfies the invariant that total_bytes <= sqrt(gas) * 4096
.