-
Notifications
You must be signed in to change notification settings - Fork 89
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ADR for redesigning the modules' API #10
Comments
IMHO, to ensure that we have a low barrier of entry/usability, we must provide two APIs - a low-level bare-bones API Phases in a handler's lifecycleFrom accepting an input message to executing it and generating events and logs, a handler's lifecycle can be broadly
Note that the above phases constitute a state machine where each phase must be executed in order and depends on the Stateless validation ('Validate' phase)Stateless validation can be expressed as a simple fn validate(msg: Self::RawMessage) -> Result<Self::Message, Self::Error> {
msg.try_into()
} Stateful validation ('Check' phase)Here, we need a context (from the host and the light client) to validate the contents of the message against. fn check<ContextRead, Message, CheckResult>(
_ctx: &ContextRead,
_msg: Message,
) -> Result<CheckResult, Error> {
todo!()
} Note that Message processing ('Process' phase)This can be modelled like so -> fn process<ContextRead, CheckResult, ProcessResult>(
_ctx: &ContextRead,
_check_result: CheckResult,
) -> Result<ProcessResult, Error> {
todo!()
}
State modification ('Write' phase)This step writes the state modifications using the result from the previous phase and completes the lifecycle of the fn write<ContextWrite, ProcessResult, Event>(
ctx: &mut ContextWrite,
process_result: ProcessResult,
) -> Result<MsgReceipt<Event>, Error> {
todo!()
} Note: The library could guarantee that Events and logsAlthough a handler itself is mostly stateless as demonstrated by above functions, it must provide facilities for logging pub trait Logger: Into<Vec<String>> {
/// Return the logs generated so-far
fn logs(&self) -> &[String];
/// Log a message
fn log_message(&mut self, _msg: impl ToString);
}
pub trait EventEmitter: Into<Vec<Self::Event>> {
/// Event type
type Event;
/// Return the events generated so-far
fn events(&self) -> &[Self::Event];
/// Emit an event
fn emit_event(&mut self, _event: Self::Event);
} Low level APIIdeally, the library must provide a low-level API that contains core logic and is easy to use. It should provide one pub fn update_client_check<Handler: Logger + EventEmitter, CtxRead: ClientReader>(
_handler: &mut Handler,
_ctx: &CtxRead,
_msg: MsgUpdateClient,
) -> Result<UpdateClientCheckResult, Error> {
/* ... */
} The rationale for providing this low level API is to lower the barrier for entry and provide users with well-audited Specifying dependenciesThe degree of specificity of what the pub fn update_client_check<CS: ConsensusState, Handler: Logger + EventEmitter>(
_handler: &mut Handler,
_client_state: impl FnOnce(&ClientId) -> Result<Box<dyn ClientState>, Error>,
_consensus_state: CS,
_msg: MsgUpdateClient,
) -> Result<UpdateClientCheckResult, Error> {
/* ... */
} The benefit of using closures is that they are easier to satisfy, they're more specific and self-contained, and they
|
|
Based on my analysis of Namada's and Penumbra's codebases as concrete use cases, I came to different conclusions from what is proposed here. I will first comment on your suggestions, and then propose an alternative design. About the 4 phases
I disagree with this model. For one, I don't think each phase should feed into one another; instead, they should be independent. Looking at Namada and Penumbra, neither could work with this proposal since feeding the result of one phase into the other doesn't work with their framework. Concretely, for example, Namada first runs "Message processing" and "State modification", and then runs both validations in parallel (i.e. their validity predicates). I also don't see the value in separating "Message processing" from "state modification". Making writes generic (i.e. using our "Keepers") handles that use case: we are implicitly telling chains to provide an implementation where writes can be reverted until committed. FWIW, both Penumbra and Namada use a "revertible store" which can be reverted until it is committed, and so will always apply state modifications right after message processing. And I expect all chains to have that kind of functionality. Hence, I think there should only be 3 independent phases that don't feed into one another:
By "independent", I mean that they all they all take as input the transaction, and either succeed or fail with an error. About the low vs high level APIsI don't see the benefit of this separation, because I see the abovementioned 3 phases as atomic. That is, you cannot separate them further without breaking IBC. Also, I find that the low-level API forces us to create more About asyncOne thing seems clear: we will need separate context traits and handlers for sync and async. Whether or not we use something like ProposalI am still fleshing out the details of the design I have in mind, and will post later. |
Simplifying host state accessCurrently, we define an entry-point function which requires an pub fn deliver<Ctx: Ics26Context>(_ctx: &mut Ctx, _message: Any) -> Result<MsgReceipt, Error> { /* ... */ } And pub trait Ics26Context:
ClientReader
+ ClientKeeper
+ ConnectionReader
+ ConnectionKeeper
+ ChannelKeeper
+ ChannelReader
+ PortReader
{} The problem with this design is that there is a duplication of methods in these supertraits and not all handlers use all I propose we define one context/state trait per handler function that only defines the methods that are relevant for trait UpdateClientCheckCtx {
fn client_state(&self, client_id: &ClientId) -> Result<Box<dyn ClientState>, Error>;
} And the pub trait Ics26Context {
fn client_state(&self, client_id: &ClientId) -> Result<Box<dyn ClientState>, Error>;
// Followed by all methods from all handler specific contexts
}
// blanket impl of all handler function context traits for any type that implements the Ics26Context
impl<T: Ics26Context> UpdateClientCheckCtx for T {
fn client_state(&self, client_id: &ClientId) -> Result<Box<dyn ClientState>, Error> {
Ics26Context::client_state(self, client_id)
}
} Modelling the store directly in the APIMaybe we should consider modelling the store directly in the API. ICS24 requires hosts to have some form of a KV store. pub trait Store: Send + Sync + Clone {
type Error;
type Path: Into<IbcPath>;
fn set(&mut self, path: Path, value: Vec<u8>) -> Result<Option<Vec<u8>>, Self::Error>;
fn get(&self, height: Height, path: &Path) -> Option<Vec<u8>>;
fn delete(&mut self, path: &Path);
} We can then define pub struct ScopedStore<S, P> {
inner: S,
_path: PhantomData<P>,
}
impl<S: Store, P> Store for ScopedStore<S, P> {
/* ... */
} And handler functions would use this instead of pub fn update_client_check<S: Store, UpdateClientStore: ScopedStore<S, UpdateClientPath>>(
_store: &UpdateClientStore,
_msg: MsgUpdateClient,
) -> Result<UpdateClientCheckResult, Error> {
/* ... */
} This API provides static guarantees about the paths that a handler can access and would make it easier to apply formal
impl<S, P> ScopedStore<S, P> {
fn written_paths(&self) -> Vec<P> {
/* ... */
}
} |
Below is my proposal. The idea is to provide 2 entry points: one for validation, and one for execution, each with their corresponding context trait. I prefer this to the idea of having handler-specific context traits, as I think no implementation will ever need that level of control, and therefore will only increase the complexity of the library with no clear benefit. As for your "modeling the store API" idea, I borrowed a few ideas from there. I don't like explicitly modeling the store where values are encoded Note that Ibc mandates all stores to store values that "look serialized", but they don't have to be. All objects stored have an ProposalWe have a trait ValidationContext {
fn validate(&self, message: Any) -> Result<(), Error> {
/* validation entrypoint implemented here */
}
/* Below are all the methods needed for validation */
/// Returns the ClientState for the given identifier `client_id`.
fn client_state(&self, client_id: &ClientId) -> Result<Box<dyn ClientState>, Error>;
/// Tries to decode the given `client_state` into a concrete light client state.
fn decode_client_state(&self, client_state: Any) -> Result<Box<dyn ClientState>, Error>;
/// Retrieve the consensus state for the given client ID at the specified
/// height.
///
/// Returns an error if no such state exists.
fn consensus_state(
&self,
client_id: &ClientId,
height: Height,
) -> Result<Box<dyn ConsensusState>, Error>;
/* ... */
} trait ExecutionContext {
fn execute(&mut self, message: Any) -> Result<(), Error> {
/* Execution entrypoint implemented here */
}
/// Called upon successful client creation
fn store_client_type(
&mut self,
client_type_path: ClientTypePath,
client_type: ClientType,
) -> Result<(), Error>;
/// Called upon successful client creation and update
fn store_client_state(
&mut self,
client_state_path: ClientStatePath,
client_state: Box<dyn ClientState>,
) -> Result<(), Error>;
/// Called upon successful client creation and update
fn store_consensus_state(
&mut self,
consensus_state_path: ClientConsensusStatePath,
consensus_state: Box<dyn ConsensusState>,
) -> Result<(), Error>;
/// Ibc events
fn emit_ibc_event(event: IbcEvent);
/* ... */
// Note: there will be some methods that read from the store as well,
// duplicated from `ValidationContext`
} We can also provide a convenience fn deliver<V, E>(val_ctx: &V, exec_ctx: &mut E, message: Any) -> Result<(), Error> {
let _ = val_ctx.validate(message)?;
exec_ctx.execute(message)
} |
Note about being generic over async: The rust-lang team has started an initiative to work on that. See blog post. |
Case study: Client Update handlerI thought it would be helpful to look at the proposal from the perspective of a concrete and specific handler impl for Note: I chose update client as that's what I used initially and I think it makes for a good example. Implementations // Source -> https://github.com/anoma/namada/blob/main/shared/src/ledger/ibc/vp/client.rs#L227
fn validate(&self, msg: Any) -> Result<(), Error> {
// 1. Decode message
// 2. Get `client_state` from host using `msg.client_id`
// 3. Construct `new_client_state` and `new_consensus_state` (with verification)
// 4. Construct event and check it matches the event emitted in `execute()`
}
// Source ->
// https://github.com/anoma/namada/blob/main/shared/src/ledger/ibc/handler.rs#L153
// https://github.com/anoma/namada/blob/main/shared/src/ledger/ibc/handler.rs#L241
fn execute(&mut self, msg: Any) -> Result<(), Error> {
// 1. Decode message
// 2. Get `client_state` from host using `msg.client_id`
// 3. Construct `new_client_state` and `new_consensus_state` (without verification)
// 4. Write `new_client_state` and `new_consensus_state` to store
// 5. Construct event and emit it
} Some key observations
Takeaways
Addressing prior criticism
Generally, designing the system in a way such that the output of a previous phase feeds into the current phase makes the
Separating
I think the right way to think about this is in terms of modularity and (single) responsibility rather than atomicity. ConclusionI still haven't touched on my other ideas including the high-level API and simplified state access as this post is All that said, I would still prefer the low-level API I initially proposed with the addition of a |
During today's sync call, @hu55a1n1 and I agreed on the following. We will expose the API that I proposed (with modifications); that is, a
However, fleshing out the private lower-level API is in scope for the ADR. The general ADR structure would look something like:
Customers which we run the ADR by can focus on (1) and (2) only, and skip the internal details which aren't relevant to them. We will investigate further the Async is out of scope for this ADR. |
Host based APIBased on ICS24, we could require hosts to implement a pub trait Host {
type Error: StoreError;
type KvStore: IbcStore<Self::Error>;
type EventEmitter: EventEmitter<Event=Event<DefaultIbcTypes>>;
fn store(&self) -> &Self::KvStore;
fn store_mut(&mut self) -> &mut Self::KvStore;
fn event_emitter(&self) -> &Self::EventEmitter;
fn event_emitter_mut(&mut self) -> &mut Self::EventEmitter;
fn current_timestamp(&self) -> IbcTimestamp;
fn current_height(&self) -> IbcHeight;
/* ... */
/* see section below on 'Host methods that cannot be obtained from the `Store`' for a complete list of methods */
} This pub struct IbcHost<H> {
host: H,
}
impl<H: Host> ValidationContext for IbcHost<H> { /* ... */ }
impl<H: Host> ExecutionContext for IbcHost<H> { /* ... */ } (click to expand):
|
Low level implementationI think we can continue to use the low-level phase based functions described before and integrate them with the context Context generic programming techniques@soareschen was kind enough to implement a PoC framework to show how these techniques can be used with the current modules An implementation of the update client handler using the same framework but integrated with the |
Summary
Create an ADR for redesigning the modules' API in a way that is more robust and usable for our customers.
Requirements
async
- The API must be usable in an async context.blockchain env.
that are composable.
For Admin Use
The text was updated successfully, but these errors were encountered: