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:

  1. Compute attestation hash (includes chain_id)
  2. Verify M-of-N signatures from registered relayers
  3. Store block header with composite key
  4. 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 exists
  • None if 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 >= 2
  • new_threshold <= active_relayer_count
  • new_threshold * 2 > active_relayer_count (strict majority - 1-of-N and other minority quorums are rejected with InvalidThreshold)

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

  1. Chain ID in Hash: Prevents replay of signatures across chains
  2. M-of-N Verification: Requires threshold signatures
  3. Relayer Whitelist: Only registered relayers can sign
  4. No Overwrites: Submitted blocks cannot be modified
  5. 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

results matching ""

    No results matching ""