""" Refracting Light v3 exam: E46's own proof-of-work hash, put to the test. STATUS: written 8 October 2026 by Claude at the owner's request, in the same plain-English style as exam_wo12.py. Run from anywhere: python3 -I e46/tests/exam_rl_v3.py the exam python3 -I e46/tests/exam_rl_v3.py --debug raw values, step by step BEFORE YOU RUN IT: set aside time to read it. The comments explain what a hash must do and how each test checks it. TL;DR 1 two_ways the pure-Python reference and the C++ library must give the same answer for every message (fixed and random) 2 in_pieces hashing a message in chunks must equal hashing it whole 3 avalanche flip one input bit: about half the 128 output bits flip 4 balance every output bit is 1 about half the time 5 length a message and the same message + one zero byte differ 6 mini_rad find a 24-bit collision: should take about 5,000 tries, as the birthday maths predicts (a tiny Rad) HONEST LIMITS These tests catch OBVIOUS flaws: a broken implementation, a lazy mixer, a biased output. Passing them does NOT prove Refracting Light is secure; no test can. That's why mission v3 invites people to attack it, and why real Rads act as a public alarm. """ # --------------------------------------------------------------------------- # IMPORTS: standard library only, so anyone can replicate this exam # random random messages (a fresh, unrecorded seed each run) # subprocess runs the C++ tool rl_cli, the library the node uses # statistics averages and spreads # Plus the repo's own pure-Python Refracting Light reference. # --------------------------------------------------------------------------- import random import statistics import subprocess import sys import os # Work from the repository root, wherever this file is run from: # this file lives in e46/tests/, so the root is two folders up. os.chdir(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, ".") # the repo root, for the RL reference from refracting_light_v3 import digest as python_rl RNG = random.SystemRandom() # unpredictable: a surprise exam each run RL_TOOL = "build/release/rl_cli" def cpp_rl(messages, mode="one", chunk=None): """ Hash many messages with the C++ library in one go (fast). Returns each 128-bit digest as an int, the same form as python_rl. The tool reads one message per line, in hex, and answers " ". We keep the first. mode "stream" feeds each message in chunks of `chunk` bytes. """ args = [RL_TOOL, "v3", mode] + ([str(chunk)] if chunk else []) text = "\n".join(m.hex() for m in messages) + "\n" out = subprocess.run(args, input=text, capture_output=True, text=True).stdout return [int(line.split()[0], 16) for line in out.splitlines()] def bits_different(a, b): """How many of the 128 bits differ between two digests (0 to 128).""" return bin(a ^ b).count("1") # --------------------------------------------------------------------------- # Test 1: two_ways # --------------------------------------------------------------------------- def two_ways(): """ The same message must give the same digest from both implementations. Two independent programs (Python, written for clarity; C++, written for speed) agreeing on every message is strong evidence neither has a bug. FIXED MESSAGES b"" the empty message b"hi" digest 111C0591 5036A35F EED5CE70 A5DE0C5B (hex) every single byte 00 to FF (0 to 255): 256 messages a 120-byte header the size of every E46 block header RANDOM MESSAGES 300 messages, 0 to 200 bytes long, fresh each run. """ fixed = [b"", b"hi", bytes(120)] + [bytes([b]) for b in range(256)] rand = [RNG.randbytes(RNG.randint(0, 200)) for _ in range(300)] messages = fixed + rand cpp = cpp_rl(messages) mismatches = sum(python_rl(m) != c for m, c in zip(messages, cpp)) return mismatches == 0, f"{len(messages)} messages, {mismatches} mismatches" # --------------------------------------------------------------------------- # Test 2: in_pieces # --------------------------------------------------------------------------- def in_pieces(): """ A miner or node may receive a message in chunks. Feeding it in pieces must give exactly the same digest as feeding it all at once. Chunk sizes tried: 1 byte, 7 bytes, 64 bytes. """ messages = [RNG.randbytes(RNG.randint(0, 300)) for _ in range(200)] whole = cpp_rl(messages) bad = 0 for size in (1, 7, 64): bad += sum(a != b for a, b in zip(whole, cpp_rl(messages, "stream", size))) return bad == 0, f"200 messages x 3 chunk sizes, {bad} mismatches" # --------------------------------------------------------------------------- # Test 3: avalanche # --------------------------------------------------------------------------- def avalanche(): """ WHAT "AVALANCHE" MEANS Like a snow avalanche: one small push at the top, and the whole slope comes down. For a hash, one tiny change to the input (a single bit, like "hi" -> "hj") should change about half of the output, in places nobody can predict. The two digests then look completely unrelated, so an attacker learns nothing by nudging the input. "hi" -> 111C0591 5036A35F EED5CE70 A5DE0C5B "hj" -> 45F16FED 03919A82 362D5322 4D9026D5 69 of 128 bits differ THE HAWKING RADIATION LINK (owner's note) A hash is one-way: nothing in E46 ever turns a digest back into its message; everything is checked forward (recompute and compare). In physics, a black hole leaks scrambled Hawking radiation, and the open "information paradox" asks whether what fell in can be recovered from it. Refracting Light borrows that picture: the 128-bit digest is the radiation, the 384 hidden bits are the core. "Can a digest be translated back?" is E46's version of the paradox, and mission v1, "Decrypt Hawking Radiation". Today: nobody knows how, every test says it holds up, and anyone is invited to try. (A picture, not physics: the hash is ordinary integer maths.) Flip ONE bit of the input; a good hash flips about HALF the output bits (64 of 128), so a tiny change gives a totally different digest. If it flipped only a few, an attacker could steer the output. Expected: average about 64, with a natural spread of about 5.7 (like counting heads in 128 coin tosses). Pass if the average over 2,000 flips is between 62 and 66, and no flip changes fewer than 32 bits. """ originals, flipped = [], [] for _ in range(2000): m = bytearray(RNG.randbytes(RNG.randint(1, 120))) originals.append(bytes(m)) bit = RNG.randrange(len(m) * 8) m[bit // 8] ^= 1 << (bit % 8) flipped.append(bytes(m)) a, b = cpp_rl(originals), cpp_rl(flipped) counts = [bits_different(x, y) for x, y in zip(a, b)] mean = statistics.mean(counts) ok = 62 <= mean <= 66 and min(counts) >= 32 return ok, f"mean {mean:.2f} bits flipped (ideal 64), min {min(counts)}, max {max(counts)}" # --------------------------------------------------------------------------- # Test 4: balance # --------------------------------------------------------------------------- def balance(): """ Each of the 128 output bits should be 1 about half the time. A bit stuck at 0 or 1, or leaning one way, is a crack to pry at. Over 4,000 random messages each bit should land between 45% and 55%. """ digests = cpp_rl([RNG.randbytes(RNG.randint(0, 100)) for _ in range(4000)]) shares = [sum((d >> k) & 1 for d in digests) / len(digests) for k in range(128)] worst = max(abs(s - 0.5) for s in shares) return worst <= 0.05, f"all 128 bits between {min(shares):.3f} and {max(shares):.3f} (ideal 0.500)" # --------------------------------------------------------------------------- # Test 5: length # --------------------------------------------------------------------------- def length(): """ "abc" and "abc" + one zero byte (00) must hash differently. Some weak designs pad messages with zeros and can't tell these apart. Refracting Light records the message length, so they must differ. """ messages = [RNG.randbytes(RNG.randint(0, 100)) for _ in range(500)] a = cpp_rl(messages) b = cpp_rl([m + b"\x00" for m in messages]) same = sum(x == y for x, y in zip(a, b)) return same == 0, f"500 pairs, {same} identical" # --------------------------------------------------------------------------- # Test 6: mini_rad # --------------------------------------------------------------------------- def mini_rad(bits=24, searches=20): """ A tiny Rad: find two different messages whose digests share their top 24 bits. The "birthday" maths says a good 24-bit search needs about 1.25 x 2^12 = about 5,100 tries (like how 23 people are enough for two to probably share a birthday). Far FEWER tries would mean the hash is leaking structure: exactly what real Rads watch for on the live chain, at 41+ bits. Pass if the average over 20 searches is between 0.6x and 1.5x the prediction. """ expected = 1.2533 * 2 ** (bits / 2) tries = [] for _ in range(searches): seen, n, found = {}, 0, False while not found: batch = [RNG.randbytes(16) for _ in range(2048)] for m, d in zip(batch, cpp_rl(batch)): n += 1 top = d >> (128 - bits) if top in seen and seen[top] != m: found = True break seen[top] = m tries.append(n) ratio = statistics.mean(tries) / expected return 0.6 <= ratio <= 1.5, f"average {statistics.mean(tries):,.0f} tries (predicted {expected:,.0f}), ratio {ratio:.2f}" # --------------------------------------------------------------------------- # Run the exam # --------------------------------------------------------------------------- TESTS = [("1 two_ways", two_ways), ("2 in_pieces", in_pieces), ("3 avalanche", avalanche), ("4 balance", balance), ("5 length", length), ("6 mini_rad", mini_rad)] def run_exam(): passed = 0 for name, test in TESTS: ok, detail = test() passed += ok print(f"{'PASS' if ok else 'FAIL'} {name:12} {detail}") print(f"\n{passed} of {len(TESTS)} passed") return passed == len(TESTS) def show(d): """A 128-bit digest as 32 hex characters in groups of 8.""" h = f"{d:032X}" return " ".join(h[i:i + 8] for i in range(0, 32, 8)) def debug(): """ Raw digests for every test, so you can see what each one checks. Run: python3 -I e46/tests/exam_rl_v3.py --debug """ print("TEST 1 two_ways: Python and C++ must agree") for m in (b"", b"hi", bytes(120), RNG.randbytes(20)): label = repr(m) if len(m) <= 4 else f"{len(m)} bytes ({m[:4].hex().upper()}...)" py, cpp = python_rl(m), cpp_rl([m])[0] print(f" {label:26} python {show(py)}") print(f" {'':26} C++ {show(cpp)} {'same' if py == cpp else 'DIFFERENT'}") print("\nTEST 2 in_pieces: chunks must equal the whole") m = RNG.randbytes(100) print(f" whole {show(cpp_rl([m])[0])}") for size in (1, 7, 64): print(f" {size:>2}-byte chunks {show(cpp_rl([m], 'stream', size)[0])}") print("\nTEST 3 avalanche: one bit in, about 64 bits out") for m, f in ((b"hi", b"hj"), (b"E46", b"E47"), (bytes(32), bytes(31) + b"\x01")): a, b = python_rl(m), python_rl(f) print(f" {m.hex().upper()[:12]:12} {show(a)}") print(f" {f.hex().upper()[:12]:12} {show(b)} {bits_different(a, b)} of 128 bits differ") print(f" {'flipped':12} {format(a ^ b, '0128b')[:64]}") print(f" {'':12} {format(a ^ b, '0128b')[64:]}") print("\nTEST 4 balance: each bit about 50% ones (first 8 bits shown, 4,000 digests)") digests = cpp_rl([RNG.randbytes(RNG.randint(0, 100)) for _ in range(4000)]) for k in range(127, 119, -1): share = sum((d >> k) & 1 for d in digests) / len(digests) print(f" bit {127 - k:>3} {share:.3f} {'#' * round(share * 40)}") print("\nTEST 5 length: a message and the same + one zero byte") m = b"abc" print(f" 'abc' {show(python_rl(m))}") print(f" 'abc' + 00 {show(python_rl(m + bytes(1)))}") print("\nTEST 6 mini_rad: two messages whose digests share the top 24 bits") seen, n = {}, 0 while True: batch = [RNG.randbytes(16) for _ in range(2048)] hit = None for msg, d in zip(batch, cpp_rl(batch)): n += 1 top = d >> 104 if top in seen and seen[top][0] != msg: hit = (seen[top], (msg, d)) break seen[top] = (msg, d) if hit: break (m1, d1), (m2, d2) = hit print(f" found after {n:,} tries (prediction about 5,134)") print(f" message A {m1.hex().upper()} -> {show(d1)}") print(f" message B {m2.hex().upper()} -> {show(d2)}") print(f" {'':48}^^^^^^ the first 6 hex characters (24 bits) match") print(f" matching leading bits: {128 - (d1 ^ d2).bit_length()}") if __name__ == "__main__": if "--debug" in sys.argv: debug() sys.exit(0) sys.exit(0 if run_exam() else 1)