-
Notifications
You must be signed in to change notification settings - Fork 42
Ethereum light client
Our Ethereum light client implementation follows the Ethereum consensus specification. This document aims to explain both the light client algorithm and the implementation details.
One thing to note when following the Ethereum consensus specification is that it is an iterative documentation. If the newer version does not add or change anything in a specific part of the specification, it means it is not changed.
This document will also be explaining each specific function of IBC and the flow.
We need to track multiple things in order to be able to verify the state of Ethereum:
- Ethereum chain configuration: fields such as
seconds_per_slot
andfork_parameters
is crucial for us to be able to do computation. They are not meant to be changed unless there is an upgrade in Ethereum. We will cover how to do this upgrade in the following sections. - The light client specific configurations:
trust_level
,frozen_height
, etc. are used by this client to define it's own state. - Ethereum IBC implementation specific configuration: This implementation is meant to track an IBC module which uses a single contract to all the commitments. This makes it easier for us to verify the storage since in this way, we only need proofs belonging to a single contract.
The following is the definition of the ClientState that contains all the necessary field. We will explore the use case of all of them in the future sections.
pub struct ClientState {
pub chain_id: U256,
pub genesis_validators_root: H256,
pub min_sync_committee_participants: u64,
pub genesis_time: u64,
pub fork_parameters: ForkParameters,
pub seconds_per_slot: u64,
pub slots_per_epoch: u64,
pub epochs_per_sync_committee_period: u64,
pub trust_level: Fraction,
pub trusting_period: u64,
pub latest_slot: u64,
pub frozen_height: Height,
pub ibc_commitment_slot: U256,
pub ibc_contract_address: H160,
}
The client state is being used to track the light client's state and the counterparty chain's configuration. Note that the light client needs the block headers which contain the commitments that we want to verify. So on each update, we save an instance of the ConsensusState type:
pub struct ConsensusState {
pub slot: u64,
pub state_root: H256,
pub storage_root: H256,
pub timestamp: u64,
/// aggregate public key of current sync committee
pub current_sync_committee: BlsPublicKey,
/// aggregate public key of next sync committee
pub next_sync_committee: Option<BlsPublicKey>,
}
We will explore these fields as well, so don't worry about it now.
We need to create a proposal in order to upload a contract. You can check out the official docs to see how that is being done.
After having the light client code ready on chain, you will need the checksum
of the code to be able to create an instance from it. This part is normally handled by the relayer but I'm documenting the details just in case.
To get the checksums, the following command can be used:
uniond query ibc-wasm checksums
We need to send CreateClient transaction which is handled by the IBC module. You might ask in this point since the client state and the consensus state that we defined above does not include the checksum, how will the IBC module know which client to instantiate? For this specific reason, IBC introduces 2 wrapper types:
The ClientState:
type ClientState struct {
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
Checksum []byte `protobuf:"bytes,2,opt,name=checksum,proto3" json:"checksum,omitempty"`
LatestHeight types.Height `protobuf:"bytes,3,opt,name=latest_height,json=latestHeight,proto3" json:"latest_height"`
}
And the ConsensusState:
type ConsensusState struct {
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
}
Our concrete states will be put under the Data
fields with the encoding of our choice. IBC does not make an encoding assumption for that field. The only encoding format we need to follow strictly here is that these states need to be wrapped by Any
and be protobuf encoded.
Alright, we will now need to send the appropriate transaction. In case you want to do this by using the CLI, you need to do:
uniond tx ibc client create [path/to/client_state.json] [path/to/consensus_state.json]
I'm not entirely sure on this point what the expected format in jsons. So I will put a shameful TODO(aeryz) here.
All light client contracts must expose an instantiate
entrypoint with the InstantiateMsg.
Here is the Rust definition of the type:
#[derive(Debug, Serialize, Deserialize)]
pub struct InstantiateMsg {
pub client_state: Binary, // cosmwasm_std::Binary is just a wrapper around `Vec<u8>` with base64 encoding
pub consensus_state: Binary,
pub checksum: Binary,
}
The light client needs to store the initial client state and the consensus state under the correct commitment keys. See our implementation as an example.
Note here that as we said before, even the light client implementation doesn't need it, it still have to save the states in Wasm
wrapped Any
wrapped format which is not ideal but reality bites.
There are no custom callable functions but rather a single entrypoint which is sudo
. This takes a SudoMsg parameter which is an enum of all the expected message types.
Note that in our implementation, this function is automatically defined by using this macro.
Membership verification is used for verifying that the chain we track really have the commitment it claims. For example, let's say that we are transfering a packet from Ethereum to Union. This packet will be relayed with a proof that it is really committed at the Ethereum side. If this commitment verification wouldn't be there, note that any third party could trick the light client into believing that some action is taken.
In IBC, you can also verify the absence of the storage. This means that we are verifying that the counterparty chain doesn't store a value at the given key. This is useful when timeouts occur. For example, let's say that Ethereum sent packet A to Union but it never made it to Union. In this case, Ethereum has to verify the packet really is timed-out. Otherwise, an adversary could make Ethereum believe that the packet is timed-out when it actually reached Union. In this case for example, it could get its tokens back on Ethereum while they are also minted in the Union side.
EVM uses merkle tree for its storage, hence the proof is merkle path which we can verify against the merkle root. This merkle root here is the storage_root
which we keep track of by saving it to the ConsensusState
.
Let's take a look at the individual parameters:
-
path
: The commitment key. eg.clients/cometbls-1/clientState
. Note that these keys are defined under ICS-023. -
value
: If the proof is for absence, there is no value. But otherwise, we expect the value that is stored at the counterparty chain. Although the counterparty chain stores the hash of the value, this function takes the value itself. -
proof
: Chain-specific proof of commitment. For ethereum, this containskey
,value
(hash), and themerkle proof
. -
delay_*
: TODO(aeryz): check when this is useful -
height
: Height at when this proof is generated. This is important because we need to verify the proof against the correctstorage_root
. And for that, we need to be able to get read thestorage_root
from theConsensusState
which we saved atheight
.
fn verify_membership(
deps: Deps<Self::CustomQuery>,
height: Height,
delay_time_period: u64,
delay_block_period: u64,
proof: Vec<u8>,
mut path: MerklePath,
value: ics008_wasm_client::StorageState,
) -> Result<(), Self::Error>;
Note again that we are verifying the proof against the storage_root
that is saved at height
.
let consensus_state: WasmConsensusState =
read_consensus_state(deps, &height)?.ok_or(Error::ConsensusStateNotFound(height))?;
In here, we are calling the following function to make sure that the commitment key is correct:
fn check_commitment_key(path: &str, ibc_commitment_slot: U256, key: H256) -> Result<(), Error> {
let expected_commitment_key = generate_commitment_key(path, ibc_commitment_slot);
// Data MUST be stored to the commitment path that is defined in ICS23.
if expected_commitment_key != key {
Err(Error::InvalidCommitmentKey {
expected: expected_commitment_key,
found: key,
})
} else {
Ok(())
}
}
One could ask shouldn't Ethereum supposed to make its commitments under the commitment paths defined in ICS-23? Yes but no. We are actually making the commitments to this mapping which is:
mapping(bytes32 => bytes32) public commitments;
In EVM there are 256 bits long slots to save the data and we are hashing the commitment key for performance reasons to make it fit into a single slot. But in EVM, mapping
does not work like other traditional maps from other programming languages. mapping
calculates unique slot numbers for every value by using the key and save the value at that slot. One could ask here what if there are two mapping
s which contain the same key. That's why it is also adding another parameter to the calculation to make it unique. In Solidity, all the globals get a corresponding slot number. So in our case for example, commitments
would get 1
, clientRegistry
would get 2
and etc. mapping
benefits this slot to calculate the commitment key. It uses the following algorithm to calculate the slot number for a value:
keccak256(key || slot)
Note that we get ibc_commitment_slot
during the chain initialization via ClientState
. This is the slot of the commitments
mapping and this is never expected to be changed unless there is a hard upgrade which we will talk about later.
The following is the tricky part that we were talking about previously. When opening a connection, the IBC implementation makes sure that the counterparty light client saved the ConsensusState
as expected. In order to do that, it is generating the ConsensusState
that it expects here and call the membership verification. This data is protobuf encoded but our solidity implementation uses eth abi encoding for performance reasons. Hence in the client, we need to make sure that we are recomputing the same exact value that Ethereum stores in order to verify the proof.
This encoding difference only exists in ClientState
and ConsensusState
right now.
let canonical_value = match path {
Path::ClientStatePath(_) => {
Any::<cometbls::client_state::ClientState>::decode_as::<Proto>(raw_value.as_ref())
.map_err(|e| Error::DecodeFromProto {
reason: format!("{e:?}"),
})?
.0
.encode_as::<EthAbi>()
}
Path::ClientConsensusStatePath(_) => Any::<
wasm::consensus_state::ConsensusState<cometbls::consensus_state::ConsensusState>,
>::decode_as::<Proto>(raw_value.as_ref())
.map_err(|e| Error::DecodeFromProto {
reason: format!("{e:?}"),
})?
.0
.data
.encode_as::<EthAbi>(),
_ => raw_value,
};