Skip to main content

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:

  1. Fetching block data from each blockchain source
  2. Computing the hash using the specified algorithm
  3. Comparing the result against what rng.dev published
  4. Submitting signed attestations

Blockchain Sources

The beacon uses 8 blockchain sources in alphabetical order:

SourceIdentifierFinalityData Used
aptosBlock height~900msBlock hash + 1st TX
arbitrumBlock number~250msBlock hash + 1st TX
baseBlock number~2sBlock hash + 1st TX
bitcoinBlock height6 confirmationsBlock hash + 2nd TX*
cardanoSlot number~20sBlock hash + 1st TX
ethereumBlock numberFinalized tagBlock hash + 1st TX
solanaSlot numberConfirmedBlockhash + 1st TX
suiCheckpoint~400msDigest + 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

PropertySpecification
EncodingUTF-8
IdentifierDecimal integer (no leading zeros)
HashLowercase hex (as returned by source)*
Transaction IDLowercase hex (as returned by source)*
SeparatorColon : 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

ChainFinality Requirement
aptosBlock with committed status
arbitrumBlock with finalized tag
baseBlock with finalized tag
bitcoin6 confirmations (tip height - 6)
cardanoBlock from confirmed slot
ethereumBlock with finalized tag
solanaSlot with confirmed commitment
suiCheckpoint 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 &current {
if *byte <= 251 {
return Ok((byte % 6) + 1);
}
}
let mut hasher = Sha3_256::new();
hasher.update(&current);
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:


Changelog

VersionDateChanges
1.02026-03-25Initial specification