> For the complete documentation index, see [llms.txt](https://docs.astralbeam.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.astralbeam.io/security/verification.md).

# Verification

This page provides detailed documentation of the cryptographic verification systems used in AstralBeam.

## Overview

The bridge uses different verification mechanisms depending on the direction:

| Direction    | Verification Method           |
| ------------ | ----------------------------- |
| EVM → Casper | MPT Receipt Proofs            |
| Casper → EVM | Multi-sig Attestations        |
| Both         | M-of-N Signature Verification |

## Directional Trust Models

The bridge is federated in both directions: M-of-N relayers are the consensus boundary. It does not run a Casper light client on EVM or an EVM light client on Casper.

### Casper → EVM

Casper-originated events are discovered through CSPR.cloud WebSocket feeds. Before an event can be signed, every honest relayer independently re-fetches the Casper execution from a separately selected RPC/REST source, reconstructs the emitted CES bridge event from execution effects, and requires every attested field to match. Transport lag is retried with bounded backoff; a confirmed failed deploy, absent event, or field mismatch is permanently rejected. This independent verification is implemented in `cli/src/shared/casper/independent-verify.ts` and gated in `cli/src/workers/casper2evm.ts`.

**Source diversity gate:** verification sources are partitioned into two infrastructure classes — *public* (the public Casper node RPC plus any endpoints in `CASPER_PUBLIC_NODE_URLS`, comma-separated) and *cloud* (CSPR.cloud REST and CSPR.cloud RPC, which share one operator). A terminal verdict — `verified` **or** `mismatch` — requires a public-class source. Cloud sources are advisory cross-checks only: if only CSPR.cloud can see a deploy, the event stays `pending` and a `VERIFICATION_SOURCE_DIVERGENCE` warning is logged. This holds in both directions: a compromised CSPR.cloud can neither fabricate execution effects to obtain a signature nor serve falsified effects to permanently reject a legitimate event. A configuration with no public-class source fails safe (nothing ever verifies).

Source class is decided by **hostname, not by config slot** (adversarial-review finding #2): a `*.cspr.cloud` host — or one matching the configured cloud REST/RPC hosts — listed in `CASPER_NODE_URL`/`CASPER_PUBLIC_NODE_URLS` is demoted to cloud class and cannot satisfy the gate (logged `VERIFICATION_PUBLIC_SLOT_IS_CLOUD_HOST`), so a misconfiguration cannot silently defeat the diversity requirement.

**Stall handling (park-and-slow-poll, bounded):** the fast retry window (10 attempts, exponential backoff 3s→2min) covers normal RPC-behind-WebSocket lag. If it is exhausted — public nodes down, or a cloud/public divergence standoff — the event is **parked, not dropped**: it re-verifies every 10 minutes and a `VERIFICATION_STALLED` alert fires on the first stalled poll and roughly hourly thereafter. Deploy visibility is monotonic, so a parked event verifies and proceeds as soon as independent infrastructure confirms it; source funds are already locked/burned, making "delayed" recoverable where "dropped" was not. To bound the WS-feed-controls-origination DoS (a compromised feed emitting unlimited fabricated events), a parked event whose deploy stays unconfirmed on a reachable public source for the maximum stall window (\~6h) is **dead-lettered** (`VERIFICATION_STALL_TIMEOUT`) and the pending maps carry a hard capacity cap that evicts the most-parked (most likely fabricated) entry first. Only a public-source-confirmed `mismatch` (the attack signal) permanently rejects an event *immediately* — infrastructure trouble never does.

Verification signals are exported as Prometheus metrics so the "operators get alerted" guarantee is backed by alertable series, not log lines alone: `casper2evm_verification_stalled_total`, `casper2evm_verification_stall_timeout_total`, `casper2evm_verification_mismatch_total`, and the `casper2evm_parked_events` gauge (parked subset of pending attestations).

The same independent-verification gate applies to *peer co-sign requests*, not only self-originated (queue) events. When another relayer asks this node to co-sign an `unlock` or `wrapped_mint` attestation — via a `signature_request` or a `sync_response` message — the handler independently confirms the underlying Casper deploy before producing a signature, using an **inverted match**: it fetches the deploy named by `casperEventKey`, recovers the on-chain `TokensBurned` / `NativeTokenLocked` event, and binds it to the attestation via the cryptographic deposit id (`keccak256(casperEventKey ‖ nonce)`, which proves the nonce), amount, EVM recipient, target chain, and token mapping. A verified match signs; a mismatch is refused permanently (logged `ATTESTATION_VERIFICATION_MISMATCH`); a not-yet-visible deploy is not signed and the requester re-requests. Terminal outcomes are cached (bounded TTL + size) so repeated co-sign requests do not re-hit Casper RPC. Without this, a malicious authorized relayer could collect co-signatures for an event that never occurred on-chain.

This is independent source corroboration, not a cryptographic Casper state proof. M-of-N signatures remain the on-chain authorization boundary on the EVM destination.

### EVM → Casper: signed header registry

Each honest relayer independently reads the EVM block after the configured confirmation policy, verifies that the attested receipt root matches the RPC-observed source block, rebuilds/verifies the receipt trie, and checks the exact bridge event fields before signing. Casper's `EthClient` then stores the block hash, state root, and receipts root only after M-of-N signature verification. `TokenFactory` requires its transfer attestation to carry the same registered receipt root.

`EthClient` is therefore a **signed header registry**, not an EVM light client. It does not validate header RLP, recompute block hashes, follow parent headers, verify EVM/L2 consensus or finality, or prove canonical-chain membership on Casper. The trust boundary is the configured relayer quorum: fewer than M relayers cannot register a root, while a compromised quorum can authorize an arbitrary root just as it can authorize a fraudulent transfer attestation.

### Operational assumptions and future hardening

* All relayers are currently first-party controlled; the set is not an open validator network.
* The production threshold is 3-of-5, with threshold governance protected by the bridge's admin controls.
* A threshold compromise is assumed to mean total bridge compromise; the signed-root cross-check is not an independent defense against the same compromised quorum.
* Moving to third-party relayers or a trust-minimized bridge would require a separately designed and audited canonical-chain light client/proof system, not a small patch to `EthClient`.

## MPT (Merkle Patricia Trie) Proofs

### What is MPT?

Ethereum stores receipts in a Merkle Patricia Trie, allowing efficient proofs of inclusion:

```mermaid
flowchart TB
    subgraph Block[Block Header]
        ReceiptsRoot[Receipts Root 0xabc...]
    end

    subgraph MPT[Merkle Patricia Trie]
        Root[Root Node]
        Branch1[Branch]
        Branch2[Branch]
        Extension[Extension]
        Leaf1[Leaf: Receipt 0]
        Leaf2[Leaf: Receipt 1]
        Leaf3[Leaf: Receipt 2]
    end

    ReceiptsRoot --> Root
    Root --> Branch1
    Root --> Branch2
    Branch1 --> Leaf1
    Branch1 --> Extension
    Extension --> Leaf2
    Branch2 --> Leaf3
```

### Proof Structure

An MPT proof consists of:

```typescript
interface MPTProof {
    // The key (RLP-encoded receipt index)
    key: Buffer;

    // Array of RLP-encoded trie nodes from root to leaf
    proof: Buffer[];

    // The value (RLP-encoded receipt)
    value: Buffer;
}
```

### Proof Generation

```typescript
async function generateReceiptProof(
    provider: Provider,
    txHash: string
): Promise<MPTProof> {
    // 1. Get the receipt
    const receipt = await provider.getTransactionReceipt(txHash);
    const block = await provider.getBlock(receipt.blockNumber);

    // 2. Get all receipts in the block
    const receipts = await Promise.all(
        block.transactions.map(tx =>
            provider.getTransactionReceipt(tx)
        )
    );

    // 3. Build the trie
    const trie = new Trie();
    for (let i = 0; i < receipts.length; i++) {
        const key = RLP.encode(i);
        const value = encodeReceipt(receipts[i]);
        await trie.put(key, value);
    }

    // 4. Generate proof for specific receipt
    const key = RLP.encode(receipt.transactionIndex);
    const proof = await trie.createProof(key);

    return {
        key,
        proof,
        value: encodeReceipt(receipt)
    };
}
```

### Proof Verification (Casper)

```rust
/// Verify an MPT proof and extract the receipt
pub fn verify_receipt_proof(
    receipts_root: H256,
    proof: &ReceiptProof,
) -> Result<TransactionReceipt, VerificationError> {
    // 1. Start at root
    let mut expected_hash = receipts_root;
    let mut key_nibbles = to_nibbles(&proof.key);
    let mut key_index = 0;

    // 2. Traverse proof nodes
    for node_rlp in &proof.proof {
        // Verify node hash matches expected
        let node_hash = keccak256(node_rlp);
        if node_hash != expected_hash {
            return Err(VerificationError::HashMismatch);
        }

        // Decode and traverse node
        let node = decode_node(node_rlp)?;
        match node {
            Node::Branch(children, value) => {
                if key_index >= key_nibbles.len() {
                    // At leaf
                    return decode_receipt(&value);
                }
                let child_index = key_nibbles[key_index] as usize;
                expected_hash = children[child_index];
                key_index += 1;
            }
            Node::Extension(path, next) => {
                // Verify path matches key
                if !matches_path(&key_nibbles[key_index..], &path) {
                    return Err(VerificationError::PathMismatch);
                }
                key_index += path.len();
                expected_hash = next;
            }
            Node::Leaf(path, value) => {
                // Verify path matches remainder of key
                if path != &key_nibbles[key_index..] {
                    return Err(VerificationError::PathMismatch);
                }
                return decode_receipt(&value);
            }
        }
    }

    Err(VerificationError::IncompleteProof)
}
```

### Receipt Verification

After extracting the receipt, verify it contains the expected event:

```rust
pub fn verify_lock_event(
    receipt: &TransactionReceipt,
    attestation: &LockAttestation,
    locker_address: Address,
) -> Result<(), VerificationError> {
    // 1. Check receipt status (must be successful)
    if receipt.status != 1 {
        return Err(VerificationError::FailedTransaction);
    }

    // 2. Find the Locked event in logs
    // V10 lockers append a trailing chainId; verifiers accept BOTH generations
    let event_sig_v10 = keccak256(b"Locked(address,address,uint256,bytes32,uint256,uint256)");
    let event_sig_legacy = keccak256(b"Locked(address,address,uint256,bytes32,uint256)");

    for log in &receipt.logs {
        // Check log is from locker contract
        if log.address != locker_address {
            continue;
        }

        // Check event signature (either generation)
        if log.topics[0] != event_sig_v10 && log.topics[0] != event_sig_legacy {
            continue;
        }

        // Decode and verify event data
        let (token, sender, recipient, amount, nonce) = decode_lock_event(log);

        if token == attestation.evm_token
            && recipient == attestation.casper_recipient
            && amount == attestation.amount
            && nonce == attestation.nonce
        {
            return Ok(());
        }
    }

    Err(VerificationError::EventNotFound)
}
```

## Signature Verification

### ECDSA Signatures

All attestations use Ethereum-style ECDSA signatures:

```typescript
// Sign an attestation
async function signAttestation(
    wallet: Wallet,
    attestation: Attestation
): Promise<string> {
    // 1. Compute hash
    const hash = computeAttestationHash(attestation);

    // 2. Sign with EIP-191 prefix
    // This adds "\x19Ethereum Signed Message:\n32" prefix
    const signature = await wallet.signMessage(ethers.getBytes(hash));

    return signature;
}
```

### Signature Structure

An Ethereum signature consists of:

| Component | Size     | Description            |
| --------- | -------- | ---------------------- |
| r         | 32 bytes | ECDSA r value          |
| s         | 32 bytes | ECDSA s value          |
| v         | 1 byte   | Recovery ID (27 or 28) |

### Signer Recovery

```solidity
function recoverSigner(
    bytes32 hash,
    bytes memory signature
) internal pure returns (address) {
    // Add EIP-191 prefix
    bytes32 prefixedHash = keccak256(abi.encodePacked(
        "\x19Ethereum Signed Message:\n32",
        hash
    ));

    // Split signature
    bytes32 r;
    bytes32 s;
    uint8 v;
    assembly {
        r := mload(add(signature, 32))
        s := mload(add(signature, 64))
        v := byte(0, mload(add(signature, 96)))
    }

    // Recover signer address
    return ecrecover(prefixedHash, v, r, s);
}
```

### Multi-Signature Verification

Complete verification with threshold check:

```solidity
function verifyMultiSig(
    bytes32 hash,
    bytes[] calldata signatures,
    uint256 threshold
) internal view returns (bool) {
    // Track signers to prevent duplicates
    address[] memory signers = new address[](signatures.length);
    uint256 validCount = 0;

    for (uint256 i = 0; i < signatures.length; i++) {
        address signer = recoverSigner(hash, signatures[i]);

        // Must be registered relayer
        if (!isRelayer[signer]) {
            continue;
        }

        // Check for duplicates
        bool isDuplicate = false;
        for (uint256 j = 0; j < validCount; j++) {
            if (signers[j] == signer) {
                isDuplicate = true;
                break;
            }
        }

        if (!isDuplicate) {
            signers[validCount] = signer;
            validCount++;
        }
    }

    return validCount >= threshold;
}
```

## Attestation Hashes

### Block Header Attestation

```typescript
function computeBlockHeaderHash(
    chainId: number,
    blockNumber: bigint,
    blockHash: string,
    stateRoot: string,
    receiptsRoot: string
): string {
    return ethers.solidityPackedKeccak256(
        ['uint64', 'uint64', 'bytes32', 'bytes32', 'bytes32'],
        [chainId, blockNumber, blockHash, stateRoot, receiptsRoot]
    );
}
```

### Lock Attestation (EVM → Casper)

Lock attestations use EIP-712 typed data bound to the Casper TokenFactory domain (see `LOCK_ATTESTATION_TYPES` in `cli/src/shared/crypto/index.ts`). The struct includes the EVM locker's stable deposit `nonce` from the `Locked` event, so signatures commit to the deposit identity rather than only its block position.

```typescript
// Type string (must match Rust LockAttestationData::type_string()):
// LockAttestation(uint256 sourceChainId,uint256 ethBlockNumber,
//   uint256 ethTxIndex,uint256 ethLogIndex,address erc20Token,
//   address sender,uint256 amount,uint8 casperRecipientType,
//   bytes32 casperRecipient,uint256 nonce,bytes32 receiptsRoot)
function computeLockAttestationHash(
    attestation: LockAttestation,
    casperDomain: CasperDomainConfig
): string {
    const domain = buildTokenFactoryDomain(casperDomain);
    const message = {
        sourceChainId: BigInt(attestation.sourceChainId),
        ethBlockNumber: BigInt(attestation.evmBlockNumber),
        ethTxIndex: BigInt(attestation.evmTxIndex),
        ethLogIndex: BigInt(attestation.evmLogIndex),
        erc20Token: attestation.erc20Token,
        sender: attestation.sender,
        amount: BigInt(attestation.amount),
        casperRecipientType: attestation.casperRecipientType,
        casperRecipient: attestation.casperRecipient,
        nonce: BigInt(attestation.nonce),          // stable deposit nonce
        receiptsRoot: attestation.receiptsRoot,
    };
    return toHex(hashTypedData(domain, LOCK_ATTESTATION_TYPES, 'LockAttestation', message));
}
```

### Burn Attestation (Casper → EVM)

```typescript
function computeBurnAttestationHash(
    attestation: BurnAttestation
): string {
    return ethers.solidityPackedKeccak256(
        [
            'uint64',   // sourceChainId (Casper identifier)
            'uint64',   // destinationChainId
            'bytes32',  // cep18Token
            'address',  // evmToken
            'bytes32',  // sender (Casper account)
            'address',  // evmRecipient
            'uint256',  // amount
            'uint64'    // nonce
        ],
        [
            attestation.sourceChainId,
            attestation.destinationChainId,
            attestation.cep18Token,
            attestation.evmToken,
            attestation.sender,
            attestation.evmRecipient,
            attestation.amount,
            attestation.nonce
        ]
    );
}
```

### EIP-712 Typed Attestations (FIND-001)

Unlock and wrapped mint attestations now use EIP-712 typed data with domain separation.

BRIDGE-E2E-1 extends the same domain-separation rule to Casper-side TokenFactory mint verification: `erc20_lockers: Mapping<u64, H160>` stores the EVM locker `verifyingContract` per `source_chain_id`, and `eip712_domain(source_chain_id)` rejects unset chains with `Erc20LockerNotSet` before signature verification. This closes the live multi-chain failure where one global `erc20_locker` let only the chain matching that single address mint successfully. The fix strengthens FIND-001 / FIND-046 replay isolation and is part of the FIND-223 / FIND-224 Casper EIP-712 integration path.

FIND-235 re-review: the relayer set is still global, but the previous single-chain rationale no longer describes production behavior now that Sepolia, Base Sepolia, and Polygon Amoy are live. BRIDGE-E2E-1 does not split relayer authorization by chain; it narrows signature-domain acceptance per source chain so a valid relayer signature for one configured chain/domain cannot satisfy another chain's TokenFactory domain. Further per-chain relayer policy remains a separate governance/audit decision, not silently risk-accepted under the old rationale.

The EVM-side domain shape remains:

```typescript
const domain = {
    name: 'CasperBridgeLocker',
    version: '1',
    chainId,
    verifyingContract: lockerAddress
};

const types = {
    Unlock: [
        { name: 'token', type: 'address' },
        { name: 'amount', type: 'uint256' },
        { name: 'recipient', type: 'address' },
        { name: 'casperEventKey', type: 'bytes32' },
        { name: 'casperStateRoot', type: 'bytes32' }
    ],
    WrappedMint: [
        { name: 'casperTokenHash', type: 'bytes32' },
        { name: 'amount', type: 'uint256' },
        { name: 'recipient', type: 'address' },
        { name: 'casperEventKey', type: 'bytes32' },
        { name: 'casperStateRoot', type: 'bytes32' }
    ]
};

const unlockValue = {
    token: attestation.token,
    amount: attestation.amount,
    recipient: attestation.recipient,
    casperEventKey: attestation.casperEventKey,
    casperStateRoot: attestation.casperStateRoot
};

const wrappedMintValue = {
    casperTokenHash: attestation.casperTokenHash,
    amount: attestation.amount,
    recipient: attestation.evmRecipient,
    casperEventKey: attestation.casperEventKey,
    casperStateRoot: attestation.casperStateRoot
};

const unlockDigest = ethers.TypedDataEncoder.hash(domain, { Unlock: types.Unlock }, unlockValue);
const wrappedMintDigest = ethers.TypedDataEncoder.hash(
    domain,
    { WrappedMint: types.WrappedMint },
    wrappedMintValue
);
```

## Verification in Rust (Casper)

### Signature Verification

```rust
use secp256k1::{Message, PublicKey, Secp256k1, Signature};

pub fn verify_eth_signature(
    message_hash: &[u8; 32],
    signature: &[u8; 65],
    expected_address: &[u8; 20],
) -> bool {
    let secp = Secp256k1::verification_only();

    // Extract r, s, v from signature
    let r = &signature[0..32];
    let s = &signature[32..64];
    let v = signature[64];

    // Recover public key
    let recovery_id = RecoveryId::from_i32((v - 27) as i32)
        .expect("Invalid recovery ID");

    let message = Message::from_slice(message_hash)
        .expect("Invalid message");

    let sig = RecoverableSignature::from_compact(
        &[r, s].concat(),
        recovery_id
    ).expect("Invalid signature");

    let public_key = secp.recover_ecdsa(&message, &sig)
        .expect("Recovery failed");

    // Derive address from public key
    let address = derive_eth_address(&public_key);

    address == expected_address
}

fn derive_eth_address(public_key: &PublicKey) -> [u8; 20] {
    let serialized = public_key.serialize_uncompressed();
    // Skip first byte (0x04 prefix)
    let hash = keccak256(&serialized[1..]);
    // Take last 20 bytes
    let mut address = [0u8; 20];
    address.copy_from_slice(&hash[12..32]);
    address
}
```

### Hash Computation (Rust)

The Casper contract computes the identical EIP-712 digest via `compute_lock_attestation_hash()` (`contracts/casper/src/crypto.rs`), with cross-language golden-vector tests asserting byte-identical output against the TypeScript implementation:

```rust
pub fn compute_lock_attestation_hash(
    domain: &DomainSeparator,
    source_chain_id: u64,
    eth_block_number: u64,
    eth_tx_index: u64,
    eth_log_index: u32,
    erc20_token: &[u8; 20],
    sender: &[u8; 20],
    amount: &[u8; 32],
    casper_recipient_type: u8,
    casper_recipient: H256,
    nonce: u64,        // stable EVM locker deposit nonce
    receipts_root: H256,
) -> H256 {
    // hash_typed_data(domain, &LockAttestationData { ... })
    // encodes each field as its 32-byte EIP-712 representation
    // per LockAttestationData::type_string().
}
```

### Replay Protection Keying

`LockAttestation::event_id()` — the key used by the contract's processed-events guard — is `keccak256(source_chain_id ‖ nonce)`. It was previously keyed by positional `(eth_block_number, eth_tx_index, eth_log_index)` fields, which can change if the same deposit is re-anchored under a different block context after a reorg; the relayer set could then produce valid attestations for both positional tuples and mint twice for one deposit. The stable locker nonce closes that gap, mirroring `BurnAttestation::event_id()`.

## Replay Protection Verification

### Nonce Tracking

```rust
// In TokenFactory
processed_nonces: Mapping<(u64, u64), bool>  // (chain_id, nonce) → processed

pub fn mint_with_signatures(&mut self, attestation: LockAttestation, ...) {
    // Check nonce not already processed
    let nonce_key = (attestation.source_chain_id, attestation.nonce);

    if self.processed_nonces.get(&nonce_key).unwrap_or(false) {
        revert!("Nonce already processed");
    }

    // ... verification logic ...

    // Mark nonce as processed
    self.processed_nonces.set(nonce_key, true);
}
```

### Chain ID Verification

```rust
pub fn verify_chain_id(
    attestation: &LockAttestation,
    allowed_chains: &[u64],
) -> Result<(), VerificationError> {
    if !allowed_chains.contains(&attestation.source_chain_id) {
        return Err(VerificationError::InvalidChainId);
    }
    Ok(())
}
```

## Testing Verification

### Test Vectors

```typescript
// Known test vector for verification
const testVector = {
    chainId: 11155111n,
    blockNumber: 1000000n,
    blockHash: '0x' + 'aa'.repeat(32),
    stateRoot: '0x' + 'bb'.repeat(32),
    receiptsRoot: '0x' + 'cc'.repeat(32),
    expectedHash: '0x...'  // Pre-computed expected hash
};

describe('Hash Computation', () => {
    it('should produce correct hash for test vector', () => {
        const hash = computeBlockHeaderHash(
            testVector.chainId,
            testVector.blockNumber,
            testVector.blockHash,
            testVector.stateRoot,
            testVector.receiptsRoot
        );
        expect(hash).to.equal(testVector.expectedHash);
    });
});
```

### Cross-Language Verification

Ensure TypeScript and Rust produce identical hashes:

```typescript
// Generate test data
const testData = generateRandomAttestation();
const tsHash = computeAttestationHash(testData);

// Call Rust verification
const rustHash = await casperClient.computeHash(testData);

expect(tsHash).to.equal(rustHash);
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.astralbeam.io/security/verification.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
