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 originalgas
and represents computation time. Apart from scaling, it represents the same resource asgas
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 sendx + ed
. This ensures that balance transfers of any amount will succeed and the receiver hasx
as available balance. The downside is that it might be unexpected for the sender to send more thanx
. To prevent confusion, we add theed
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 exactlyx
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 (seestorage_deposit_limit
above).
- This is also true when a contract sends balance to another contract. In this case, we always take the