The Ethereum canister offers a secure and trustless way to access Ethereum blockchain data within the ICP ecosystem.
Behind the scenes, it leverages the helios
light Ethereum client.
This client is equipped with the capability to validate the authenticity of fetched data.
dfx start --clean --background --artificial-delay 100
# deploy
dfx deploy
# start it
dfx canister call ethereum_canister setup 'record {
network = variant { Mainnet };
execution_rpc_url = "https://ethereum.publicnode.com";
consensus_rpc_url = "https://www.lightclientdata.org";
}'
# utilize it
dfx canister call ethereum_canister erc20_balance_of 'record {
contract = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
account = "0xF977814e90dA44bFA03b6295A0616a897441aceC";
}'
(2_100_000_000_000_000 : nat) # canister's output
dfx start --clean --background --artificial-delay 100
cargo test --target x86_64-unknown-linux-gnu
# or using nextest
cargo nextest run --target x86_64-unknown-linux-gnu
Ethereum canister is a thin wrapper around the helios
light client.
The client operates as global state and conducts periodic synchronization with the Ethereum consensus node in the background.
Canister exposes a trustless RPC API of the execution node from the helios
for other canisters.
You can find the API definition for the Ethereum canister in the candid.did
file.
Additionally all the api types for Rust canisters are in the interface
crate
(will probably use a rename in the future) that doesn't depend on helios
directly but on a lighter ethers-core
for that matter.
The canister mostly just exposes functions from the underlying helios client. Most of the users familiar with
Ethereum will likely be familiar with them too so there is no point in trying to be innovative there. Those usually just
have the same name, take the same arguments, and return the same types (or their Candid
counterparts).
Functions are categorized as update or query based on whether they involve making an RPC call to the execution node or not.
Examples of queries can be get_block_number
or get_gas_price
, which just take information from already synchronized blocks.
Examples of updates are calling the function of a contract or estimating the gas for a transaction as it requires fetching
the addresses and other data.
For the smart contract standards like the ERC20 or ERC721 the exposed API's are in form ${standard_name}_${function_name}
eg. erc20_balance_of
. The parameters to those functions are Candid's equivalents for the parameters from the contract's standard ABI.
It is on the ethereum canister to properly encode them before making a call
.
Presently, the setup function is the only exception to the aforementioned categorization. It is responsible for configuring and initiating the helios client. It is required to be called before any other function, otherwise, the called function will return an error. It takes urls to the consensus node and execution node the client will connect to, as well as the type of network it should operate on and an optional weak subjectivity checkpoint, a trusted hash of a block that nodes agree on. If not provided, the last checkpoint from provided consensus node will be taken. Please note that providing a checkpoint that is too old can result in much more https outcalls and computations needed to reach the synchronization, in some cases exceeding the limits of an update call.
The background loop utilizes ic_cdk_timers::set_timer_interval
and operates at 12-second intervals, mirroring the Ethereum slot time.
This is the only place where the running helios client is ever mutated and the time of locking for the updates was reduced to the
required minimum which should be unnoticeable.
The canister stores its configuration and the latest checkpoint it has reached in stable memory. When upgrading the canister it should restart the helios client itself with the previous configuration and the checkpoint that was already trusted. The last 64 blocks will be re-fetched.
Exploring error handling strategies for inter-canister calls reveals two options: returning Results or triggering panics upon errors.
The panicking way was chosen, as the Result
s seemed less ergonomic for potential developers and they feel a bit inconsistent
as CallResult
already has CanisterError
and CanisterReject
variants. Also going with the panics and having the state reverted
felt more familiar with the smart contracts on other blockchains.
The foundation of this canister. The ethereum canister depends on the client
, consensus
, execution
, and common
helios crates.
To be able to use a helios on the ICP it had to be forked. The fork introduced many
changes to the internals of helios thus making itself not-upstreamable in its current form. Probably it is possible to upstream
some of those changes and maybe even make it compatible with the ICP ecosystem but this is considered to be not a trivial task
with many possible ways of achieving the goals that should be considered and brainstormed.
The changes were made in a manner that helios still works perfectly fine for native, but when targeting wasm it only supports
the ICP and no longer the browsers.
The changes include:
- updating the
helios
and making it compatible with the ruststable
toolchain - getting rid of any occurrences of
wasm-bindgen
,glue-timers
, and other browser-related wasm dependencies - getting rid of
tokio
-related stuff that is incompatible with wasm - using
ethers-core
where possible and removingethers-providers
entirely - replace
reqwest
with https outcalls when targeting wasm - update the
revm
to v3 and introduce a way to fetch missing slots outside in an async manner - change the updating logic to only lock for a short period and add proper shutdown and cleanup
For the complete list of changes see this comparison.
The ethers
crate is the building block of helios and the home for most of the types from the Ethereum ecosystem. It also
provides the utilities to generate types and encoding logic from the smart contract's ABI. It would be really useful to have this
crate working for the ICP ecosystem completely nonetheless this is considered a nontrivial task. The main problem is that it
has already good support for browser-side wasm and some of its subcrates utilize not supported crates heavily. The main pain
point we've encountered was the ethers-providers
crate. The best way to approach this would be to support ICP in crates
like instant
and futures-timer
or even
gloo-timers
, guard the http implementation behind the feature flag and add a way for a user
to add a custom provider.
There was one change made to the ethers, allowing the use of ethers-contract
without the need to depend on ethers-providers
and it was already upstreamed.
The measurements presented below were acquired on a local dfx network setup. These measurements encompass supplementary logs and logic integrated into the canister's code and its dependencies. They are not precise and shouldn't be taken as truth, rather just to shed some light on the potential costs.
call cost [cycles] | payments [cycles] | instructions | https outcalls | |
---|---|---|---|---|
setup | 122,579,275,431 | 122,553,254,989 | 1,149,129,243 | 74 |
advance | 14,126,362,419 | 12,794,806,467 | 839,856,523 | 8 |
get_gas_price | 105,594 | 0 | 5,605 | 0 |
get_block_number | 101,230 | 0 | 2,336 | 0 |
erc20_balance_of [SHIB] | 14,438,775,194 | 14,438,169,162 | 10,996,872 | 9 |
erc20_balance_of [BNB] | 14,439,107,417 | 14,438,470,723 | 12,031,945 | 9 |
erc20_balance_of [USDT] | 17,655,037,455 | 17,654,021,703 | 24,259,653 | 11 |
erc721_owner_of [ENS] | 17,654,474,853 | 17,653,498,044 | 23,011,079 | 11 |
erc721_owner_of [DREAD] | 17,656,442,523 | 17,655,209,030 | 31,469,631 | 11 |
erc721_owner_of [MIL] | 20,866,407,305 | 20,865,374,497 | 25,107,423 | 13 |
All the functions were measured with the advancing (sync loop) disabled except the advance itself.
All the functions were measured with 5 repetitions and the maximum acquired value was presented in the table.
The setup
function was measured with fetching the latest checkpoint from the consensus node.
The 'call cost' was measured using the dfx canister status
command before and after executing the case.
The 'payments' was measured using the difference between ic_cdk::api::canister_balance
invoked at the beginning
and the end of the function. It should reflect the amount spent for https outcalls.
The 'instructions' were measured using the difference between ic_cdk::api::performance_counter
invoked at the
beginning and the end of the function. It should reflect the number of wasm instructions executed during the call.
The 'https outcalls' was measured by counting the calls to the http::get
and http::post
functions.
Https outcalls are the biggest factor of the costs and it should be the primary focus when optimizing.
Among the methods, the setup call incurs the highest cost due to its requirement to initialize everything for the first time and retrieve the last 64 blocks. Unfortunately, it uses mostly the beacon API which is a restful http api, not a json-rpc like the execution API, so requests cannot be batched easily. And even then, a single block can be bigger than 1MB so fetching two at the time could exceed the limit of 2MB for the https outcall.
However, it seems to be possible to heavily optimize all the execution RPC calls. Here is a sample log from the smart contract call.
Log - erc721_owner_of [MIL]
common::http::icp] POST https://ethereum.publicnode.com {"id":0,"jsonrpc":"2.0","method":"eth_createAccessList","params":[{"type":"0x02","to":"0x5af0d9827e0c53e4799bb226655a1de152a425a5","gas":"0x5f5e100","data":"0x6352211e0000000000000000000000000000000000000000000000000000000000001e5d","accessList":[],"maxPriorityFeePerGas":"0x0","maxFeePerGas":"0x0"},"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":1,"jsonrpc":"2.0","method":"eth_getProof","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5",["0x54a8fc6a2a9e7e91706be865e93ec51a73727c47639b740f76ecd87ef67aaffa","0x0000000000000000000000000000000000000000000000000000000000000002","0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb9789"],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":2,"jsonrpc":"2.0","method":"eth_getProof","params":["0x0000000000000000000000000000000000000000",[],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":3,"jsonrpc":"2.0","method":"eth_getProof","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5",[],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":4,"jsonrpc":"2.0","method":"eth_getProof","params":["0x388c818ca8b9251b393131c08a736a67ccb19297",[],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":5,"jsonrpc":"2.0","method":"eth_getCode","params":["0x388c818ca8b9251b393131c08a736a67ccb19297","0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":6,"jsonrpc":"2.0","method":"eth_getCode","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5","0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":7,"jsonrpc":"2.0","method":"eth_getCode","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5","0x111d0e5"]}
execution::evm] fetch basic evm state for address=0x0000000000000000000000000000000000000000
execution::evm] fetch basic evm state for address=0x388c818ca8b9251b393131c08a736a67ccb19297
execution::evm] fetch basic evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=38292851690199200197994960496712248457597506228771887340217700269531888005114
execution::evm] Missing slots: MissingSlots { address: 0x5af0d9827e0c53e4799bb226655a1de152a425a5, slots: [0x54a8fc6a2a9e7e91706be865e93ec51a73727c47639b740f76ecd87ef67aaffa] }
common::http::icp] POST https://ethereum.publicnode.com {"id":8,"jsonrpc":"2.0","method":"eth_getProof","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5",["0x54a8fc6a2a9e7e91706be865e93ec51a73727c47639b740f76ecd87ef67aaffa"],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":9,"jsonrpc":"2.0","method":"eth_getCode","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5","0x111d0e5"]}
execution::evm] fetch basic evm state for address=0x0000000000000000000000000000000000000000
execution::evm] fetch basic evm state for address=0x388c818ca8b9251b393131c08a736a67ccb19297
execution::evm] fetch basic evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=38292851690199200197994960496712248457597506228771887340217700269531888005114
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=2
execution::evm] Missing slots: MissingSlots { address: 0x5af0d9827e0c53e4799bb226655a1de152a425a5, slots: [0x0000000000000000000000000000000000000000000000000000000000000002] }
common::http::icp] POST https://ethereum.publicnode.com {"id":10,"jsonrpc":"2.0","method":"eth_getProof","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5",["0x0000000000000000000000000000000000000000000000000000000000000002","0x54a8fc6a2a9e7e91706be865e93ec51a73727c47639b740f76ecd87ef67aaffa"],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":11,"jsonrpc":"2.0","method":"eth_getCode","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5","0x111d0e5"]}
execution::evm] fetch basic evm state for address=0x0000000000000000000000000000000000000000
execution::evm] fetch basic evm state for address=0x388c818ca8b9251b393131c08a736a67ccb19297
execution::evm] fetch basic evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=38292851690199200197994960496712248457597506228771887340217700269531888005114
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=2
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=29102676481673041902632991033461445430619272659676223336789171408008386418569
execution::evm] Missing slots: MissingSlots { address: 0x5af0d9827e0c53e4799bb226655a1de152a425a5, slots: [0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb9789] }
common::http::icp] POST https://ethereum.publicnode.com {"id":12,"jsonrpc":"2.0","method":"eth_getProof","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5",["0x405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb9789","0x54a8fc6a2a9e7e91706be865e93ec51a73727c47639b740f76ecd87ef67aaffa","0x0000000000000000000000000000000000000000000000000000000000000002"],"0x111d0e5"]}
common::http::icp] POST https://ethereum.publicnode.com {"id":13,"jsonrpc":"2.0","method":"eth_getCode","params":["0x5af0d9827e0c53e4799bb226655a1de152a425a5","0x111d0e5"]}
execution::evm] fetch basic evm state for address=0x0000000000000000000000000000000000000000
execution::evm] fetch basic evm state for address=0x388c818ca8b9251b393131c08a736a67ccb19297
execution::evm] fetch basic evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=38292851690199200197994960496712248457597506228771887340217700269531888005114
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=2
execution::evm] fetch evm state for address=0x5af0d9827e0c53e4799bb226655a1de152a425a5, slot=29102676481673041902632991033461445430619272659676223336789171408008386418569
Firstly, optimizing Evm::batch_fetch_accounts
could involve consolidating requests to fetch codes and proofs into a single operation after establishing the access list.
Also ExecutionClient::fetch_account
could fetch the code and proof in one go. That would reduce the amount of https outcalls used in this specific case from 13 to 5.
Another avenue to explore is determining the additional required slots for EVM execution and pre-fetching them. Alternatively, seeking a method to acquire a
comprehensive list of missing addresses from revm in a single instance—without resimulation—could be pursued, albeit this might entail modifications in revm.
Depending on the outcomes, this might lead to a potential reduction in the number of calls from 5 down to 3 or possibly even 2.
If any of the urls provided as a configuration for the canister leads to the http redirections (eg. "https://www.lightclientdata.org" results in redirects), then all the https outcalls will be repeated for the original url and then for the redirected on. Supporting redirections can be useful when self hosting nodes for this project, so it would be good to optimize it in a way it first checks for the redirections and then storing the actual final url for the requests. The original url can then be kept as a fallback url in case the redirected one is no longer valid.
The initial development was mainly focused on getting the helios
and ethers
to the point where they operate correctly in the ICP environment.
The next step would be to apply all the best practices for canisters implementation including things like controllers, metrics collection,
API design, handling payments, and guarding some methods from public usage.
Also exposing the full helios API and seeking community input sounds like a neat idea.