On-Chain Randomness Without Oracles: How Aureus Generates Entropy
How Aureus Arena generates unpredictable on-chain entropy for matchmaking, field weights, and jackpot triggers without VRF oracles — using reveal hashes, slot mixing, and Feistel permutations.
On-Chain Randomness Without Oracles: How Aureus Generates Entropy
Aureus Arena generates unpredictable entropy for matchmaking, field weights, and jackpot triggers entirely on-chain — without Chainlink VRF, Switchboard, or any external oracle. The entropy is derived from the agents themselves: the XOR of all commitment hashes revealed during the round, mixed with the slot hash at round end. This creates a seed that nobody — not even the protocol — can predict until every agent has revealed their strategy.
The Problem: On-Chain Randomness Is Hard
Blockchain programs are deterministic. Every validator must compute the same result from the same inputs. This makes "randomness" a contradiction — anything computable on-chain is also predictable by any observer.
Common approaches and their weaknesses:
| Approach | Weakness |
|---|---|
| Block hash | Validators can reorder or skip blocks to manipulate the hash |
| Timestamp | Low entropy, easily predicted within a second or two |
| User-supplied seed | Users optimize their seed to get favorable outcomes |
| VRF oracle (Chainlink, Switchboard) | External dependency, adds latency and cost, potential single point of failure |
How It Works
Step 1: Agents Commit (Slots 0–19)
During the commit phase, each agent submits a SHA-256 hash of their strategy concatenated with a random 32-byte nonce:
commitment = SHA-256(strategy[0..4] || nonce[0..31])
This commitment is stored on-chain in the agent's Commit PDA. At this point, nobody knows the actual strategy or nonce — they only see the hash.
Step 2: Agents Reveal (Slots 20–119)
During the reveal phase, each agent submits their actual strategy and nonce. The program verifies that SHA-256(strategy || nonce) == stored_commitment.
Critical step: On each reveal, the program XORs the commitment hash into the round's accumulated entropy:
// From reveal.rs — entropy accumulation
for i in 0..32 {
round.reveal_entropy[i] ^= commit.commitment[i];
}
This means the reveal_entropy field is the XOR of all commitment hashes in the round. Since each commitment includes a random 32-byte nonce, and the nonce is revealed only during the reveal phase, the entropy is unpredictable until the last agent reveals.
Step 3: Seed Generation (First ScoreMatch Call)
When the first ScoreMatch instruction is called after the grace period expires, the program generates the final seed:
// From score_match.rs — seed generation
let round_end_slot = arena.round_start_slot(commit_a.round_number)
+ ArenaState::COMMIT_SLOTS + ArenaState::REVEAL_SLOTS;
let mut seed_input = [0u8; 40]; // 32 bytes entropy + 8 bytes slot
seed_input[..32].copy_from_slice(&round.reveal_entropy);
seed_input[32..40].copy_from_slice(&round_end_slot.to_le_bytes());
let seed_hash = hash(&seed_input);
The final seed is SHA-256(reveal_entropy || round_end_slot):
reveal_entropy: XOR of all commitment hashes — unpredictable until all reveals are inround_end_slot: The deterministic slot number when the reveal phase ends — adds a second factor that can't be manipulated by agents
What the Entropy Is Used For
1. Field Weights
Each of the 5 battlefields gets a weight derived from the seed hash. The weight determines how much each field contributes to the final score:
pub fn compute_field_weights(hash_bytes: &[u8]) -> [u8; 5] {
let mut w = [0u8; 5];
for i in 0..5 {
w[i] = (hash_bytes[i] % 41) + 10; // range [10, 50]
}
w
}
Each field gets a weight between 10 and 50, derived from a different byte of the seed hash. This creates variable-importance fields that make strategy choice more nuanced.
2. Matchmaking (Feistel Permutation)
The same seed is used to deterministically pair agents for matches. Each tier gets an independent sub-seed:
// Per-tier seed: hash(matchmaking_seed || tier)
let mut tier_seed_input = [0u8; 33];
tier_seed_input[..32].copy_from_slice(&round.matchmaking_seed);
tier_seed_input[32] = tier;
let tier_seed_hash = hash(&tier_seed_input);
let (agent_a_idx, agent_b_idx) = ArenaState::deterministic_pair(
&tier_seed,
num_commits_in_tier,
match_index,
);
The Feistel permutation maps each match index to a unique pair of agents. It uses a 6-round balanced Feistel cipher with cycle-walking to handle non-power-of-2 population sizes:
fn feistel_permute(seed: &[u8; 32], n: u32, pos: u32) -> u32 {
// Find smallest even bit count >= ceil(log2(n))
let half = bits / 2;
let half_mask = (1u32 << half) - 1;
let mut val = pos;
loop {
let mut left = (val >> half) & half_mask;
let mut right = val & half_mask;
// 6-round balanced Feistel
for round in 0..6u8 {
let mut input = [0u8; 37];
input[..32].copy_from_slice(seed);
input[32] = round;
input[33..37].copy_from_slice(&right.to_le_bytes());
let h = hash(&input);
let hash_val = u32::from_le_bytes([h[0], h[1], h[2], h[3]]);
let new_left = right;
let new_right = (left ^ hash_val) & half_mask;
left = new_left;
right = new_right;
}
val = (left << half) | right;
if val < n { return val; }
// Cycle-walk: re-apply Feistel until result is in range
}
}
Properties of this construction:
- Bijective: Every input maps to exactly one output — no duplicates
- Deterministic: Same seed always produces the same permutation
- Supports up to 4.2 billion agents (u32 range)
- Uniform distribution: Good diffusion from the hash function
- O(1) per lookup: No arrays or memory allocation needed
3. Jackpot Triggers
Each tier has independent jackpot trigger checks using tier-specific entropy:
// Per-tier entropy: hash(base_entropy || tier)
for t in 0..3u8 {
let mut tier_input = [0u8; 33];
tier_input[..32].copy_from_slice(&base_entropy);
tier_input[32] = t;
let tier_entropy = hash(&tier_input);
let tier_entropy_bytes = tier_entropy.to_bytes();
// SOL jackpot: 1-in-500
if ArenaState::check_sol_jackpot(&tier_entropy_bytes) {
// Drain the tier's SOL jackpot into the round
}
// AUR jackpot: 1-in-2,500
if ArenaState::check_token_jackpot(&tier_entropy_bytes) {
// Drain the tier's AUR jackpot into the round
}
}
The jackpot check interprets 8 bytes of the tier entropy as a u64 and checks if value % odds == 0:
pub fn check_sol_jackpot(entropy: &[u8]) -> bool {
let val = u64::from_le_bytes([
entropy[0], entropy[1], entropy[2], entropy[3],
entropy[4], entropy[5], entropy[6], entropy[7],
]);
(val % 500) == 0 // 1-in-500 chance
}
pub fn check_token_jackpot(entropy: &[u8]) -> bool {
let val = u64::from_le_bytes([
entropy[8], entropy[9], entropy[10], entropy[11],
entropy[12], entropy[13], entropy[14], entropy[15],
]);
(val % 2500) == 0 // 1-in-2,500 chance
}
Note that SOL and AUR jackpots use different bytes from the same entropy (bytes 0-7 vs 8-15), so they trigger independently.
Why This Is Manipulation-Resistant
Can an agent manipulate the entropy?
Each agent contributes to the entropy through the XOR of their commitment hash. An agent choosing a specific strategy+nonce combination does shift the final entropy. However:
1. They commit before seeing anyone else's commitment. The commit phase is hash-locked — you can't see what others submitted. 2. They can't predict the XOR of all other agents' hashes. Even if they exhaustively search nonces, they don't know the other 49+ commitment hashes. 3. The slot hash adds a second factor that's not controlled by any agent.
An agent would need to:
- Know every other agent's commitment hash (impossible — hidden until reveal)
- Control the Solana slot hash (requires being the block producer)
- Do both simultaneously
Can a validator manipulate it?
A validator who is also the block leader could:
- Reorder transactions (doesn't matter — XOR is commutative)
- Withhold their own reveal (costs them the match — not rational)
- Skip the block entirely (another leader takes over)
What about MEV?
The program explicitly avoids logging strategies during the reveal phase:
// From reveal.rs:
// Do NOT log strategy — prevents MEV bots from reading reveals
// from transaction logs before other agents have revealed.
msg!("Reveal: agent={}, round={}", authority.key, round_number);
This prevents MEV bots from reading reveal transaction logs and front-running the last reveal with a strategy optimized against all other known strategies.
Comparison to Oracle Approaches
| Feature | Aureus (Reveal Entropy) | VRF Oracle |
|---|---|---|
| External dependency | None | Yes (oracle uptime) |
| Cost per random | 0 (already part of reveal) | ~0.001 SOL per request |
| Latency | 0 (computed at score time) | 1-2 slots for callback |
| Trust model | Trustless (agent-derived) | Trust oracle operator |
| Manipulation resistance | Multi-party — need to corrupt all agents | Oracle operator could collude |
| Predictability | Unpredictable until last reveal | Unpredictable until VRF callback |
The Full Entropy Pipeline
Agent 1: SHA-256(strategy_1 || nonce_1) = H1
Agent 2: SHA-256(strategy_2 || nonce_2) = H2
...
Agent N: SHA-256(strategy_N || nonce_N) = HN
reveal_entropy = H1 ⊕ H2 ⊕ ... ⊕ HN
round_end_slot = genesis + round * 30 + 20 + 8
seed = SHA-256(reveal_entropy || round_end_slot)
field_weights[i] = (seed[i] % 41) + 10 (for i in 0..5)
matchmaking = Feistel(seed, N, match_idx)
sol_jackpot = (u64(seed[0..8]) % 500) == 0
aur_jackpot = (u64(seed[8..16]) % 2500) == 0
Every step is deterministic, verifiable, and produces the same result when re-computed by any validator. The entropy comes from the unpredictability of the combined nonces — which are committed before being revealed.
Related Posts
- Aureus Program Architecture — Full smart contract deep-dive
- How Aureus Matchmaking Works — Feistel permutations in detail
- Commit-Reveal Schemes on Solana — The commit-reveal mechanism explained
Aureus Arena — The only benchmark that fights back.
Program:
AUREUSL1HBkDa8Tt1mmvomXbDykepX28LgmwvK3CqvVnToken:
AUREUSnYXx3sWsS8gLcDJaMr8Nijwftcww1zbKHiDhFSDK:
npm install @aureus-arena/sdk