aere-contracts/contracts/oracle/AereRandomnessBeaconV2.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

404 lines
19 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
/**
* @title AereDrandQuicknetBLS
* @notice Real on-chain BLS12-381 verification of drand beacon signatures for
* the League of Entropy "quicknet" chain, built entirely on Aere's
* live EIP-2537 precompiles. This library replaces the honesty gap in
* the original AereRandomnessBeacon, whose `_verify` was a placeholder
* that returned `true` for every input (it verified nothing).
*
* Target chain: drand quicknet (scheme id `bls-unchained-g1-rfc9380`).
* chain hash : 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
* period : 3 seconds genesis: 1692803367 (unix)
* public key : 96-byte compressed G2 point (see PUBKEY_G2 below, decompressed)
* signatures : 48-byte compressed G1 points
* message : SHA-256( round_number as 8-byte big-endian ) [UNCHAINED]
* hash-to-curve DST: "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"
*
* The min-signature-size BLS check (signature in G1, public key in G2) is:
*
* e( H(m), pubkey ) == e( signature, G2_generator )
*
* which we verify as a single EIP-2537 pairing product against 1:
*
* e( H(m), pubkey ) * e( signature, -G2_generator ) == 1
*
* Every step is fail-closed. Any malformed input, any precompile that is
* absent (returns empty) or returns the wrong-length output, any failed
* pairing, all resolve to `false`. There is no path that returns `true`
* without a genuine pairing equality.
*
* HONESTY / SCOPE NOTE: drand's BLS12-381 is CLASSICAL cryptography. It is
* breakable by a sufficiently large quantum computer (Shor). This library
* makes the beacon actually verify what it stores, which is a correctness and
* honesty fix. It does NOT make the randomness post-quantum. Aere's PQC work
* (Falcon/ML-DSA/SLH-DSA precompiles) is a separate track and is not involved
* in drand verification.
*
* EIP-2537 precompiles used (all "Supported" on Aere mainnet, Prague parity):
* 0x05 MODEXP (pre-Prague; modular exponentiation for hash-to-field reduction and sqrt)
* 0x0b BLS12_G1ADD
* 0x0f BLS12_PAIRING_CHECK
* 0x10 BLS12_MAP_FP_TO_G1
*/
library AereDrandQuicknetBLS {
// ── precompile addresses ──
address internal constant MODEXP = address(0x05);
address internal constant BLS_G1ADD = address(0x0b);
address internal constant BLS_PAIRING = address(0x0f);
address internal constant BLS_MAP_FP_G1 = address(0x10);
// ── field constants (BLS12-381 base field Fp), big-endian, 48 bytes ──
// p = 0x1a0111ea...aaab
bytes internal constant FIELD_MODULUS =
hex"1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab";
// (p + 1) / 4 — sqrt exponent, valid because p ≡ 3 (mod 4). 48 bytes.
bytes internal constant SQRT_EXP =
hex"0680447a8e5ff9a692c6e9ed90d2eb35d91dd2e13ce144afd9cc34a83dac3d8907aaffffac54ffffee7fbfffffffeaab";
// RFC 9380 DST_prime = DST || I2OSP(len(DST), 1). len(DST) = 43 = 0x2b.
// DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"
bytes internal constant DST_PRIME =
hex"424c535f5349475f424c53313233383147315f584d443a5348412d3235365f535357555f524f5f4e554c5f2b";
// drand quicknet aggregated public key, decompressed to an EIP-2537 G2
// point (256 bytes: x.c0 || x.c1 || y.c0 || y.c1, each a 64-byte Fp element).
// Decompressed off-chain from the canonical 96-byte compressed key
// 83cf0f28...ece45a (verifiable at api.drand.sh/<chainhash>/info).
bytes internal constant PUBKEY_G2 =
hex"000000000000000000000000000000000d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a0000000000000000000000000000000003cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d106451000000000000000000000000000000000e5db2b6bfbb01c867749cadffca88b36c24f3012ba09fc4d3022c5c37dce0f977d3adb5d183c7477c442b1f045152730000000000000000000000000000000001a714f2edb74119a2f2b0d5a7c75ba902d163700a61bc224ededd8e63aef7be1aaf8e93d7a9718b047ccddb3eb5d68b";
// Negated BLS12-381 G2 generator, EIP-2537 encoding (256 bytes). Constant
// for all of BLS12-381; used so the pairing product checks against 1.
bytes internal constant NEG_G2_GENERATOR =
hex"00000000000000000000000000000000024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb80000000000000000000000000000000013e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e000000000000000000000000000000000d1b3cc2c7027888be51d9ef691d77bcb679afda66c73f17f9ee3837a55024f78c71363275a75d75d86bab79f74782aa0000000000000000000000000000000013fa4d4a0ad8b1ce186ed5061789213d993923066dddaf1040bc3ff59f825c78df74f2d75467e25e0f55f8a00fa030ed";
// ───────────────────────── message ─────────────────────────
/// drand quicknet is UNCHAINED: message = SHA-256(round as 8-byte big-endian).
function messageForRound(uint64 round) internal pure returns (bytes32) {
return sha256(abi.encodePacked(round));
}
/// Canonical drand randomness derivation: SHA-256 of the compressed signature.
function canonicalRandomness(bytes memory compressedSig) internal pure returns (bytes32) {
return sha256(compressedSig);
}
// ─────────────────── RFC 9380 hash-to-field ───────────────────
/**
* @dev expand_message_xmd with SHA-256, len_in_bytes = 128 (RFC 9380 §5.3.1),
* then reduce the two 64-byte blocks mod p into EIP-2537 Fp elements.
* Returns two 64-byte field elements (u0, u1). SHA-256 (0x02) and
* MODEXP (0x05) only, so this runs on any EVM (no EIP-2537 needed).
*/
function hashToField(bytes32 message) internal view returns (bytes memory u0, bytes memory u1) {
// msg_prime = Z_pad(64) || msg || l_i_b_str(0x0080) || 0x00 || DST_prime
bytes memory zPad = new bytes(64);
bytes32 b0 = sha256(abi.encodePacked(zPad, message, uint16(128), uint8(0), DST_PRIME));
bytes32 b1 = sha256(abi.encodePacked(b0, uint8(1), DST_PRIME));
bytes32 b2 = sha256(abi.encodePacked(b0 ^ b1, uint8(2), DST_PRIME));
bytes32 b3 = sha256(abi.encodePacked(b0 ^ b2, uint8(3), DST_PRIME));
bytes32 b4 = sha256(abi.encodePacked(b0 ^ b3, uint8(4), DST_PRIME));
// uniform_bytes = b1 || b2 || b3 || b4 (128 bytes); two 64-byte blocks.
u0 = _reduce64ModP(abi.encodePacked(b1, b2));
u1 = _reduce64ModP(abi.encodePacked(b3, b4));
}
/**
* @dev Full hash-to-curve to G1 (RFC 9380 SSWU_RO). Because EIP-2537's
* MAP_FP_TO_G1 already clears the cofactor, and cofactor clearing is a
* group homomorphism, map(u0) + map(u1) equals RFC 9380's
* clear_cofactor(map(u0) + map(u1)). Requires EIP-2537.
* @return point 128-byte uncompressed G1 point, ok false if any precompile
* is absent or misbehaves (fail-closed).
*/
function hashToG1(bytes32 message) internal view returns (bytes memory point, bool ok) {
(bytes memory u0, bytes memory u1) = hashToField(message);
(bytes memory p0, bool ok0) = _mapFpToG1(u0);
(bytes memory p1, bool ok1) = _mapFpToG1(u1);
if (!ok0 || !ok1) return (new bytes(0), false);
(bool s, bytes memory out) = BLS_G1ADD.staticcall(abi.encodePacked(p0, p1));
if (!s || out.length != 128) return (new bytes(0), false);
return (out, true);
}
// ─────────────────── G1 signature decompression ───────────────────
/**
* @dev Decompress a 48-byte compressed G1 point (zcash/ETH2 serialization,
* which drand uses) into a 128-byte EIP-2537 uncompressed point.
* Uses only MODEXP, so it runs without EIP-2537. Fail-closed: returns
* ok=false for the point at infinity, non-compressed encodings,
* non-canonical x (>= p), or an x whose x^3+4 is not a square.
*/
function decompressG1(bytes memory comp) internal view returns (bytes memory point, bool ok) {
if (comp.length != 48) return (new bytes(0), false);
uint8 flags = uint8(comp[0]);
if (flags & 0x80 == 0) return (new bytes(0), false); // must be compressed
if (flags & 0x40 != 0) return (new bytes(0), false); // infinity disallowed
bool signBit = (flags & 0x20) != 0;
bytes memory x = new bytes(48);
x[0] = bytes1(flags & 0x1f); // clear the 3 metadata bits
for (uint256 i = 1; i < 48; i++) {
x[i] = comp[i];
}
if (_ge(x, FIELD_MODULUS)) return (new bytes(0), false); // x must be < p
// y^2 = x^3 + 4 (mod p)
bytes memory y2 = _addSmallModP(_modexp(x, hex"03", FIELD_MODULUS), 4);
// candidate y = (y^2)^((p+1)/4) (mod p)
bytes memory y = _modexp(y2, SQRT_EXP, FIELD_MODULUS);
// reject if not a genuine square root (point not on curve)
if (keccak256(_modexp(y, hex"02", FIELD_MODULUS)) != keccak256(y2)) {
return (new bytes(0), false);
}
// pick the root whose "sign" matches the compression flag
bytes memory negY = _sub(FIELD_MODULUS, y); // p - y
bool yIsLarger = _ge(y, negY);
bytes memory yFinal;
if (signBit) {
yFinal = yIsLarger ? y : negY;
} else {
yFinal = yIsLarger ? negY : y;
}
point = abi.encodePacked(bytes16(0), x, bytes16(0), yFinal); // 16+48+16+48 = 128
ok = true;
}
// ───────────────────────── verify ─────────────────────────
/**
* @notice Verify a drand quicknet beacon round from its compressed signature.
* Fail-closed end to end. Requires EIP-2537 for the pairing; without
* it, hashToG1 / pairing produce empty output and this returns false.
*/
function verifyRoundCompressed(uint64 round, bytes memory compressedSig)
internal
view
returns (bool)
{
(bytes memory sigPoint, bool sigOk) = decompressG1(compressedSig);
if (!sigOk) return false;
return verifyRoundUncompressed(round, sigPoint);
}
/**
* @notice Verify with an already-uncompressed 128-byte G1 signature point.
* Checks e(H(m), pubkey) * e(sig, -G2) == 1 via EIP-2537 pairing.
*/
function verifyRoundUncompressed(uint64 round, bytes memory sigPoint)
internal
view
returns (bool)
{
if (sigPoint.length != 128) return false;
(bytes memory hm, bool ok) = hashToG1(messageForRound(round));
if (!ok) return false;
// pairing input: (H(m), pubkey) then (sig, -G2_generator) = 2 * 384 bytes
bytes memory input = abi.encodePacked(hm, PUBKEY_G2, sigPoint, NEG_G2_GENERATOR);
(bool s, bytes memory out) = BLS_PAIRING.staticcall(input);
if (!s || out.length != 32) return false;
return out[31] == 0x01 && _isOne(out);
}
// ───────────────────────── helpers ─────────────────────────
function _mapFpToG1(bytes memory fp64) private view returns (bytes memory pt, bool ok) {
(bool s, bytes memory out) = BLS_MAP_FP_G1.staticcall(fp64);
if (s && out.length == 128) return (out, true);
return (new bytes(0), false);
}
function _modexp(bytes memory base, bytes memory exp, bytes memory mod)
private
view
returns (bytes memory)
{
bytes memory input = abi.encodePacked(
uint256(base.length), uint256(exp.length), uint256(mod.length),
base, exp, mod
);
(bool s, bytes memory out) = MODEXP.staticcall(input);
require(s && out.length == mod.length, "AereDrand: MODEXP failed");
return out;
}
/// Reduce a 64-byte big-endian value mod p, returning a 64-byte EIP-2537 Fp element.
function _reduce64ModP(bytes memory chunk64) private view returns (bytes memory) {
bytes memory reduced = _modexp(chunk64, hex"01", FIELD_MODULUS); // 48 bytes, < p
return abi.encodePacked(bytes16(0), reduced); // left-pad to 64
}
/// a >= b for equal-length big-endian byte arrays.
function _ge(bytes memory a, bytes memory b) private pure returns (bool) {
for (uint256 i = 0; i < a.length; i++) {
if (a[i] != b[i]) return uint8(a[i]) > uint8(b[i]);
}
return true; // equal
}
/// a - b for equal-length big-endian arrays, assuming a >= b.
function _sub(bytes memory a, bytes memory b) private pure returns (bytes memory) {
bytes memory r = new bytes(a.length);
int256 borrow = 0;
for (uint256 i = a.length; i > 0; i--) {
int256 d = int256(uint256(uint8(a[i - 1]))) - int256(uint256(uint8(b[i - 1]))) - borrow;
if (d < 0) {
d += 256;
borrow = 1;
} else {
borrow = 0;
}
r[i - 1] = bytes1(uint8(uint256(d)));
}
return r;
}
/// (t + c) mod p for a small c, where t is a 48-byte value < p.
function _addSmallModP(bytes memory t, uint256 c) private pure returns (bytes memory) {
bytes memory r = new bytes(t.length);
for (uint256 i = 0; i < t.length; i++) {
r[i] = t[i];
}
uint256 carry = c;
for (uint256 i = t.length; i > 0 && carry > 0; i--) {
uint256 v = uint256(uint8(r[i - 1])) + (carry & 0xff);
r[i - 1] = bytes1(uint8(v & 0xff));
carry = (carry >> 8) + (v >> 8);
}
if (_ge(r, FIELD_MODULUS)) {
r = _sub(r, FIELD_MODULUS);
}
return r;
}
function _isOne(bytes memory out) private pure returns (bool) {
for (uint256 i = 0; i < 31; i++) {
if (out[i] != 0x00) return false;
}
return out[31] == 0x01;
}
}
/**
* @title AereRandomnessBeaconV2
* @notice Fail-closed, self-verifying drand quicknet randomness beacon for Aere.
*
* This is the honest replacement for AereRandomnessBeacon's stub verifier.
* `submitRound` ALWAYS performs a real on-chain BLS12-381 pairing check
* against the drand quicknet public key before storing anything. There is no
* "phase 1 trust the submitter" mode and no admin switch that turns
* verification off: a round that does not verify cannot be stored, and the
* stored randomness is the canonical drand value SHA-256(signature).
*
* This contract is a BUILT-AND-TESTED replacement. It is NOT a live swap of
* the deployed beacon; activation on Aere mainnet is founder-gated.
*
* Quantum note: drand BLS is classical (Shor-breakable). Verifying it does
* not make the randomness post-quantum. This is a correctness/honesty fix.
*/
contract AereRandomnessBeaconV2 {
using AereDrandQuicknetBLS for *;
bytes32 public constant DRAND_CHAIN_HASH =
0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971;
uint64 public constant DRAND_PERIOD = 3;
uint64 public constant DRAND_GENESIS = 1692803367;
mapping(uint64 => bytes32) public randomnessOfRound;
mapping(uint64 => bytes) public signatureOfRound;
mapping(uint64 => address) public submitterOfRound;
event RoundSubmitted(uint64 indexed round, bytes32 randomness, address indexed submitter);
/**
* @notice Submit and verify a drand quicknet round. Anyone may call; the
* submitter pays gas. Reverts (fail-closed) if the BLS signature
* does not verify against the quicknet public key.
* @param round drand round number.
* @param signature 48-byte compressed G1 drand signature (as published at api.drand.sh).
* @return randomness canonical drand randomness = SHA-256(signature).
*/
function submitRound(uint64 round, bytes calldata signature)
external
returns (bytes32 randomness)
{
require(round > 0, "AereDrand: bad round");
require(signature.length == 48, "AereDrand: sig must be 48 bytes (G1 compressed)");
require(randomnessOfRound[round] == bytes32(0), "AereDrand: already submitted");
uint64 roundTime = DRAND_GENESIS + (round * DRAND_PERIOD);
require(roundTime <= block.timestamp + 30, "AereDrand: round not yet emitted by drand");
// MANDATORY, fail-closed on-chain BLS12-381 verification.
require(
AereDrandQuicknetBLS.verifyRoundCompressed(round, signature),
"AereDrand: BLS verification failed"
);
randomness = AereDrandQuicknetBLS.canonicalRandomness(signature);
randomnessOfRound[round] = randomness;
signatureOfRound[round] = signature;
submitterOfRound[round] = msg.sender;
emit RoundSubmitted(round, randomness, msg.sender);
}
function getRandomness(uint64 round) external view returns (bytes32) {
return randomnessOfRound[round];
}
function isAvailable(uint64 round) external view returns (bool) {
return randomnessOfRound[round] != bytes32(0);
}
function roundAtTime(uint64 unixTimestamp) external pure returns (uint64) {
require(unixTimestamp >= DRAND_GENESIS, "AereDrand: before drand genesis");
return uint64((unixTimestamp - DRAND_GENESIS + DRAND_PERIOD - 1) / DRAND_PERIOD);
}
// ───────── transparency / test surface (pure crypto, safe to expose) ─────────
/// The BLS message that is signed for `round` (SHA-256 of the 8-byte round).
function messageForRound(uint64 round) external pure returns (bytes32) {
return AereDrandQuicknetBLS.messageForRound(round);
}
/// RFC 9380 hash-to-field of a round's message: two 64-byte EIP-2537 Fp elements.
function hashToFieldForRound(uint64 round) external view returns (bytes memory u0, bytes memory u1) {
return AereDrandQuicknetBLS.hashToField(AereDrandQuicknetBLS.messageForRound(round));
}
/// Full RFC 9380 hash-to-curve of a round's message to G1 (needs EIP-2537).
function hashToG1ForRound(uint64 round) external view returns (bytes memory point, bool ok) {
return AereDrandQuicknetBLS.hashToG1(AereDrandQuicknetBLS.messageForRound(round));
}
/// Decompress a 48-byte compressed G1 signature to a 128-byte point.
function decompressSignature(bytes calldata compressedSig)
external
view
returns (bytes memory point, bool ok)
{
return AereDrandQuicknetBLS.decompressG1(compressedSig);
}
/// Canonical drand randomness for a compressed signature (SHA-256).
function canonicalRandomness(bytes calldata compressedSig) external pure returns (bytes32) {
return AereDrandQuicknetBLS.canonicalRandomness(compressedSig);
}
/// Read-only verify (does not store). Fail-closed; requires EIP-2537.
function verifyRound(uint64 round, bytes calldata compressedSig) external view returns (bool) {
return AereDrandQuicknetBLS.verifyRoundCompressed(round, compressedSig);
}
}