Mercury is an IBC relayer built with plain Rust traits and generics. No macro frameworks, no code generation.
- Direct trait impls. Every chain operation is a trait method with a direct
implblock. No provider indirection. - Few, focused traits. ~21 traits grouped by concern.
ChainTypescarries all chain-level types,IbcTypescarries all IBC-specific types. Short where clauses. - Typed errors with retryability.
eyre::Result<T>with four typed error enums (TxError,QueryError,ProofError,ClientError) for retryability classification. - Struct fields, not trait getters. Configuration and RPC clients are struct fields, not abstracted behind traits.
ChainTypes - height, timestamp, chain ID, client ID, events, messages, chain status. All ThreadSafe (Send + Sync + 'static).
IbcTypes: ChainTypes - client state, consensus state, proofs, packets, acknowledgements. Non-generic (no counterparty parameter). Each chain declares its IBC types once regardless of counterparty, which eliminates circular dependencies.
Cross-chain relaying (Cosmos↔EVM) hits Rust's orphan rule. Mercury solves this with:
- Core type (e.g.,
CosmosChain<S>) - lives in the chain's core crate, implements all traits - Adapter type (e.g.,
CosmosAdapter<S>) - lives in the counterparty crate, wraps core viaHasCore, adds cross-chain impls
The delegate_chain! macro generates all delegation boilerplate. Use skip_cpb when the adapter needs a custom ClientPayloadBuilder.
| Group | Count | Traits |
|---|---|---|
| Type | 3 | ChainTypes, IbcTypes, HasCore |
| Query | 4 | ChainStatusQuery, ClientQuery<C>, PacketStateQuery, MisbehaviourQuery<C> |
| Builder | 5 | ClientPayloadBuilder<C>, ClientMessageBuilder<C>, PacketMessageBuilder<C>, MisbehaviourDetector<C>, MisbehaviourMessageBuilder<C> |
| Events | 1 | PacketEvents |
| Messaging | 1 | MessageSender |
| Relay | 5 | Relay, BiRelay, RelayChain, ClientUpdater, RelayPacketBuilder |
| Infra | 2 | Worker, ThreadSafe |
RelayChain is the baseline: HasCore + ChainStatusQuery + MessageSender + PacketStateQuery + PacketEvents. Builder and query traits get bound individually on Relay with different requirements for source vs destination.
Cosmos→EVM relay: the EVM crate needs Cosmos types. If IbcTypes were generic, you get circular crate dependencies.
- Non-generic
IbcTypes- each chain declares IBC types once, no counterparty awareness needed - Adapter pattern - counterparty crates define local wrapper types that satisfy the orphan rule
- Weakened bounds -
ClientPayloadBuilderandClientMessageBuilderrequire onlyCounterparty: ChainTypes, notIbcTypes - Type matching at relay site -
Relaytrait enforces payload type compatibility between producer (src) and consumer (dst) - Feature gates - cross-chain impls behind
cosmos-sp1/ethereum-beaconfeatures
ClientMessageBuilder has two defaulted hooks for chain-specific customization:
enrich_update_payload- attach proof data before building update messagesfinalize_batch- post-process the batch (e.g., combine into a single ZK proof)
Both are no-ops for Cosmos↔Cosmos. The Ethereum bridge uses them for batched ZK proving.
Chains register into a ChainRegistry via plugin traits rather than enum-based dispatch. No CLI modifications needed to add a chain.
ChainPlugin- per-chain operations (config, connection, queries). Keyed by type string.RelayPairPlugin- relay construction for a(src_type, dst_type)pair.DynRelay- type-erased relay runner.
Chains are type-erased via Arc<dyn Any + Send + Sync>. Relay plugins downcast to concrete types when building relays.
graph TD
CLI[cli]
COSMOS[cosmos]
ETH[ethereum]
COSMOS_CP[cosmos-counterparties]
ETH_CP[ethereum-counterparties]
CCR[cosmos-cosmos-relay]
CER[cosmos-ethereum-relay]
RELAY[relay]
TRAITS[chain-traits]
CORE[core]
CLI --> COSMOS_CP & ETH_CP & CCR & CER & RELAY
CCR --> COSMOS_CP & RELAY
CER --> COSMOS_CP & ETH_CP & RELAY
COSMOS_CP --> COSMOS & TRAITS
COSMOS_CP -. ethereum-beacon .-> ETH
ETH_CP --> ETH & TRAITS
ETH_CP -. cosmos-sp1 .-> COSMOS
COSMOS & ETH --> TRAITS
RELAY --> TRAITS --> CORE
Core chain crates are independent. Counterparty crates add cross-chain impls behind feature flags. Relay-pair crates depend on both counterparty crates and provide RelayPairPlugin implementations.
Each relay direction runs seven workers connected by tokio::mpsc channels. Everything shuts down through a CancellationToken.
graph LR
EW[EventWatcher] -- events --> PW[PacketWorker]
SW[PacketSweeper] -- events --> PW
PW -- dst msgs --> TW[TxWorker]
PW -- src msgs --> STW[SrcTxWorker]
CRW[ClientRefresh] -- dst msgs --> TW
MW[MisbehaviourWorker]
- EventWatcher - polls source chain block by block for
SendPacket/WriteAck, stays 1 block behind tip - PacketSweeper (optional) - periodic full scan recovering missed packets. Enabled via
sweep_interval - PacketWorker - classifies live vs timed-out packets, queries proofs (8 concurrent, 3 retries), builds messages, calls
finalize_batch() - ClientRefreshWorker - refreshes destination client at 1/3 trusting period
- MisbehaviourWorker (optional) - detects conflicting headers, submits misbehaviour evidence, terminates relay
- TxWorker / SrcTxWorker - batched tx submission with semaphore-bounded concurrency (max 3 in-flight)
Four typed error enums, each implementing HasRetryability (classifies variants as Retryable or Fatal):
| Error | Retryable | Fatal |
|---|---|---|
TxError |
SequenceMismatch, SimulationFailed, BroadcastFailed, NotConfirmed, OutOfGas | Reverted, InsufficientFunds |
QueryError |
Timeout, StaleState | NotFound, Deserialization, UnsupportedType |
ProofError |
FetchFailed, ZkProvingFailed, Missing | VerificationFailed |
ClientError |
(none) | Expired, Frozen, NotFound |
Untyped errors (eyre!/bail!) default to retryable. RetryableExt checks retryability through the error chain via downcast_ref().
Logging (tracing), configuration (struct fields), test infrastructure, and transaction internals (fee estimation, nonce management, batch splitting, tx signing). All concrete, none behind traits.