This ADR is meant to address the main limitation of our current light client API, first introduced in ADR 4 and later improved to adopt some of the ideas present in ibc-go's ADR 6. Implementing some ClientState
methods require additional information from the host. For example, the Tendermint client's implementation of ClientState::verify_client_message
needs access to the host timestamp to properly perform a message's verification. Previously, we solved this problem by giving a reference to a ValidationContext
and ExecutionContext
, since most methods are already made available by these traits. However, this solution has some limitations:
- Not all methods needed by every future light client is present in
ValidationContext
orExecutionContext
. For example, if a light client X finds that it would need access to some resource Y, currently the only way to solve this is to submit a PR on the ibc-rs repository that adds a methodget_resource_Y()
toValidationContext
.- This means that every host will need to implement
get_resource_Y()
, even if they don't use light client X. - It clutters up
ValidationContext
andExecutionContext
.
- This means that every host will need to implement
- We found that some methods only needed by the Tendermint light client made their way into
ValidationContext
.next_consensus_state()
andprev_consensus_state()
are not used in the core handlers; they're only there because of the Tendermint light client.
- It gives more power to light clients than they really need
- By giving the light clients access to
ValidationContext
andExecutionContext
, we're effectively giving them the same capabilities as the core handlers. - Although our current model is that all code is trusted (including light clients we didn't write), restraining the capabilities we give to light clients at the very least eliminates a class of bugs (e.g. calling the wrong method), and serves as documentation for exactly which methods the light client needs.
- By giving the light clients access to
This ADR is all about fixing this issue; namely, to enable light clients to define their own ValidationContext
and ExecutionContext
traits for the host to implement.
The ClientState
functionality is split into 3 traits:
ClientStateCommon
,ClientStateValidation<ClientValidationContext>
, andClientStateExecution<ClientExecutionContext>
Then, ClientState
is defined as
pub trait ClientState<ClientValidationContext, E: ClientExecutionContext>:
ClientStateCommon
+ ClientStateValidation<ClientValidationContext>
+ ClientStateExecution<E>
// + ...
{
}
A blanket implementation implements ClientState
when these 3 traits are implemented on a given type. For details as to why ClientState
was split into 3 traits, see the section "Why are there 3 ClientState
traits?".
The ClientStateValidation
and ClientStateExecution
traits are the most important ones, as they are the ones that enable light clients to define Context
traits for the host to implement.
Say the implementation of a light client needs a get_resource_Y()
method from the host in ClientState::verify_client_message()
. The implementor would first define a trait for the host to implement.
trait MyClientValidationContext {
fn get_resource_Y(&self) -> Y;
}
Then, they would implement the ClientStateValidation<ClientValidationContext>
trait conditioned on ClientValidationContext
having MyClientValidationContext
as supertrait.
impl<ClientValidationContext> ClientStateValidation<ClientValidationContext> for MyClientState
where
ClientValidationContext: MyClientValidationContext,
{
fn verify_client_message(
&self,
ctx: &ClientValidationContext,
// ...
) -> Result<(), ClientError> {
// `get_resource_Y()` accessible through `ctx`
}
// ...
}
This is the core idea of this ADR. Everything else is a consequence of wanting to make this work.
ClientStateExecution
is defined a little differently from ClientStateValidation
.
pub trait ClientStateExecution<E>
where
E: ClientExecutionContext,
{ ... }
where ClientExecutionContext
is defined as (simplified)
pub trait ClientExecutionContext: Sized {
// ... a few associated types
/// Called upon successful client creation and update
fn store_client_state(
...
) -> Result<(), ContextError>;
/// Called upon successful client creation and update
fn store_consensus_state(
...
) -> Result<(), ContextError>;
}
Under our current architecture (inspired from ibc-go's ADR 6), clients have the responsibility to store the ClientState
and ConsensusState
. Hence, ClientExecutionContext
defines a uniform interface that clients can use to store their ClientState
and ConsensusState
. It also means that the host only needs to implement these methods once, as opposed to once per client. Note that clients who don't store consensus states (e.g. solomachine) can simply leave the implementation of store_consensus_state()
empty (or return an error, whichever is most appropriate).
The ClientState
changes described above induce some changes on ValidationContext
and ExecutionContext
.
ValidationContext
is now defined as:
pub trait ValidationContext: Router {
type ClientValidationContext;
type ClientExecutionContext;
/// Enum that can contain a `ConsensusState` object of any supported light client
type AnyConsensusState: ConsensusState<EncodeError = ContextError>;
/// Enum that can contain a `ClientState` object of any supported light client
type AnyClientState: ClientState<
Self::AnyConsensusState,
Self::ClientValidationContext,
Self::ClientExecutionContext,
>;
// ...
}
AnyConsensusState
and AnyClientState
are expected to be enums that hold the consensus states and client states of all supported light clients. For example,
enum AnyConsensusState {
Tendermint(TmConsensusState),
Near(NearConsensusState),
// ...
}
enum AnyClientState {
Tendermint(TmClientState),
Near(NearClientState),
// ...
}
ClientValidationContext
and ClientExecutionContext
correspond to the same types described in the previous section. The host must ensure that these 2 types implement the Tendermint and Near "ValidationContext
and ExecutionContext
traits" (as discussed in the previous section). For example,
struct MyClientValidationContext;
// Here, `TmClientValidationContext` is a Tendermint's `ValidationContext`, meaning that it contains all the methods
// that the Tendermint client requires from the host in order to perform message validation.
impl TmClientValidationContext for MyClientValidationContext {
// ...
}
impl NearClientValidationContext for MyClientValidationContext {
// ...
}
// Code for `ClientExecutionContext` is analogous
Notice that ValidationContext::AnyClientState
needs to implement ClientState
, and ValidationContext::AnyConsensusState
needs to implement ConsensusState
. Given that AnyClientState
and AnyConsensusState
are enums that wrap types that must implement ClientState
or ConsensusState
(respectively), implementing these traits is gruesome boilerplate:
impl ClientStateCommon for AnyClientState {
fn client_type(&self) -> ClientType {
match self {
Tendermint(cs) => cs.client_type(),
Near(cs) => cs.client_type()
}
}
// ...
}
To relieve users of such torture, we provide derive macros that do just that:
#[derive(ConsensusState)]
enum AnyConsensusState {
Tendermint(TmConsensusState),
Near(NearConsensusState),
// ...
}
#[derive(ClientState)]
#[validation(MyClientValidationContext)]
#[execution(MyClientExecutionContext)]
enum AnyClientState {
Tendermint(TmClientState),
Near(NearClientState),
// ...
}
The ClientState
trait is defined as
trait ClientState<ClientValidationContext, ClientExecutionContext>
The problem with defining all methods directly under ClientState
is that it would force users to use fully qualified notation to call any method.
This arises from the fact that no method uses both generic parameters. This playground provides an explanatory example. Hence, our solution is to have all methods in a trait use every generic parameter of the trait to avoid this problem.
Why did you write custom ClientState
and ConsensusState
derive macros? Why not use enum_dispatch
or enum_delegate
?
We ended up having to write our own custom derive macros because existing crates that offer similar functionality had shortcomings that prevented us from using them:
enum_dispatch
: the traitClientState
and the enum that implementsClientState
need to be defined in the same crateenum_delegate
(v0.2.*): was designed to remove the above restriction. However, generic traits are not supported.- we investigated turning the generic types of
ClientState
into associated types. However we were hit by the other limitation ofenum_delegate
:ClientState
cannot have any supertrait.
- we investigated turning the generic types of
- All light clients can now be implemented in their crates without ever needing to modify ibc-rs
- Removes trait object downcasting in light client implementations
- downcasting fails at runtime; these errors are now compile-time
- Increased complexity.
- Harder to document.
- Specifically, we do not write any trait bounds on the
Client{Validation, Execution}Context
generic parameters. The effective trait bounds are spread across all light client implementations that a given host uses.
- Specifically, we do not write any trait bounds on the
- Our light client traits are no longer trait-object safe. Hence, for example, all uses of
Box<dyn ConsensusState>
are replaced by the analogousValidationContext::AnyConsensusState
.
In the methods ClientState::{verify_client_message, check_for_misbehaviour, update_state, update_state_on_misbehaviour}
, the client_message
argument is still of type ibc_proto::google::protobuf::Any
(i.e. still serialized). Ideally, we would have it be well-typed and unserialized. Since there are many ways to do this, and this was slightly tangential to this work, we left it as future work.