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