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
270 lines
14 KiB
Solidity
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);
|
|
}
|
|
}
|