aere-contracts/contracts/AereDaFraudProofVerifier.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

340 lines
13 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @title AereDaFraudProofVerifier
/// @notice On-chain verifier for the AERE Data Availability layer's
/// bad-encoding fraud proofs and availability samples. It recomputes,
/// entirely on-chain, the Namespaced-Merkle-Tree (NMT) commitment and
/// the 2D Reed-Solomon re-encoding used by the off-chain `aere-da`
/// Rust crate (aerenew/da), and CONVICTS a badly-encoded block while
/// REJECTING spurious challenges.
///
/// Commitment layout (Celestia-style, identical to the crate):
/// * each row and each column of the 2k x 2k extended data square is
/// committed with an NMT -> row_root / col_root;
/// * an outer NMT over row_roots ++ col_roots gives the data root.
///
/// A bad-encoding fraud proof reveals the k committed basis shares of a
/// row/column plus one committed "disputed" share. The verifier:
/// 1. checks the challenged line's root is committed under the data
/// root (outer NMT inclusion), and
/// 2. checks every revealed share is committed under that root (inner
/// NMT inclusion), and
/// 3. recomputes the RS value at the disputed position from the k
/// basis shares and CONVICTS iff it disagrees with the committed
/// disputed share (the RS-parity-break check).
/// All three must hold for a conviction; otherwise the challenge is
/// rejected.
///
/// Hash: this verifier uses the EVM `sha256` precompile (0x02) and so
/// commits to the crate's SHA-256 hasher. The crate also offers a
/// FIPS-202 SHAKE256 hasher matching AERE mainnet's live 0x0AE5
/// precompile; standard EVM exposes no SHAKE256 precompile, so the
/// SHA-256 variant is the on-chain path here. Both are hash-based and
/// carry no pairing / discrete-log assumption.
///
/// Scope: verification logic only. No block production, no economic
/// slashing, no deployment wired here. This is the settlement-side
/// adjudicator a DA dispute would call.
contract AereDaFraudProofVerifier {
// ---------------------------------------------------------------------
// Types (mirror the crate's serialization; see aerenew/da/src/nmt.rs,
// rs2d.rs, das.rs).
// ---------------------------------------------------------------------
/// An NMT node: the namespace range it spans and its 32-byte hash.
struct Node {
bytes8 min;
bytes8 max;
bytes32 hash;
}
/// A single-leaf NMT inclusion proof: siblings from leaf level upward.
struct InclusionProof {
uint256 index;
Node[] siblings;
}
/// A revealed share at a line position, with its inclusion proof.
struct ShareProof {
uint256 pos;
bytes8 ns;
bytes data;
InclusionProof proof;
}
/// A bad-encoding fraud proof for one row (axis 0) or column (axis 1).
struct BadEncoding {
uint8 axis; // 0 = row ('r'), 1 = column ('c')
uint256 index;
Node root;
InclusionProof outerProof;
ShareProof[] basis; // k basis shares
ShareProof disputed; // the committed share that breaks RS parity
}
/// A verifiable availability sample (light-node path).
struct Sample {
uint256 row;
uint256 col;
bytes8 ns;
bytes data;
InclusionProof rowProof;
Node rowRoot;
InclusionProof outerProof;
}
bytes1 private constant LEAF_PREFIX = 0x00;
bytes1 private constant NODE_PREFIX = 0x01;
/// @dev GF(2^8) exp/log tables over the AES field x^8+x^4+x^3+x^2+1 (0x11b),
/// generator 0x03. Built once in the constructor; identical to the
/// crate's `Gf256::new`. `expTable` is length 512 so multiply needs no
/// modulo.
bytes private expTable;
bytes private logTable;
event BadBlockConvicted(bytes32 indexed dataRootHash, uint8 axis, uint256 index, uint256 disputedPos);
constructor() {
bytes memory e = new bytes(512);
bytes memory l = new bytes(256);
uint16 x = 1;
for (uint256 i = 0; i < 255; i++) {
e[i] = bytes1(uint8(x));
l[uint256(x)] = bytes1(uint8(i));
// multiply by generator 0x03 == x + 1: x*3 = (x<<1) ^ x
x ^= x << 1;
if (x & 0x100 != 0) {
x ^= 0x11b;
}
x &= 0xff;
}
for (uint256 i = 255; i < 512; i++) {
e[i] = e[i - 255];
}
expTable = e;
logTable = l;
}
// ---------------------------------------------------------------------
// NMT recomputation (SHA-256), byte-for-byte matching aerenew/da/src/nmt.rs.
// ---------------------------------------------------------------------
function _leaf(bytes8 ns, bytes memory data) internal pure returns (Node memory) {
bytes32 h = sha256(abi.encodePacked(LEAF_PREFIX, ns, data));
return Node({min: ns, max: ns, hash: h});
}
function _inner(Node memory a, Node memory b) internal pure returns (Node memory) {
bytes32 h = sha256(
abi.encodePacked(NODE_PREFIX, a.min, a.max, a.hash, b.min, b.max, b.hash)
);
return Node({min: a.min, max: b.max, hash: h});
}
function _eq(Node memory a, Node memory b) internal pure returns (bool) {
return a.min == b.min && a.max == b.max && a.hash == b.hash;
}
/// @dev outer_ns(i): 8-byte big-endian encoding of i (matches das.rs).
function _outerNs(uint256 i) internal pure returns (bytes8) {
return bytes8(uint64(i));
}
/// @dev node_bytes(node) = min || max || hash (48 bytes), the outer-tree
/// leaf payload for a row/column root.
function _nodeBytes(Node memory node) internal pure returns (bytes memory) {
return abi.encodePacked(node.min, node.max, node.hash);
}
/// @dev Verify a single-leaf NMT inclusion proof against `root`.
function _verifyInclusion(
Node memory root,
InclusionProof memory proof,
bytes8 ns,
bytes memory data
) internal pure returns (bool) {
Node memory node = _leaf(ns, data);
uint256 idx = proof.index;
uint256 len = proof.siblings.length;
for (uint256 i = 0; i < len; i++) {
if (idx & 1 == 0) {
node = _inner(node, proof.siblings[i]);
} else {
node = _inner(proof.siblings[i], node);
}
idx >>= 1;
}
return _eq(node, root);
}
// ---------------------------------------------------------------------
// GF(256) Reed-Solomon (matches aerenew/da/src/gf256.rs).
// ---------------------------------------------------------------------
function _mul(uint8 a, uint8 b) internal view returns (uint8) {
if (a == 0 || b == 0) return 0;
uint256 idx = uint256(uint8(logTable[a])) + uint256(uint8(logTable[b]));
return uint8(expTable[idx]);
}
function _div(uint8 a, uint8 b) internal view returns (uint8) {
// Caller guarantees b != 0 (interpolation nodes are distinct).
if (a == 0) return 0;
uint256 idx = (uint256(uint8(logTable[a])) + 255 - uint256(uint8(logTable[b]))) % 255;
return uint8(expTable[idx]);
}
/// @dev Evaluate at `x` the unique degree<m polynomial through (xs[i],ys[i]),
/// via the Lagrange form. Identical to `Gf256::interpolate_eval`.
function _interp(uint8[] memory xs, uint8[] memory ys, uint8 x) internal view returns (uint8) {
uint8 acc = 0;
uint256 m = xs.length;
for (uint256 i = 0; i < m; i++) {
if (x == xs[i]) {
return ys[i];
}
uint8 num = 1;
uint8 den = 1;
for (uint256 j = 0; j < m; j++) {
if (i == j) continue;
num = _mul(num, x ^ xs[j]);
den = _mul(den, xs[i] ^ xs[j]);
}
acc ^= _mul(ys[i], _div(num, den));
}
return acc;
}
// ---------------------------------------------------------------------
// Public verification API.
// ---------------------------------------------------------------------
/// @notice Verify a bad-encoding fraud proof. Returns true iff it is a
/// VALID proof that the committed square is badly encoded: the RS
/// re-encoding of the k committed basis shares disagrees with the
/// committed disputed share, and every revealed share carries a
/// valid inclusion proof against the committed line root, which is
/// itself committed under `dataRoot`.
/// @dev View (reads the GF tables). Pure with respect to persistent state.
function verifyBadEncoding(
Node calldata dataRoot,
uint256 k,
uint256 n,
BadEncoding calldata p
) public view returns (bool) {
if (p.basis.length != k) {
return false;
}
// The challenged line root must be committed under the data root at the
// right outer index: row r sits at index r, column c at index n + c.
uint256 outerIdx = p.axis == 0 ? p.index : n + p.index;
if (p.outerProof.index != outerIdx) {
return false;
}
if (!_verifyInclusion(_copyNode(dataRoot), _copyProof(p.outerProof), _outerNs(outerIdx), _nodeBytes(_copyNode(p.root)))) {
return false;
}
Node memory rootMem = _copyNode(p.root);
// Every basis share is committed under the line root at its position.
for (uint256 i = 0; i < k; i++) {
ShareProof calldata b = p.basis[i];
if (b.proof.index != b.pos) {
return false;
}
if (!_verifyInclusion(rootMem, _copyProof(b.proof), b.ns, b.data)) {
return false;
}
}
// The disputed share is committed under the line root at its position.
ShareProof calldata d = p.disputed;
if (d.proof.index != d.pos) {
return false;
}
if (!_verifyInclusion(rootMem, _copyProof(d.proof), d.ns, d.data)) {
return false;
}
// Recompute the RS value at the disputed position from the k basis
// shares, symbol-wise over each byte offset. CONVICT iff any byte of the
// committed disputed share disagrees with the re-encoding.
uint256 sl = d.data.length;
uint8[] memory xs = new uint8[](k);
for (uint256 i = 0; i < k; i++) {
xs[i] = uint8(p.basis[i].pos);
}
uint8 xj = uint8(d.pos);
for (uint256 bpos = 0; bpos < sl; bpos++) {
uint8[] memory ys = new uint8[](k);
for (uint256 i = 0; i < k; i++) {
ys[i] = uint8(p.basis[i].data[bpos]);
}
uint8 rec = _interp(xs, ys, xj);
if (rec != uint8(d.data[bpos])) {
return true; // RS parity break -> badly encoded -> convict
}
}
return false; // re-encoding matches -> spurious challenge -> reject
}
/// @notice Same as {verifyBadEncoding} but state-changing: emits
/// {BadBlockConvicted} on a genuine bad-encoding proof and reverts
/// on a spurious challenge. This is the entry point a DA slashing
/// module would call.
function challenge(
Node calldata dataRoot,
uint256 k,
uint256 n,
BadEncoding calldata p
) external returns (bool) {
require(verifyBadEncoding(dataRoot, k, n, p), "AereDA: not a valid bad-encoding fraud proof");
emit BadBlockConvicted(dataRoot.hash, p.axis, p.index, p.disputed.pos);
return true;
}
/// @notice Verify a single availability sample against the data root: the
/// cell is committed under its row root, and the row root is
/// committed under the data root. This is the NMT-inclusion leg a
/// sampling light node checks.
function verifySample(
Node calldata dataRoot,
uint256 n,
Sample calldata s
) external pure returns (bool) {
Node memory rowRoot = _copyNode(s.rowRoot);
if (!_verifyInclusion(rowRoot, _copyProof(s.rowProof), s.ns, s.data)) {
return false;
}
if (s.rowProof.index != s.col) {
return false;
}
if (s.outerProof.index != s.row) {
return false;
}
if (!_verifyInclusion(_copyNode(dataRoot), _copyProof(s.outerProof), _outerNs(s.row), _nodeBytes(rowRoot))) {
return false;
}
return s.row < n;
}
// ---------------------------------------------------------------------
// Small calldata -> memory copy helpers (viaIR keeps these cheap).
// ---------------------------------------------------------------------
function _copyNode(Node calldata nd) internal pure returns (Node memory) {
return Node({min: nd.min, max: nd.max, hash: nd.hash});
}
function _copyProof(InclusionProof calldata pr) internal pure returns (InclusionProof memory out) {
out.index = pr.index;
out.siblings = new Node[](pr.siblings.length);
for (uint256 i = 0; i < pr.siblings.length; i++) {
out.siblings[i] = _copyNode(pr.siblings[i]);
}
}
}