Validator Technical Specification
This document provides the formal specification for independently verifying rng.dev beacon rounds. It is intended for:
- Blockchain foundations running their own nodes
- Analytics firms with existing blockchain infrastructure
- Security researchers implementing verification tooling
Overview
Validators independently verify beacon rounds by:
- Fetching block data from each blockchain source
- Computing the hash using the specified algorithm
- Comparing the result against what rng.dev published
- Submitting signed attestations
Blockchain Sources
The beacon uses 8 blockchain sources in alphabetical order:
| Source | Identifier | Finality | Data Used |
|---|---|---|---|
| aptos | Block height | ~900ms | Block hash + 1st TX |
| arbitrum | Block number | ~250ms | Block hash + 1st TX |
| base | Block number | ~2s | Block hash + 1st TX |
| bitcoin | Block height | 6 confirmations | Block hash + 2nd TX* |
| cardano | Slot number | ~20s | Block hash + 1st TX |
| ethereum | Block number | Finalized tag | Block hash + 1st TX |
| solana | Slot number | Confirmed | Blockhash + 1st TX |
| sui | Checkpoint | ~400ms | Digest + 1st TX |
*Bitcoin uses the 2nd transaction (index 1) to skip the coinbase transaction.
Canonical Input Format
Each source's input is formatted as:
{identifier}:{hash}:{tx_id}
If no transaction exists (empty block), the format is:
{identifier}:{hash}
Format Rules
| Property | Specification |
|---|---|
| Encoding | UTF-8 |
| Identifier | Decimal integer (no leading zeros) |
| Hash | Lowercase hex (as returned by source)* |
| Transaction ID | Lowercase hex (as returned by source)* |
| Separator | Colon : between fields |
*Some chains return uppercase hex - convert to lowercase before hashing.
Examples
# Bitcoin (height 831245, 2nd TX exists)
831245:00000000000000000002a7c4de53e4a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4:def4567890abcdef1234567890abcdef12345678
# Ethereum (block 19234567, 1st TX exists)
19234567:0x8a3f2e1d4c5b6a7980123456789abcdef0123456789abcdef0123456789abcdef:0x1a2b3c4d5e6f7890abcdef1234567890abcdef12345678901234567890abcdef
# Solana (slot 245678901, empty block - no TX)
245678901:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp
Hash Algorithm
The beacon uses SHA3-256 with sequential mixing.
Algorithm
def compute_output_hash(inputs: dict[str, str]) -> str:
"""
Compute beacon output hash from source inputs.
Args:
inputs: Dict mapping source name to canonical input string.
Keys must be: aptos, arbitrum, base, bitcoin, cardano,
ethereum, solana, sui
Returns:
64-character lowercase hex string (SHA3-256 hash).
"""
import hashlib
# Step 1: Sort sources alphabetically
sorted_sources = sorted(inputs.keys())
# Step 2: Sequential mixing
state: str | None = None
for source in sorted_sources:
canonical_input = inputs[source]
if state is None:
# First source: state = SHA3-256(input)
state = hashlib.sha3_256(canonical_input.encode("utf-8")).hexdigest()
else:
# Subsequent sources: state = SHA3-256(state + "|" + input)
combined = f"{state}|{canonical_input}"
state = hashlib.sha3_256(combined.encode("utf-8")).hexdigest()
return state
Sequential Mixing Visualization
Source order: aptos, arbitrum, base, bitcoin, cardano, ethereum, solana, sui
Step 1: state₁ = SHA3-256(aptos_input)
Step 2: state₂ = SHA3-256(state₁ + "|" + arbitrum_input)
Step 3: state₃ = SHA3-256(state₂ + "|" + base_input)
Step 4: state₄ = SHA3-256(state₃ + "|" + bitcoin_input)
Step 5: state₅ = SHA3-256(state₄ + "|" + cardano_input)
Step 6: state₆ = SHA3-256(state₅ + "|" + ethereum_input)
Step 7: state₇ = SHA3-256(state₆ + "|" + solana_input)
Step 8: output = SHA3-256(state₇ + "|" + sui_input)
Critical: SHA3-256, Not SHA-256
The beacon uses SHA3-256 (Keccak-based, NIST FIPS 202), not SHA-256 (SHA-2 family). These produce completely different outputs.
import hashlib
data = b"test"
# SHA-256 (WRONG)
sha256 = hashlib.sha256(data).hexdigest()
# -> 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
# SHA3-256 (CORRECT)
sha3_256 = hashlib.sha3_256(data).hexdigest()
# -> 36f028580bb02cc8272a9a020f4200e346e276ae664e45ee80745574e2f5ab80
Die Value Derivation
The die value (1-6) is derived using rejection sampling to avoid modulo bias.
Algorithm
def derive_die_value(hash_hex: str) -> int:
"""
Derive a fair die value (1-6) from a hash.
Uses rejection sampling:
- Byte values 0-251 map evenly to 1-6 (42 values each)
- Byte values 252-255 are rejected (would cause bias)
- If all 32 bytes are in reject range, rehash and retry
Args:
hash_hex: 64-character lowercase hex string.
Returns:
Integer from 1 to 6 inclusive.
"""
import hashlib
current_bytes = bytes.fromhex(hash_hex)
max_iterations = 100 # Safety bound
for _ in range(max_iterations):
for byte_value in current_bytes:
if byte_value <= 251: # 252 = 42 × 6
return (byte_value % 6) + 1
# All bytes in reject range (astronomically unlikely)
# Rehash and try again
current_bytes = hashlib.sha3_256(current_bytes).digest()
raise RuntimeError("Rejection sampling exhausted")
Why Rejection Sampling?
Simple modulo (hash_int % 6) would introduce bias because 256 is not divisible by 6:
256 = 42 × 6 + 4
Without rejection: values 1-4 appear 43 times, values 5-6 appear 42 times
With rejection: all values appear equally (42 times each)
Block Selection
Round Timing
Each round lasts 1000ms. The round number is the Unix timestamp in seconds.
Round N begins at: N × 1000 ms since Unix epoch
Round N ends at: (N + 1) × 1000 ms since Unix epoch
Block Selection Rule
For each round N, use the latest finalized block before the round end time:
Block timestamp ≤ (N + 1) × 1000 ms
Finality Requirements by Chain
| Chain | Finality Requirement |
|---|---|
| aptos | Block with committed status |
| arbitrum | Block with finalized tag |
| base | Block with finalized tag |
| bitcoin | 6 confirmations (tip height - 6) |
| cardano | Block from confirmed slot |
| ethereum | Block with finalized tag |
| solana | Slot with confirmed commitment |
| sui | Checkpoint with certified status |
API for Validators
Get Round Data
GET /api/v1/round/{round_number}
Response includes the inputs field with all canonical inputs:
{
"round": 12345678,
"output_hash": "a3f2e8c9...",
"die_value": 4,
"inputs": {
"aptos": "12345678:0xabc123...:0xdef456...",
"arbitrum": "442950038:0x9a877ac9...:0xa4b31d82...",
"base": "43507585:0x6f0e768a...:0x63b60a42...",
"bitcoin": "831245:0000000000000000000...:def4567890abc...",
"cardano": "123456789:abc...def:ghi7891234...",
"ethereum": "19234567:0x8a3f2e...b9:0x1a2b3c4d...",
"solana": "245678901:5eykt...:5VERv8NMvzbJMEkV...",
"sui": "12345678:abc123...:def789..."
}
}
Submit Attestation
POST /api/v1/validators/{validator_id}/attestations
Request body:
{
"round_number": 12345678,
"attested_hash": "<64-char-lowercase-hex-hash>",
"signature": "base64-encoded-ed25519-signature"
}
The signature is over the message:
beacon:attest:{round_number}:{attested_hash}:{validator_id}
Encoded as UTF-8 bytes, signed with Ed25519.
Reference Implementation
Python
#!/usr/bin/env python3
"""
Reference implementation for beacon verification.
"""
import hashlib
from typing import Dict
def format_canonical_input(identifier: int, hash: str, tx_id: str | None = None) -> str:
"""Format canonical input string."""
if tx_id:
return f"{identifier}:{hash}:{tx_id}"
return f"{identifier}:{hash}"
def compute_output_hash(inputs: Dict[str, str]) -> str:
"""Compute output hash using sequential mixing."""
sorted_sources = sorted(inputs.keys())
state = None
for source in sorted_sources:
canonical = inputs[source]
if state is None:
state = hashlib.sha3_256(canonical.encode("utf-8")).hexdigest()
else:
combined = f"{state}|{canonical}"
state = hashlib.sha3_256(combined.encode("utf-8")).hexdigest()
return state
def derive_die_value(hash_hex: str) -> int:
"""Derive die value using rejection sampling."""
current = bytes.fromhex(hash_hex)
for _ in range(100):
for byte_val in current:
if byte_val <= 251:
return (byte_val % 6) + 1
current = hashlib.sha3_256(current).digest()
raise RuntimeError("Rejection sampling exhausted")
def verify_round(inputs: Dict[str, str], expected_hash: str, expected_die: int) -> bool:
"""Verify a complete round."""
computed_hash = compute_output_hash(inputs)
computed_die = derive_die_value(computed_hash)
return computed_hash == expected_hash and computed_die == expected_die
Go
package beacon
import (
"crypto/sha3"
"encoding/hex"
"fmt"
"sort"
)
func ComputeOutputHash(inputs map[string]string) string {
// Sort sources alphabetically
sources := make([]string, 0, len(inputs))
for source := range inputs {
sources = append(sources, source)
}
sort.Strings(sources)
// Sequential mixing
var state []byte
for _, source := range sources {
canonical := inputs[source]
if state == nil {
hash := sha3.Sum256([]byte(canonical))
state = hash[:]
} else {
combined := fmt.Sprintf("%x|%s", state, canonical)
hash := sha3.Sum256([]byte(combined))
state = hash[:]
}
}
return hex.EncodeToString(state)
}
func DeriveDieValue(hashHex string) (int, error) {
current, err := hex.DecodeString(hashHex)
if err != nil {
return 0, err
}
for i := 0; i < 100; i++ {
for _, b := range current {
if b <= 251 {
return int(b%6) + 1, nil
}
}
hash := sha3.Sum256(current)
current = hash[:]
}
return 0, fmt.Errorf("rejection sampling exhausted")
}
Rust
use sha3::{Sha3_256, Digest};
use std::collections::BTreeMap;
pub fn compute_output_hash(inputs: &BTreeMap<String, String>) -> String {
let mut state: Option<Vec<u8>> = None;
// BTreeMap iterates in sorted order
for (_source, canonical) in inputs {
if let Some(prev) = state {
let combined = format!("{}|{}", hex::encode(&prev), canonical);
let mut hasher = Sha3_256::new();
hasher.update(combined.as_bytes());
state = Some(hasher.finalize().to_vec());
} else {
let mut hasher = Sha3_256::new();
hasher.update(canonical.as_bytes());
state = Some(hasher.finalize().to_vec());
}
}
hex::encode(state.unwrap())
}
pub fn derive_die_value(hash_hex: &str) -> Result<u8, &'static str> {
let mut current = hex::decode(hash_hex).map_err(|_| "invalid hex")?;
for _ in 0..100 {
for byte in ¤t {
if *byte <= 251 {
return Ok((byte % 6) + 1);
}
}
let mut hasher = Sha3_256::new();
hasher.update(¤t);
current = hasher.finalize().to_vec();
}
Err("rejection sampling exhausted")
}
Verification Levels
Level 1: Hash Verification (Dependent)
Fetch inputs from rng.dev API, recompute hash.
- Verifies: Correct hash computation
- Trusts: rng.dev to fetch correct blockchain data
Level 2: Independent Verification (API-Based)
Fetch inputs directly from blockchain APIs (Alchemy, Infura, etc.), compare against rng.dev.
- Verifies: Correct hash computation AND correct data fetching
- Requires: API keys for each blockchain
Level 3: Partial Independent Verification (Node-Based)
Fetch inputs from your own blockchain nodes for the chains you operate.
- Verifies: Data correctness for chains you run nodes for
- Requires: Running 1+ blockchain nodes
- Use case: Blockchain foundations verifying their own chain's data
For example, Cardano Foundation would verify Cardano data directly from their nodes, while trusting rng.dev (or API providers) for the other 7 chains. This partial verification is valuable - it confirms the beacon is correctly fetching data from at least one chain.
Attestations can indicate which chains were independently verified:
{
"round_number": 12345678,
"attested_hash": "abc...",
"independently_verified_chains": ["cardano"],
"signature": "..."
}
Attestation Submission
Validators submit signed attestations to confirm verification:
Ed25519 Signature Format
Message: "beacon:attest:{round}:{hash}:{validator_id}"
Encoding: UTF-8 bytes
Algorithm: Ed25519
Signature: Base64-encoded 64-byte signature
Example
from nacl.signing import SigningKey
import base64
def sign_attestation(
private_key: bytes,
round_number: int,
attested_hash: str,
validator_id: str
) -> str:
"""Sign an attestation."""
message = f"beacon:attest:{round_number}:{attested_hash}:{validator_id}"
signing_key = SigningKey(private_key)
signed = signing_key.sign(message.encode("utf-8"))
return base64.b64encode(signed.signature).decode("ascii")
Contact
For integration support:
- GitHub: github.com/rngdotdev/beacon
- Email: [email protected]
Changelog
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-03-25 | Initial specification |