EthClient (Casper)
The EthClient contract stores EVM block headers on Casper Network and provides verification services for cross-chain proofs.
Overview
| Property | Value |
|---|---|
| Language | Rust (Odra Framework) |
| License | MIT |
| Storage | Per-chain block headers |
| Security | M-of-N Multi-signature |
Core Concepts
Multi-Chain Storage
The EthClient stores block headers from multiple EVM chains using composite keys:
// Storage key: (chain_id, block_number)
block_headers: Mapping<(u64, u64), BlockHeader>
// Examples:
// (11155111, 1000000) → Sepolia block 1,000,000
// (84532, 1000000) → Base block 1,000,000
This prevents block collisions between chains with the same block numbers.
Block Header Structure
struct BlockHeader {
block_hash: H256,
state_root: H256,
receipts_root: H256,
}
Proactive freshness and transfer headers
Relayers elect a proposer before choosing a finalized EVM header. Backups inherit
the known candidate and take over after deterministic rank waits. The default
interval is 600 seconds, configured fleet-wide with
PROACTIVE_HEADER_INTERVAL_SECONDS and applied on restart.
Any independently verified increase in the latest stored height satisfies proactive freshness, including a transfer-driven publication. The interval starts from each relayer's local observation, so actual cadence can be slower. Repeated observations of the same height do not restart it. A transfer still requires its own exact header and is not delayed by this interval. The contract does not store a proactive round, publication timestamp or provenance.
Pending-transaction suppression is bounded and independently verified. Partitions and timing races can still produce duplicate paid submissions; this is not a consensus protocol or a guarantee of zero duplicate fees.
Core Functions
submit_block_header_with_signatures
Submit a block header with M-of-N relayer signatures.
pub fn submit_block_header_with_signatures(
&mut self,
chain_id: u64,
block_number: u64,
block_hash: H256,
state_root: H256,
receipts_root: H256,
signatures: Vec<Vec<u8>>,
)
Parameters:
| Name | Type | Description |
|---|---|---|
chain_id |
u64 | EVM chain identifier |
block_number |
u64 | Block number |
block_hash |
H256 | 32-byte block hash |
state_root |
H256 | 32-byte state root |
receipts_root |
H256 | 32-byte receipts root |
signatures |
Vec |
Array of 65-byte ECDSA signatures |
Process:
- Compute attestation hash (includes chain_id)
- Verify M-of-N signatures from registered relayers
- Store block header with composite key
- Update latest block for chain
Attestation Hash:
// Hash includes chain_id FIRST for replay protection
let hash = keccak256(
chain_id.to_be_bytes() +
block_number.to_be_bytes() +
block_hash +
state_root +
receipts_root
);
Events:
struct BlockHeaderSubmitted {
block_number: u64,
block_hash: H256,
submitter: Address,
}
get_block_hash
Get the stored block hash for a chain/block.
pub fn get_block_hash(&self, chain_id: u64, block_number: u64) -> Option<H256>
Returns:
Some(hash)if block existsNoneif block not stored
get_state_root
Get the stored state root.
pub fn get_state_root(&self, chain_id: u64, block_number: u64) -> Option<H256>
get_receipts_root
Get the stored receipts root for MPT verification.
pub fn get_receipts_root(&self, chain_id: u64, block_number: u64) -> Option<H256>
Usage: TokenFactory uses this to verify lock event proofs:
let stored_root = eth_client.get_receipts_root(chain_id, block_number);
verify_mpt_proof(stored_root, proof, expected_receipt);
is_block_known
Check if a block is stored.
pub fn is_block_known(&self, chain_id: u64, block_number: u64) -> bool
get_latest_block
Get the latest stored block for a chain.
pub fn get_latest_block(&self, chain_id: u64) -> u64
Admin Functions
add_eth_relayer
Add a new relayer.
pub fn add_eth_relayer(&mut self, eth_address: H160)
Access: Admin only
Reverts with InvalidThreshold if the grown active set would break the
strict-majority rule (e.g. adding a 4th relayer to a 2-of-3 config - 2-of-4 is
exactly half, not a majority). To grow past the boundary, raise the threshold
with set_threshold first, then add.
Events:
struct RelayerAdded {
eth_address: H160,
relayer_index: u32,
}
remove_eth_relayer
Remove a relayer.
pub fn remove_eth_relayer(&mut self, eth_address: H160)
Access: Admin only
Events:
struct RelayerRemoved {
eth_address: H160,
}
set_threshold
Update the signature threshold.
pub fn set_threshold(&mut self, new_threshold: u32)
Access: Admin only
Validation:
new_threshold >= 2new_threshold <= active_relayer_countnew_threshold * 2 > active_relayer_count(strict majority - 1-of-N and other minority quorums are rejected withInvalidThreshold)
The same rule applies at init and on remove_eth_relayer (a removal that
would leave the stored threshold invalid for the shrunken set reverts).
Events:
struct ThresholdUpdated {
old_threshold: u32,
new_threshold: u32,
}
propose_admin
Propose a new admin (step 1 of the two-step transfer). Rejects the zero address and the current admin. Overwrites any previous pending proposal.
pub fn propose_admin(&mut self, new_admin: Address)
Access: Admin only
Events: AdminTransferProposed
accept_admin
Accept a pending admin transfer (step 2 of 2). The admin role only moves once the proposed admin calls this with their own key - a mistyped address can never take the role.
pub fn accept_admin(&mut self)
Access: Proposed (pending) admin only
Events: AdminTransferAccepted
View Functions
get_threshold
Get current signature threshold.
pub fn get_threshold(&self) -> u32
get_relayer_count
Get total number of relayers.
pub fn get_relayer_count(&self) -> u32
is_eth_relayer
Check if address is a relayer.
pub fn is_eth_relayer(&self, eth_address: H160) -> bool
get_eth_relayer
Get relayer address by index.
pub fn get_eth_relayer(&self, index: u32) -> Option<H160>
get_authorized_relayers
Get all authorized relayer addresses.
pub fn get_authorized_relayers(&self) -> Vec<H160>
Error Codes
| Error | Code | Description |
|---|---|---|
Unauthorized |
60001 | Caller is not the admin |
BlockAlreadySubmitted |
60003 | Block header already submitted for this chain/block |
BlockNotFound |
60004 | Block header not found in storage |
InvalidBlockNumber |
60005 | Invalid block number provided |
InsufficientSignatures |
60006 | Not enough valid signatures (below threshold) |
InvalidSignature |
60007 | Signature format invalid or recovery failed |
UnauthorizedRelayer |
60008 | Recovered signer is not an authorized relayer |
InvalidThreshold |
60009 | Threshold must be > 0 and <= relayer count |
RelayerAlreadyExists |
60010 | Relayer address already registered |
RelayerNotFound |
60011 | Relayer address not found in registry |
AttestationVerificationFailed |
60012 | Attestation hash verification failed |
Storage
The EthClient stores block headers keyed by (chain_id, block_number), tracks the latest stored block per chain, and maintains the authorized relayer set with its signature threshold.
Signature Verification
Attestation Hash
The attestation hash is the Keccak-256 of the packed fields below - with chain_id first to prevent cross-chain replay:
| Field | Size | Encoding |
|---|---|---|
chain_id |
8 bytes | big-endian u64 |
block_number |
8 bytes | big-endian u64 |
block_hash |
32 bytes | raw |
state_root |
32 bytes | raw |
receipts_root |
32 bytes | raw |
Signature Format
Signatures are standard Ethereum ECDSA (secp256k1), 65 bytes: r (32) + s (32) + v (1). The contract recovers each signer's Ethereum address from the signature and checks it against the authorized relayer set; unknown or duplicate signers are rejected.
Integration Examples
TypeScript (via bridge-cli)
import { CasperClient } from './casper';
const client = new CasperClient(config);
// Submit block header
await client.submitBlockHeaderAttestation({
chainId: 11155111,
blockNumber: 5000000n,
blockHash: '0x...',
stateRoot: '0x...',
receiptsRoot: '0x...',
}, signatures);
// Query block header
const receiptsRoot = await client.getReceiptsRoot(11155111, 5000000n);
const isKnown = await client.isBlockKnown(11155111, 5000000n);
const latestBlock = await client.getLatestBlock(11155111);
Query via casper-client
# Check if block is known
casper-client query-global-state \
--node-address https://node.testnet.casper.network \
--key "hash-<eth_client_hash>" \
--query-path "block_headers/(11155111, 5000000)"
Security Considerations
- Chain ID in Hash: Prevents replay of signatures across chains
- M-of-N Verification: Requires threshold signatures
- Relayer Whitelist: Only registered relayers can sign
- No Overwrites: Submitted blocks cannot be modified
- Independent Storage: Each chain's blocks stored separately
Multi-Chain Security
Why Chain ID Matters
Without chain ID in the attestation hash:
Attack: Use Sepolia signature to submit fake Base block
- Same block number on both chains
- Signatures would be valid without chain_id
With chain ID:
Safe: Signatures are chain-specific
- Hash(Sepolia, 1000000, ...) ≠ Hash(Base, 1000000, ...)
- Sepolia signatures invalid for Base blocks
Block Number Isolation
Sepolia block 1,000,000 stored at: (11155111, 1000000)
Base block 1,000,000 stored at: (84532, 1000000)
Different keys → No collision