Frontier: Support for EVM Weight V2

Challenges

Currently, the gas used in the EVM matches (more or less) the cpu time
being used by the OPCODEs executed. However, with the limitation of the PoV size
required by the relaychain, it is not enough to just rely on the cpu time.

  1. How do we account for the Proof Size when executing an EVM transaction ?
  2. How do we restrict the Proof Size of an EVM Transaction ?

1. Accounting for the Proof Size

The solution we are proposing is to change the EVM Gasometer to add another dimension to the execution, counting every read to the storage and comparing to a given limit (see 2.).

In the case the limit of the Proof Size is reached, a revert message with “out-of-proof-size” (probably converted to “out-of-gas” for compatibility) will be triggered.

Subcall accounting

At the difference of the gasLimit, which can be provided in a subcall, the Proof Size limit will be independant and will always be to the remaining proof size when executing the subcall.

Once the PR for using a dedicated storage for the Smart Contract code size is merged, it will be possible to check and charge directly the amount of Proof Size needed to perform a sub-call

Example:

  1. Smart Contract A calls Smart contract B (5kB)
    • Gasometer: checks proof size > 5kB, reduces proof size remaining by 5kB
  2. Smart Contract B accesses 10 storages of 32 bytes
    • Gasometer: checks proof size > 32 bytes (for each access), reduces total proof size remaining by 320B
  3. Smart Contract B calls Smart Contract C (9kB) with 10_000 gasLimit
    • Gasometer: checks proof size > 9k (doesn’t matter the gasLimit value), reduces proof size remaining by 9kB

In order to succeed, the proof size limit should be at least 5kB + 320B + 9kB=> ~15kB

2. Computing the Proof Size limit

In order to avoid Ethereum Transactions from abusing the Proof Size,
each transaction needs to have an associated limit for the proof size.

When being executed from XCM, this will be taken directly from the BuyExecution.
However, when coming from an Ethereum Transaction, we need a way to convert the gas limit into a proof size limit.

Currently, blocks are limited to 5Mb PoV (This limit applied to the compressed Pov, but for simplicity I’ll consider only the uncompressed Proof Size), so a simple solution is to use a ratio of Maximum amount of Proof Size (5M) by the Maximum amount of Gas (15M currently in Moonbeam) allowed for a block.

We could then apply this ratio to the gasLimit that is provided with the Ethereum Transaction to obtain a Proof Size limit to use with the gasometer.

Ex:
Transfer(Alith, Bob, 2 ETH), GasLimit: 21000: would allow 21000 * 5M / 15M => 7000 bytes of Proof Size.

1 Like

I think one of the next step is to check the PoV size usage of existing transactions and determine how much incompatibility the proposed limit will introduce. If for example, say it breaks 50% of the historical calls, then we will need to tweak the algorithm.

Yes, I’ve looked at our transactions and contracts overall, and didn’t find cases that would be limited by this PoV limit, mostly because the EVM is not allowing large storages. This is also the reason we never experienced a block with over the limit proof size, most of EVM will use a lot more CPU than storage read.

Leaving this here: [2208.07919] Dynamic Pricing for Non-fungible Resources: Designing Multidimensional Blockchain Fee Markets

It’s a convex optimization approach to multidimensional gas metering for resources such as compute/PoV size.

What about instead of adding proof metering to SputnikVM, we instead make SputnikVM generic over gas type?

Context

Current evm gas meter looks rougly like this:

struct Gasometer {
  gas_limit: u64,
  used_gas: u64,
}

Solution proposed by @crystalin:

struct Gasometer {
  gas_limit: u64,
  used_gas: u64,
  // Added fields
  proof_limit: u64,
  used_proof: u64,
}

I propose going

struct Gasometer<Gas> {
  gas_limit: Gas,
  used_gas: Gas,
}

instead.

This way, we not only can make no substrate-specific changes in EVM implementation but also implement a metering system, which is more suitable for substrate-based chains, and also allow said generic gas implementation to support future Ethereum own multi-dimensional weight system, which was already proposed several times.

Problem

The problem with the current system is that we have one Gas2Weight mapping, which can’t be both correct and fair.
In Ethereum, write costs 20000 gas, and the read of cold storage is 2100 gas, ≈ 10 times difference.
But in substrate, write costs 100μs, and read costs 25μs in case of RocksDb, 4 times difference.
And 50μs/8μs in the case of ParityDb, 6 times difference.

Let’s estimate what substrate is capable of, based on RocksDb weights:

  • ReadsPerSecond = 1s / 25μs = 40000
  • WritesPerSecond = 1s / 100μs = 10000

If we base our Gas2Weight mapping on write prices (Which is bad, because changing non-zero storage value to non-zero is much cheaper than cold storage write)

  • GasPerSecond = WritesPerSecond * 20000Gas = 200MGas

We can perform

  • EthereumReadsPerSecond = GasPerSecond / 2100Gas ≈ 95000

And 95000 > ReadsPerSecond, so we’ve got a vulnerability. Not correct.

And if we base our Gas2Weight mapping on read prices

  • GasPerSecond = ReadsPerSecond * 2100 = 84MGas

We can perform

  • EthereumWritesPerSecond = GasPerSecond / 20000 Gas = 4200

And this is ok, as 4200 < WritesPerSecond… Until we account for warm storage (= specified in the EIP-1559 transaction), to which access is cheaper. Not fair.

In both cases, we have a considerable difference between consumed gas and ref_time, which should be consumed.

The situation is even worse in the case of

  • Optimized accesses, for which EVM provides subsided costs, i.e., this is not possible to optimize access_list from EIP-1559 (G_warmaccess, G_accesslistaddress, G_accessliststorage), thus in substrate access_list should not affect the weight calculation.
  • Changing a storage key from non-zero to non-zero (G_sreset) should not be cheaper too, as in substrate this will result in pov consumed for read and write, and this should not be cheaper than cold storage write
  • Bidirectional conversions: in the case of substrate user calling substrate, we have a conversion from gas to weight for price calculation, and in the case of precompile benchmarking, we have a conversion from weight to gas, in the end resulting in inadequate conversions with no means to track proof size reliably

Solution

With that said, I propose the following Gas implementation for the generic gas type proposed, which also allows measuring ref_time part of a 2-dimensional weight system and makes it possible to use benchmarking with precompiles correctly.

struct Gas {
  traditional_gas: u64,
  ref_time: u64,
  proof_size: u64,
}
  • traditional_gas - is a legacy gas value used for backward compatibility (until no solution is proposed on the Ethereum implementation side).
  • ref_time - consumed time, used to return correct weight in post_info.
  • proof_size - for post_info.

How it will work

Each instruction should have its benchmark, according to substrate specifics, and not based on Ethereum specs (Because they are written for Ethereum, not for substrate).

For compatibility with existing Ethereum RPCs, specified gas should also provide some ref_time/proof_size, I.e

let gas_limit = Gas {
  traditional_gas: tx.gas_limit,
  ref_time: tx.gas_limit * RefTimePerGas,
  proof_size: tx.gas_limit * ProofSizePerGas,
}

This way, existing contracts will work the same, subcall gas limit will only limit traditional_gas consumption; as there is currently no way to specify other limits provided by EVM spec, we will be able to limit contract proof_size consumption, and even more, it allows to return correct PostInfo, which may enable increasing Ethereum transaction throughput on substrate chains.

Links:

1 Like