aere-contracts/contracts/pqc-evidence/PqEquivocationEvidence.sol
Aere Network 6038f079af
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (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
PqEquivocationEvidence: on-chain equivocation evidence for post-quantum consensus seals (AIP-22 section 6), with its local tests and the drill signer; measured on testnet 28001
2026-09-24 01:38:15 +03:00

270 lines
14 KiB
Solidity

// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.23;
/// @title PqEquivocationEvidence
/// @notice AIP-22 section 6: a permanent, independently verifiable record that ONE validator row sealed, with its post-quantum
/// (Falcon-512) key, two different consensus messages of the same type for the same height and round. Immutable and
/// ownerless: no setter, no admin, no upgrade path. A registry epoch added after deployment needs a new contract.
/// @dev What is verified, all on chain:
/// 1. the row: the v3 row pre-image (AERE-PQ-ROW-3, the canonical bytes of PqRegistryV3.rowPreimage) is parsed for chain id,
/// epoch, row count, index, id and the Falcon-512 key; the id is the one derived from that key; the row hash is proven into
/// the epoch's Merkle root, and the registry hash built from that root equals the value pinned on chain for the epoch in force
/// at the height (the 11-byte read-only getter, slot 0), so the key is the one the chain bound to that index;
/// 2. the two messages: rebuilt on chain from their pre-image fields (PqAnchor.*Message, one ASCII domain per type), they
/// differ, and each seal verifies under the row's key through the live Falcon-512 precompile at 0x0AE1;
/// 3. the record: (id, type, height, round) is recorded once and an event names the index and the id.
/// The object carries the signed PRE-IMAGE FIELDS rather than the full upstream payloads: a seal binds only its pre-image, so
/// nothing else in a payload could be checked here (stated deviation from the AIP-22 text, plan 9.8).
/// Equivocation per type: PROPOSAL and PREPARE, same height and round, different digest; COMMIT (the anchor form COMMIT-1,
/// which carries no round), same height, different block hash; ROUND-CHANGE, same height and target round, different prepared
/// claim. Not in this contract, stated: the second form of section 6 (two conflicting anchor headers).
contract PqEquivocationEvidence {
address public constant FALCON512 = address(0x0AE1);
/// @notice runtime of the pin every epoch must use: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 32 PUSH1 0 RETURN (no SSTORE anywhere)
bytes32 public constant PIN_CODEHASH = keccak256(hex"60005460005260206000f3");
uint8 public constant PROPOSAL = 1;
uint8 public constant PREPARE = 2;
uint8 public constant COMMIT = 3;
uint8 public constant ROUNDCHANGE = 4;
uint64 public immutable chainId;
uint64[] private _bindHeights;
address[] private _pins;
struct Evidence {
uint8 kind;
uint64 height;
uint64 round; // PROPOSAL/PREPARE: the round; ROUNDCHANGE: the target round; COMMIT: must be 0
bytes32 digestA; // PROPOSAL/PREPARE: digest; COMMIT: block hash; ROUNDCHANGE: prepared digest (0 when not prepared)
bytes32 digestB;
bool preparedA; // ROUNDCHANGE only
bool preparedB;
uint64 preparedRoundA; // ROUNDCHANGE only
uint64 preparedRoundB;
bytes sealA; // Falcon-512 seal as on the wire: 0x39 || nonce(40) || compressed
bytes sealB;
bytes rowPreimage; // AERE-PQ-ROW-3 pre-image of the sealing row
bytes32[] merkleProof; // sibling path, leaf level first
}
struct Record {
bytes20 id;
uint32 index;
uint64 bindHeight;
bytes32 messageA;
bytes32 messageB;
address submitter;
uint64 recordedAtBlock;
}
/// @notice keccak256(abi.encode(id, kind, height, round)) => record
mapping(bytes32 => Record) public records;
uint256 public recordCount;
event EquivocationRecorded(
bytes20 indexed id,
uint32 indexed index,
uint64 indexed height,
uint8 kind,
uint64 round,
bytes32 messageA,
bytes32 messageB,
address submitter
);
error BadEpochs();
error BadPin(address pin);
error UnknownKind(uint8 kind);
error CommitCarriesRound();
error NotAConflict();
error NoEpochAtHeight(uint64 height);
error BadRow(string what);
error RowNotInEpoch();
error PinMismatch(bytes32 pinned, bytes32 computed);
error SealInvalid(uint8 which);
error AlreadyRecorded(bytes32 key);
/// @param chainId_ the chain every pre-image names
/// @param bindHeights ascending activation heights of the v3 registry epochs
/// @param pins for each epoch, the read-only pin contract whose slot 0 is the epoch's registry hash
constructor(uint64 chainId_, uint64[] memory bindHeights, address[] memory pins) {
if (bindHeights.length == 0 || bindHeights.length != pins.length) revert BadEpochs();
for (uint256 i = 0; i < pins.length; i++) {
if (i > 0 && bindHeights[i] <= bindHeights[i - 1]) revert BadEpochs();
if (pins[i].codehash != PIN_CODEHASH) revert BadPin(pins[i]);
_bindHeights.push(bindHeights[i]);
_pins.push(pins[i]);
}
chainId = chainId_;
}
/// @notice the epochs this contract judges: (bindHeight, pin) in ascending order
function epochs() external view returns (uint64[] memory, address[] memory) {
return (_bindHeights, _pins);
}
/// @notice verify and record one equivocation; reverts with the reason if anything does not hold
function submit(Evidence calldata e) external returns (bytes32 key) {
(bytes32 mA, bytes32 mB) = messages(e);
if (mA == mB) revert NotAConflict();
(uint64 bind, address pin) = epochAt(e.height);
(bytes20 id, uint32 index, bytes memory pk) = _checkRow(e.rowPreimage, e.merkleProof, bind, pin);
if (!falconVerify(pk, mA, e.sealA)) revert SealInvalid(1);
if (!falconVerify(pk, mB, e.sealB)) revert SealInvalid(2);
key = keccak256(abi.encode(id, e.kind, e.height, e.round));
if (records[key].submitter != address(0)) revert AlreadyRecorded(key);
records[key] = Record(id, index, bind, mA, mB, msg.sender, uint64(block.number));
recordCount += 1;
emit EquivocationRecorded(id, index, e.height, e.kind, e.round, mA, mB, msg.sender);
}
/// @notice the two 32-byte messages the seals sign, rebuilt from the pre-image fields exactly as PqAnchor builds them
function messages(Evidence calldata e) public view returns (bytes32 mA, bytes32 mB) {
if (e.kind == PROPOSAL || e.kind == PREPARE) {
bytes memory dom = e.kind == PROPOSAL ? bytes("AERE-PQ-PROPOSAL-1") : bytes("AERE-PQ-PREPARE-1");
mA = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _scalar(e.round), _rlpBytes(abi.encodePacked(e.digestA)))));
mB = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _scalar(e.round), _rlpBytes(abi.encodePacked(e.digestB)))));
} else if (e.kind == COMMIT) {
if (e.round != 0) revert CommitCarriesRound();
bytes memory dom = bytes("AERE-PQ-COMMIT-1");
mA = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _rlpBytes(abi.encodePacked(e.digestA)))));
mB = keccak256(_list(bytes.concat(_rlpBytes(dom), _scalar(chainId), _scalar(e.height), _rlpBytes(abi.encodePacked(e.digestB)))));
} else if (e.kind == ROUNDCHANGE) {
mA = _roundChange(e.height, e.round, e.preparedA, e.preparedRoundA, e.digestA);
mB = _roundChange(e.height, e.round, e.preparedB, e.preparedRoundB, e.digestB);
} else {
revert UnknownKind(e.kind);
}
}
function _roundChange(uint64 height, uint64 target, bool prepared, uint64 preparedRound, bytes32 digest) internal view returns (bytes32) {
// the bare form (PqAnchor.roundChangeMessage without a prepared claim): hasPrepared 0, preparedRound 0, EMPTY digest
bytes memory d = prepared ? abi.encodePacked(digest) : bytes("");
uint64 pr = prepared ? preparedRound : 0;
return keccak256(_list(bytes.concat(
_rlpBytes(bytes("AERE-PQ-ROUNDCHANGE-1")), _scalar(chainId), _scalar(height), _scalar(target),
_scalar(prepared ? 1 : 0), _scalar(pr), _rlpBytes(d))));
}
/// @notice the epoch in force at a height: the last one whose bindHeight is at or below it
function epochAt(uint64 height) public view returns (uint64 bind, address pin) {
uint256 n = _bindHeights.length;
for (uint256 i = n; i > 0; i--) {
if (_bindHeights[i - 1] <= height) return (_bindHeights[i - 1], _pins[i - 1]);
}
revert NoEpochAtHeight(height);
}
/// @notice read-only: does this row pre-image, with this Merkle path, belong to the epoch pinned for `height`? Reverts with the
/// reason if not; returns the row's id and index if so. The same check submit() makes, open to anyone as a call.
function rowAt(uint64 height, bytes calldata rowPreimage, bytes32[] calldata merkleProof)
external
view
returns (bytes20 id, uint32 index)
{
(uint64 bind, address pin) = epochAt(height);
(id, index, ) = _checkRow(rowPreimage, merkleProof, bind, pin);
}
/// @notice the registry-v3 row check: parse, derive the id, prove the row into the pinned registry hash; returns id, index, key
function _checkRow(bytes calldata p, bytes32[] calldata proof, uint64 bind, address pin)
internal
view
returns (bytes20 id, uint32 index, bytes memory falconPk)
{
if (p.length < 60 || bytes13(p[0:13]) != bytes13("AERE-PQ-ROW-3") || uint8(p[13]) != 3) revert BadRow("prefix");
if (uint64(bytes8(p[14:22])) != chainId) revert BadRow("chain id");
if (uint64(bytes8(p[22:30])) != bind) revert RowNotInEpoch();
uint32 count = uint32(bytes4(p[30:34]));
index = uint32(bytes4(p[34:38]));
id = bytes20(p[38:58]);
if (count == 0 || index >= count) revert BadRow("index");
uint256 nKeys = uint8(p[58]);
uint256 o = 59;
uint256 lastWire = 0;
for (uint256 k = 0; k < nKeys; k++) {
if (o + 5 > p.length) revert BadRow("key header");
uint256 wire = uint8(p[o]);
if (wire <= lastWire) revert BadRow("key order");
lastWire = wire;
uint256 pkLen = uint32(bytes4(p[o + 1:o + 5]));
uint256 pkEnd = o + 5 + pkLen;
if (pkEnd + 4 > p.length) revert BadRow("key length");
if (wire == 1) {
if (pkLen != 897) revert BadRow("falcon key length");
falconPk = p[o + 5:pkEnd];
}
uint256 popLen = uint32(bytes4(p[pkEnd:pkEnd + 4]));
o = pkEnd + 4 + popLen;
if (o > p.length) revert BadRow("pop length");
}
if (o >= p.length) revert BadRow("transport flag");
uint8 hasTransport = uint8(p[o]);
if (!((hasTransport == 0 && p.length == o + 1) || (hasTransport == 1 && p.length == o + 21))) revert BadRow("transport");
if (falconPk.length == 0) revert BadRow("no falcon key");
// The id is NOT re-derived from the key here: a ROTATED row keeps the id of its first registration (AIP-22 stage 3b), so on a
// live epoch the id of a rotated row is not the derivation of its current key (measured 2026-09-23 on 28001: row 2 of epoch
// 3,097,600). The id is inside the row hash, which is proven below into the registry hash the chain pinned, and the registry
// itself binds each id (derivation for a new id, the previous keys' rotation signatures for a rotated one; PqRegistryV3.load).
// Merkle: node = keccak256(0x01 || l || r); an odd node is paired with itself; the depth follows from count
uint256 depth = 0;
for (uint256 n = count; n > 1; n = (n + 1) / 2) depth++;
if (proof.length != depth) revert BadRow("proof length");
bytes32 acc = keccak256(p);
uint256 i = index;
for (uint256 d = 0; d < depth; d++) {
acc = i % 2 == 0 ? keccak256(abi.encodePacked(bytes1(0x01), acc, proof[d])) : keccak256(abi.encodePacked(bytes1(0x01), proof[d], acc));
i /= 2;
}
bytes32 computed = keccak256(abi.encodePacked("AERE-PQ-REGISTRY-3", chainId, bind, count, acc));
(bool ok, bytes memory ret) = pin.staticcall("");
if (!ok || ret.length != 32) revert BadPin(pin);
bytes32 pinned = abi.decode(ret, (bytes32));
if (pinned != computed) revert PinMismatch(pinned, computed);
}
/// @notice one Falcon-512 seal through the live precompile: 0x39 || nonce || compressed on the wire becomes
/// pk(897) || u16(len esig) || nonce(40) || message || esig, esig = 0x29 || compressed (SPEC 4.2; proven on 28001)
function falconVerify(bytes memory pk, bytes32 message, bytes calldata seal) public view returns (bool) {
if (pk.length != 897 || seal.length <= 41 || uint8(seal[0]) != 0x39) return false;
bytes memory esig = bytes.concat(bytes1(0x29), seal[41:]);
if (esig.length > type(uint16).max) return false;
bytes memory input = bytes.concat(pk, bytes2(uint16(esig.length)), seal[1:41], message, esig);
(bool ok, bytes memory ret) = FALCON512.staticcall(input);
return ok && ret.length >= 32 && ret[31] == 0x01;
}
// ---- RLP, the four shapes PqAnchor writes: bytes, a long scalar (minimal big-endian, 0 is empty), a list ----
function _rlpLen(uint256 len, uint8 offset) private pure returns (bytes memory) {
if (len <= 55) return abi.encodePacked(uint8(offset + len));
bytes memory l = _minimal(len);
return bytes.concat(abi.encodePacked(uint8(offset + 55 + l.length)), l);
}
function _minimal(uint256 v) private pure returns (bytes memory out) {
uint256 n = 0;
for (uint256 t = v; t > 0; t >>= 8) n++;
out = new bytes(n);
for (uint256 i = 0; i < n; i++) out[n - 1 - i] = bytes1(uint8(v >> (8 * i)));
}
function _rlpBytes(bytes memory b) private pure returns (bytes memory) {
if (b.length == 1 && uint8(b[0]) < 0x80) return b;
return bytes.concat(_rlpLen(b.length, 0x80), b);
}
function _scalar(uint256 v) private pure returns (bytes memory) {
return _rlpBytes(_minimal(v));
}
function _list(bytes memory payload) private pure returns (bytes memory) {
return bytes.concat(_rlpLen(payload.length, 0xc0), payload);
}
}