Skip to main content

Differences to Ethereum

We are trying to be as compatible with Ethereum as possible. This includes semantics for the contract code as well as the RPC endpoints of the node. However, fundamental design decisions lead to a necessary degree of incompatibility. We argue that this is a good trade-off for most applications, and only slight modifications to existing code are necessary, if at all.

PolkaVM instead of EVM

This is the most obvious difference from other Ethereum-compatible solutions. We don't actually use an EVM. Instead, we use our own VM, which is based on the RISC-V instruction set. This is not an issue for a typical Solidity developer. Our revive compiler maintains full Solidity support, including inline assembler. However, any code that tries to download the bytecode of a contract and inspect it will fail, as it would expect to find EVM bytecode. Luckily, applications usually just pass through the bytecode as an opaque blob.

A contract can encounter problems if it uses EXTCODECOPY to copy contract code into memory and then attempts to mutate it. This is not possible in Solidity and would require dropping down to YUL. An example of this would be a factory contract (written in assembly) that constructs and instantiates new contracts by generating the code at runtime. In our experience, such contracts are rare. Additionally, this pattern is not necessary on our platform because the same can be achieved much more easily: we feature on-chain constructors, which can be used to instantiate a contract without modifying code.

Gas Model

Ethereum uses a one-dimensional resource: gas. Every action has a certain gas amount assigned. Most Ethereum-compatible solutions simply use the same values for maximum compatibility.

We made two changes to the original gas model:

Scaling of Gas Values

We don't stick to the original gas values but determine more precise gas values based on benchmarks. This is necessary in order to benefit from the improved execution performance provided by PolkaVM. This makes instructions cheaper relative to I/O-bound operations. Without modifications, we can't account for these improvements.

We also change the actual values more often. Therefore, contract code should not rely on exact numbers. Note that Ethereum has also made adjustments to gas values in the past. This is generally not a problem since the gas limit is set by the wallet, which determines the values through dry runs.

The only issue for on-chain code can arise when passing an absolute gas limit to a cross-contract call. If the actual costs change, this can lead to failure. This is why we recommend passing in values that are relative to the remaining gas of the caller. Passing in a gas limit is usually only necessary when calling into an untrusted contract.

Multi-Dimensional Gas

To make full use of the available resources, we do away with Ethereum's one-dimensional gas. Instead, we meter three resources and also take fees for them:

  • ref_time: This is most similar to the original gas and represents computation time. Apart from scaling, it represents the same resource as gas on Ethereum.
  • proof_size: This resource represents the size of the state proof generated by the contract execution. The state proof is necessary so that the Polkadot validators (who are stateless) can validate the transaction. Ethereum, as a non-sharded system, doesn't account for this resource.
  • storage_deposit: To address state bloat, we charge a deposit from a transaction signer every time a contract it calls adds data to the blockchain's state. This deposit is transferred to the contract and held there. Otherwise, the contract cannot spend or use it. Whoever signs a transaction that removes storage will receive a refund proportional to the amount of storage removed.

Transaction-Level Gas Limit

All three of these can be limited at the transaction level, just like gas can be on Ethereum. Our Ethereum RPC proxy maps all three of these into the single dimension gas so that for users, everything behaves as on Ethereum. We ensure this in a way that the transaction cost displayed in the wallet accurately represents the actual costs, even though we use these three resources internally.

Cross-Contract Call Gas Limit

These resources can also be limited when making a cross-contract call. However, Solidity doesn't allow specifying anything other than gas_limit for a cross-contract call. We take the gas_limit the contract supplies and use that as ref_time_limit. The other two resources are just uncapped in this case. Please note that uncapped means they are still constrained by the transaction-specified limits, so this cannot be used to trick the signer of the transaction.

Why would you need to limit the resources of a cross-contract call? Generally, when your contract is calling into an untrusted contract. In this case, sometimes you need to ensure that you have a certain amount of resources left over to continue executing after the call returns. A game loop where you call into untrusted agent contracts is a good example.

All other gas-related opcodes like GAS or GAS_LIMIT will only return the ref_time as it is the closest match to gas. We will provide pre-compiles that will offer extended APIs to make full use of all resources, such as performing a cross-contract call with all three resources specified.

Memory Limits

Every platform defines certain limits for the contracts they are willing to execute. Examples include: How much memory can a contract access, how large the contract's code can be, and how deeply cross-contract calls can be nested. These limits exist to ensure nodes don't run out of memory.

Ethereum

Ethereum sets a hard limit of 24KB for code size. All other mentioned limits are only constrained by gas. For example, the amount of gas charged for allocating additional memory depends on how much memory is already allocated. The curve is chosen to ensure that a chosen memory envelope can never be exceeded, given Ethereum's block gas limit. The other resources are constrained in a similar way.

This is an elegant approach, but it has one drawback: it does not accurately reflect how expensive a certain operation is. Allocating memory doesn't take more time (charging more gas) depending on how much memory is already allocated, given the right allocator, of course. This means we overcharge for allocations to fit into the memory envelope. Overcharging leads to reduced throughput and higher transaction costs, effectively preventing full use of all available memory.

Our Approach

This is why we didn't adopt Ethereum's method for defining these limits. Instead, our operations are fixed cost. We apply hard limits to ensure we don't consume too much memory. Not conflating memory with execution time gas allows us to charge less. However, this can lead to a situation where our limits are too constraining. We are committed to tuning them until they don't impact functionality in practice. We welcome your feedback to help with this task.

We set a constant memory limit per contract. From that, we derive how deeply we can nest contracts, assuming each contract uses all available memory. This then defines our maximum nesting depth. This is a straightforward approach that limits the nesting depth independently of the actual memory consumption. In the future, we might consider a more dynamic approach where we meter the memory consumption. This would allow for deeper nesting depths when smaller contracts are used. However, it would add yet another resource that would need to be limited at the cross-contract boundary, so it would be essential to implement this before stabilizing the API.

Existential Deposit

On Polkadot, an account must hold a minimum balance to exist. When it drops below this minimum amount, the account is deleted. We call this minimum amount the "existential deposit" (ed). It exists to prevent unused accounts from bloating the state. This is not the case on Ethereum, where accounts are never deleted once created, and there is no minimum balance an account must hold to retain its associated data structures (e.g., nonce) in state. Since contracts are accounts (more precisely, code that controls an account), they are also affected by this.

This leads to a situation where every account on Polkadot has some portion of its currency that it cannot spend. This may confuse contracts and off-chain tools (e.g., wallets) written for Ethereum.

Luckily, we can hide this fact from all participants so everything keeps working as expected. It's just something to be aware of:

  • Every Ethereum RPC that returns a balance will subtract the existential deposit. This means that all returned balance is actually spendable, just as on Ethereum.
  • Every EVM opcode that returns the balance of an account will do the same.
  • When sending balance x to a new account, we actually send x + ed. This ensures that balance transfers of any amount will succeed and the receiver has x as available balance. The downside is that it might be unexpected for the sender to send more than x. To prevent confusion, we add the ed to the transaction fee if it needs to be paid. This way, the user is always aware of the total cost of a transaction.
    • This is also true when a contract sends balance to another contract. In this case, we always take the ed from the signer of the transaction and not the sending contract. This makes the additional send balance transparent to contracts. This is important since contract code is free to assume that exactly x is sent. If a call to a contract funds multiple new accounts, this will be reflected in the transaction fee, just like any other deposit made to cover storage costs (see storage_deposit_limit above).