// 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); } }