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
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

This commit is contained in:
Aere Network 2026-09-24 01:38:15 +03:00
parent d5edf25698
commit 6038f079af
5 changed files with 584 additions and 0 deletions

View File

@ -46,6 +46,18 @@ code in this repository.
default (`test test ... junk`) used for local test signers. default (`test test ... junk`) used for local test signers.
- `package.json` the dependency manifest. - `package.json` the dependency manifest.
### `contracts/pqc-evidence/`: on-chain equivocation evidence (AIP-22 section 6; added 2026-09-23)
`PqEquivocationEvidence` records, permanently and without any owner, that ONE validator row of a post-quantum registry epoch sealed
two different consensus messages of the same type for the same height and round. It proves the row into the registry hash pinned on
chain for the epoch in force (an 11-byte read-only getter, slot 0), rebuilds both signed pre-images exactly as the clients do, and
verifies both Falcon-512 seals through the chain's precompile at `0x0AE1`. `contracts/pqc-evidence/test/MockFalcon512.sol` is a
stand-in for that precompile used ONLY by the local tests (`test/evidence/`); real seals are checked against the live precompile.
`test/evidence/ExercitiuEchivoc.java` is the program that produced the drill seals below, with the clients' own signing code.
Measured on the public testnet 28001 on 2026-09-23, not on chain 2800: a drill instance recorded a PREPARE and a COMMIT equivocation
of drill keys (322,404 and 303,103 gas), and an instance for the testnet's own registry epochs proves the rows of the live epoch and
verifies live anchor seals through the contract. The procedure and the addresses are in the AIP-22 stage records (aere-research).
## Verify ## Verify
```bash ```bash

View File

@ -0,0 +1,269 @@
// 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);
}
}

View File

@ -0,0 +1,21 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.23;
/// @title MockFalcon512 (TESTS ONLY; its runtime is placed at 0x0AE1 on the local test chain)
/// @notice A deterministic stand-in for the live Falcon-512 precompile, so the local probes can measure the framing, the pre-images,
/// the Merkle path and every refusal of PqEquivocationEvidence without a Falcon implementation in the EVM. It reads the input
/// EXACTLY as the live precompile does (pk(897) || u16(len esig) || nonce(40) || message || esig, esig = 0x29 || body) and
/// answers 1 iff the 32-byte body equals keccak256(pk || message). Real Falcon seals are measured against the live
/// precompile on testnet 28001, never with this contract.
contract MockFalcon512 {
fallback(bytes calldata input) external returns (bytes memory) {
if (input.length < 897 + 2 + 40 + 1) return abi.encode(uint256(0));
uint256 esigLen = uint16(bytes2(input[897:899]));
if (input.length < 939 + esigLen) return abi.encode(uint256(0));
uint256 msgLen = input.length - 939 - esigLen;
bytes calldata esig = input[939 + msgLen:];
if (esig.length != 33 || uint8(esig[0]) != 0x29) return abi.encode(uint256(0));
bytes32 want = keccak256(bytes.concat(input[0:897], input[939:939 + msgLen]));
return abi.encode(uint256(bytes32(esig[1:33]) == want ? 1 : 0));
}
}

View File

@ -0,0 +1,97 @@
// AIP-22 stage 6, the DRILL for the live run on testnet 28001 (2026-09-23). It builds a registry-v3 epoch of THREE rows whose Falcon-512
// keys are generated here, in memory, for this run only (drill keys: never written to disk, never printed; the epoch names chain 28099,
// the rehearsal chain, so no record of the drill can be read as one about a validator of 28001 or 2800), and has ONE row seal two
// conflicting PREPARE messages and two conflicting COMMIT messages, with the same code the clients use (PqAnchor.*Message,
// FalconSealScheme through BouncyCastle, PqRegistryV3 row pre-image, Merkle tree and registry hash). Everything it prints is public:
// row pre-images, Merkle paths, registry hash, the messages and the seals, plus a positive and a negative control of each seal under the
// same verifier the clients use. The contract on 28001 then checks the same seals with the live precompile.
// javac -cp "<besu>/lib/*" ExercitiuEchivoc.java && java -cp ".:<besu>/lib/*" ExercitiuEchivoc > exercitiu.json
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.apache.tuweni.bytes.Bytes;
import org.apache.tuweni.bytes.Bytes32;
import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters;
import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator;
import org.bouncycastle.pqc.crypto.falcon.FalconParameters;
import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters;
import org.hyperledger.besu.consensus.common.bft.FalconSealScheme;
import org.hyperledger.besu.consensus.common.bft.PqAnchor;
import org.hyperledger.besu.consensus.common.bft.PqRegistryV3;
import org.hyperledger.besu.consensus.common.bft.SealSchemes;
public final class ExercitiuEchivoc {
static final long CHAIN = 28099L;
static final long BIND = 1000L;
static final int COUNT = 3;
static final int SIGNER = 1;
static final long HEIGHT = 2000L;
static final int ROUND = 2;
static String hx(byte[] b) { return Bytes.wrap(b).toHexString(); }
public static void main(String[] a) {
final SecureRandom rng = new SecureRandom();
final FalconSealScheme falcon = (FalconSealScheme) SealSchemes.FALCON_512;
final List<FalconPrivateKeyParameters> sks = new ArrayList<>();
final List<byte[]> std = new ArrayList<>();
final List<byte[]> pre = new ArrayList<>();
final List<Bytes32> leaves = new ArrayList<>();
for (int i = 0; i < COUNT; i++) {
final FalconKeyPairGenerator g = new FalconKeyPairGenerator();
g.init(new FalconKeyGenerationParameters(rng, FalconParameters.falcon_512));
final FalconPrivateKeyParameters sk = (FalconPrivateKeyParameters) g.generateKeyPair().getPrivate();
final byte[] h = sk.getPublicKey();
final byte[] s = new byte[PqRegistryV3.FALCON_512_PK_LENGTH];
s[0] = PqRegistryV3.FALCON_512_PK_HEADER;
System.arraycopy(h, 0, s, 1, h.length);
final byte[] id = PqRegistryV3.idOf(s);
final byte[] pop = falcon.signWithParams(sk, PqRegistryV3.popMessage(CHAIN, BIND, COUNT, i, id, SealSchemes.FALCON_512.wireId(), s).toArrayUnsafe()).orElseThrow();
final TreeMap<Integer, PqRegistryV3.Key> keys = new TreeMap<>();
keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, s, pop));
final PqRegistryV3.Row row = new PqRegistryV3.Row(i, id, keys, null, Map.of(), null, null);
final byte[] p = PqRegistryV3.rowPreimage(CHAIN, BIND, COUNT, row);
sks.add(sk); std.add(s); pre.add(p); leaves.add(PqRegistryV3.rowHash(CHAIN, BIND, COUNT, row));
}
final Bytes32 root = PqRegistryV3.merkleRoot(leaves);
final Bytes32 rh = PqRegistryV3.registryHash(CHAIN, BIND, COUNT, root);
final Bytes32 dA = Bytes32.random(rng), dB = Bytes32.random(rng), hA = Bytes32.random(rng), hB = Bytes32.random(rng);
final Bytes32 pA = PqAnchor.prepareMessage(CHAIN, HEIGHT, ROUND, dA), pB = PqAnchor.prepareMessage(CHAIN, HEIGHT, ROUND, dB);
final Bytes32 cA = PqAnchor.commitMessage(CHAIN, HEIGHT, hA), cB = PqAnchor.commitMessage(CHAIN, HEIGHT, hB);
final FalconPrivateKeyParameters sk = sks.get(SIGNER);
final byte[] spA = falcon.signWithParams(sk, pA.toArrayUnsafe()).orElseThrow(), spB = falcon.signWithParams(sk, pB.toArrayUnsafe()).orElseThrow();
final byte[] scA = falcon.signWithParams(sk, cA.toArrayUnsafe()).orElseThrow(), scB = falcon.signWithParams(sk, cB.toArrayUnsafe()).orElseThrow();
final byte[] reg = sks.get(SIGNER).getPublicKey();
final byte[] other = sks.get((SIGNER + 1) % COUNT).getPublicKey();
// controls under the clients' own verifier: each seal verifies under its row's key and NOT under another row's, nor over the other message
final boolean ok = falcon.verifyRaw(reg, pA.toArrayUnsafe(), spA) && falcon.verifyRaw(reg, pB.toArrayUnsafe(), spB)
&& falcon.verifyRaw(reg, cA.toArrayUnsafe(), scA) && falcon.verifyRaw(reg, cB.toArrayUnsafe(), scB);
final boolean neg = !falcon.verifyRaw(other, pA.toArrayUnsafe(), spA) && !falcon.verifyRaw(reg, pB.toArrayUnsafe(), spA);
final StringBuilder o = new StringBuilder();
o.append("{\n \"what\": \"AIP-22 stage 6 drill: chain 28099 epoch of drill keys generated in memory for this run\",\n");
o.append(" \"chainId\": ").append(CHAIN).append(", \"bindHeight\": ").append(BIND).append(", \"count\": ").append(COUNT).append(",\n");
o.append(" \"root\": \"").append(root.toHexString()).append("\", \"registryHash\": \"").append(rh.toHexString()).append("\",\n");
o.append(" \"rows\": [");
for (int i = 0; i < COUNT; i++) o.append(i > 0 ? ", " : "").append('"').append(hx(pre.get(i))).append('"');
o.append("],\n \"proofs\": [");
for (int i = 0; i < COUNT; i++) {
o.append(i > 0 ? ", " : "").append('[');
final List<Bytes32> pr = PqRegistryV3.merkleProof(leaves, i);
for (int j = 0; j < pr.size(); j++) o.append(j > 0 ? ", " : "").append('"').append(pr.get(j).toHexString()).append('"');
o.append(']');
}
o.append("],\n \"signer\": ").append(SIGNER).append(", \"height\": ").append(HEIGHT).append(", \"round\": ").append(ROUND).append(",\n");
o.append(" \"prepare\": {\"digestA\": \"").append(dA.toHexString()).append("\", \"digestB\": \"").append(dB.toHexString())
.append("\", \"messageA\": \"").append(pA.toHexString()).append("\", \"messageB\": \"").append(pB.toHexString())
.append("\", \"sealA\": \"").append(hx(spA)).append("\", \"sealB\": \"").append(hx(spB)).append("\"},\n");
o.append(" \"commit\": {\"hashA\": \"").append(hA.toHexString()).append("\", \"hashB\": \"").append(hB.toHexString())
.append("\", \"messageA\": \"").append(cA.toHexString()).append("\", \"messageB\": \"").append(cB.toHexString())
.append("\", \"sealA\": \"").append(hx(scA)).append("\", \"sealB\": \"").append(hx(scB)).append("\"},\n");
o.append(" \"otherRowSeal\": \"").append(hx(falcon.signWithParams(sks.get((SIGNER + 1) % COUNT), pB.toArrayUnsafe()).orElseThrow())).append("\",\n");
o.append(" \"controlsUnderClientVerifier\": {\"positive\": ").append(ok).append(", \"negative\": ").append(neg).append("}\n}\n");
System.out.print(o);
if (!ok || !neg) System.exit(1);
}
}

View File

@ -0,0 +1,185 @@
// AIP-22 stage 6: PqEquivocationEvidence, local probes (2026-09-23).
// A registry v3 epoch of THREE rows (odd, so the Merkle self-pairing is exercised) is built here with the canonical bytes of
// PqRegistryV3.rowPreimage (the same code as the published registry-v3.mjs), pinned in the 11-byte getter, and the live Falcon-512
// precompile is stood in by MockFalcon512 at 0x0AE1 (a "seal" is keccak256(pk || message) framed like a real one). Every refusal is
// asserted by its REASON (custom error), not by a bare revert. Real Falcon seals are measured on testnet 28001, not here.
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const CHAIN = 28001n;
const BIND = 3097600n;
const PREC = "0x0000000000000000000000000000000000000ae1";
const K = (b) => Buffer.from(ethers.getBytes(ethers.keccak256(b)));
const u32 = (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(v)); return b; };
const u64 = (v) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(v)); return b; };
function falconPk(seed) { const pk = Buffer.alloc(897); pk[0] = 0x09; for (let i = 1; i < 897; i++) pk[i] = (seed * 31 + i * 7) & 0xff; return pk; }
const idOf = (pk) => K(Buffer.concat([Buffer.from([0x50, 0x20]), pk])).subarray(12, 32);
// idOverride: a ROTATED row keeps the id of its first registration, so its id is not the derivation of its current key (AIP-22 stage 3b)
function rowPreimage(chainId, bind, count, index, pk, withSlh, transport, idOverride) {
const parts = [Buffer.from("AERE-PQ-ROW-3", "ascii"), Buffer.from([3]), u64(chainId), u64(bind), u32(count), u32(index), idOverride || idOf(pk)];
const keys = [[1, pk, Buffer.alloc(666, 0x39)]];
if (withSlh) keys.push([2, Buffer.alloc(32, 0x5a + index), Buffer.alloc(7856, 0x11)]);
parts.push(Buffer.from([keys.length]));
for (const [w, k, pop] of keys) parts.push(Buffer.from([w]), u32(k.length), k, u32(pop.length), pop);
parts.push(transport ? Buffer.concat([Buffer.from([1]), transport]) : Buffer.from([0]));
return Buffer.concat(parts);
}
const node = (l, r) => K(Buffer.concat([Buffer.from([1]), l, r]));
function merkle(leaves) {
const levels = [leaves];
while (levels[levels.length - 1].length > 1) {
const lv = levels[levels.length - 1], nx = [];
for (let i = 0; i < lv.length; i += 2) nx.push(node(lv[i], lv[i + 1] ?? lv[i]));
levels.push(nx);
}
return levels;
}
function proof(levels, index) {
const p = []; let i = index;
for (let d = 0; d < levels.length - 1; d++) { const lv = levels[d]; const s = i % 2 === 0 ? i + 1 : i - 1; p.push(lv[s] ?? lv[i]); i = Math.floor(i / 2); }
return p.map((x) => "0x" + x.toString("hex"));
}
const registryHash = (chainId, bind, count, root) =>
K(Buffer.concat([Buffer.from("AERE-PQ-REGISTRY-3", "ascii"), u64(chainId), u64(bind), u32(count), root]));
// RLP and the PqAnchor pre-images, written from the Java source (PqAnchor.prepareMessage / proposalMessage / commitMessage / roundChangePreimage)
const rlpLen = (n, off) => { if (n <= 55) return Buffer.from([off + n]); let h = n.toString(16); if (h.length % 2) h = "0" + h; const l = Buffer.from(h, "hex"); return Buffer.concat([Buffer.from([off + 55 + l.length]), l]); };
const rb = (b) => (b.length === 1 && b[0] < 0x80 ? b : Buffer.concat([rlpLen(b.length, 0x80), b]));
const sc = (v) => { v = BigInt(v); if (v === 0n) return rb(Buffer.alloc(0)); let h = v.toString(16); if (h.length % 2) h = "0" + h; return rb(Buffer.from(h, "hex")); };
const list = (...x) => { const p = Buffer.concat(x); return Buffer.concat([rlpLen(p.length, 0xc0), p]); };
const A = (s) => rb(Buffer.from(s, "ascii"));
const H = (h) => rb(Buffer.from(ethers.getBytes(h)));
const msgPR = (dom, h, r, d) => K(list(A(dom), sc(CHAIN), sc(h), sc(r), H(d)));
const msgCommit = (h, hash) => K(list(A("AERE-PQ-COMMIT-1"), sc(CHAIN), sc(h), H(hash)));
const msgRC = (h, t, prep, pr, d) => K(list(A("AERE-PQ-ROUNDCHANGE-1"), sc(CHAIN), sc(h), sc(t), sc(prep ? 1 : 0), sc(prep ? pr : 0), prep ? H(d) : rb(Buffer.alloc(0))));
// a mock "seal": 0x39 || nonce(40) || keccak256(pk || message)
const seal = (pk, m) => "0x" + Buffer.concat([Buffer.from([0x39]), Buffer.alloc(40, 0x77), K(Buffer.concat([pk, m]))]).toString("hex");
describe("PqEquivocationEvidence (AIP-22 stage 6)", function () {
let ev, pin, pks, pre, levels, root, count, ROT, ids;
const D1 = "0x" + "11".repeat(32), D2 = "0x" + "22".repeat(32);
async function deployPin(hash) {
const [s] = await ethers.getSigners();
const init = "0x7f" + hash.toString("hex") + "600055600b80602f6000396000f3" + "60005460005260206000f3";
const tx = await s.sendTransaction({ data: init });
const r = await tx.wait();
return r.contractAddress;
}
before(async function () {
const Mock = await ethers.getContractFactory("MockFalcon512");
const m = await Mock.deploy(); await m.waitForDeployment();
await network.provider.send("hardhat_setCode", [PREC, await ethers.provider.getCode(await m.getAddress())]);
count = 3n;
pks = [falconPk(1), falconPk(2), falconPk(3)];
// row 0 is a ROTATED row (its id is another key's derivation), as row 2 of the live epoch 3,097,600 on 28001
ROT = idOf(falconPk(99));
ids = [ROT, idOf(pks[1]), idOf(pks[2])];
pre = [rowPreimage(CHAIN, BIND, count, 0, pks[0], true, null, ROT), rowPreimage(CHAIN, BIND, count, 1, pks[1], true, Buffer.alloc(20, 0xab)), rowPreimage(CHAIN, BIND, count, 2, pks[2], false, null)];
levels = merkle(pre.map((p) => K(p)));
root = levels[levels.length - 1][0];
pin = await deployPin(registryHash(CHAIN, BIND, count, root));
const F = await ethers.getContractFactory("PqEquivocationEvidence");
ev = await F.deploy(CHAIN, [BIND], [pin]); await ev.waitForDeployment();
});
const base = (kind, h, r, dA, dB, idx, sA, sB, extra = {}) => ({
kind, height: h, round: r, digestA: dA, digestB: dB, preparedA: false, preparedB: false, preparedRoundA: 0, preparedRoundB: 0,
sealA: sA, sealB: sB, rowPreimage: "0x" + pre[idx].toString("hex"), merkleProof: proof(levels, idx), ...extra,
});
it("the pin is the 11-byte read-only getter and returns the registry hash", async function () {
expect(await ethers.provider.getCode(pin)).to.equal("0x60005460005260206000f3");
expect(await ethers.provider.call({ to: pin })).to.equal("0x" + registryHash(CHAIN, BIND, count, root).toString("hex"));
});
it("rebuilds the four pre-images exactly as PqAnchor writes them", async function () {
const h = 3131500n;
const e1 = base(2, h, 3, D1, D2, 1, "0x", "0x");
const [a, b] = await ev.messages(e1);
expect(a).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D1).toString("hex"));
expect(b).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D2).toString("hex"));
const [p] = await ev.messages(base(1, h, 0, D1, D2, 1, "0x", "0x"));
expect(p).to.equal("0x" + msgPR("AERE-PQ-PROPOSAL-1", h, 0, D1).toString("hex"));
const [c] = await ev.messages(base(3, h, 0, D1, D2, 1, "0x", "0x"));
expect(c).to.equal("0x" + msgCommit(h, D1).toString("hex"));
const [r1, r2] = await ev.messages(base(4, h, 2, D1, D2, 1, "0x", "0x", { preparedA: false, preparedB: true, preparedRoundB: 1 }));
expect(r1).to.equal("0x" + msgRC(h, 2, false, 0, D1).toString("hex"));
expect(r2).to.equal("0x" + msgRC(h, 2, true, 1, D2).toString("hex"));
});
it("records a PREPARE equivocation of one row, once, and names its index and id", async function () {
const h = 3131500n, r = 3n;
const e = base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D1)), seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D2)));
await expect(ev.submit(e)).to.emit(ev, "EquivocationRecorded");
const key = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes20", "uint8", "uint64", "uint64"], ["0x" + idOf(pks[1]).toString("hex"), 2, h, r]));
const rec = await ev.records(key);
expect(rec.id).to.equal("0x" + idOf(pks[1]).toString("hex"));
expect(rec.index).to.equal(1n);
expect(rec.bindHeight).to.equal(BIND);
expect(await ev.recordCount()).to.equal(1n);
await expect(ev.submit(e)).to.be.revertedWithCustomError(ev, "AlreadyRecorded");
});
it("records PROPOSAL, COMMIT and ROUND-CHANGE equivocations (last row of an odd tree: self-paired leaf)", async function () {
const h = 3131600n;
await expect(ev.submit(base(1, h, 1, D1, D2, 2, seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D1)), seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D2))))).to.emit(ev, "EquivocationRecorded");
await expect(ev.submit(base(3, h, 0, D1, D2, 0, seal(pks[0], msgCommit(h, D1)), seal(pks[0], msgCommit(h, D2))))).to.emit(ev, "EquivocationRecorded");
const x = { preparedA: false, preparedB: true, preparedRoundB: 1 };
await expect(ev.submit(base(4, h, 2, D1, D2, 0, seal(pks[0], msgRC(h, 2, false, 0, D1)), seal(pks[0], msgRC(h, 2, true, 1, D2)), x))).to.emit(ev, "EquivocationRecorded");
expect(await ev.recordCount()).to.equal(4n);
});
it("refuses each way of not being an equivocation, by its reason", async function () {
const h = 3131700n, r = 0n;
const mA = msgPR("AERE-PQ-PREPARE-1", h, r, D1), mB = msgPR("AERE-PQ-PREPARE-1", h, r, D2);
// the same message twice is not a conflict
await expect(ev.submit(base(2, h, r, D1, D1, 1, seal(pks[1], mA), seal(pks[1], mA)))).to.be.revertedWithCustomError(ev, "NotAConflict");
// the second seal by ANOTHER row's key: one validator did not sign both
await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[0], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(2);
// a seal over another round
await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, 1, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1);
// a seal of the PROPOSAL domain offered as a PREPARE
await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PROPOSAL-1", h, r, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1);
// a Merkle path that does not reach the pinned root
const bad = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); bad.merkleProof = [...bad.merkleProof]; bad.merkleProof[0] = "0x" + "00".repeat(32);
await expect(ev.submit(bad)).to.be.revertedWithCustomError(ev, "PinMismatch");
// a row claiming the index of another row (the path of row 1 used for row 0's preimage)
const swap = base(2, h, r, D1, D2, 0, seal(pks[0], mA), seal(pks[0], mB)); swap.merkleProof = proof(levels, 1);
await expect(ev.submit(swap)).to.be.revertedWithCustomError(ev, "PinMismatch");
// a proof of the wrong depth
const short = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); short.merkleProof = short.merkleProof.slice(1);
await expect(ev.submit(short)).to.be.revertedWithCustomError(ev, "BadRow").withArgs("proof length");
// a row of another chain, another epoch, and an id not derived from its key
const other = (mut) => { const p = Buffer.from(pre[1]); mut(p); const e = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); e.rowPreimage = "0x" + p.toString("hex"); return e; };
await expect(ev.submit(other((p) => u64(28002).copy(p, 14)))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("chain id");
await expect(ev.submit(other((p) => u64(BIND + 1n).copy(p, 22)))).to.be.revertedWithCustomError(ev, "RowNotInEpoch");
// the id is bound by the REGISTRY (inside the row hash), not re-derived on chain: a changed id no longer proves into the pin
await expect(ev.submit(other((p) => { p[38] ^= 1; }))).to.be.revertedWithCustomError(ev, "PinMismatch");
await expect(ev.submit(other((p) => { p[0] = 0x42; }))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("prefix");
// a COMMIT that names a round, an unknown type, a height before the first epoch
await expect(ev.submit(base(3, h, 1, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "CommitCarriesRound");
await expect(ev.submit(base(9, h, r, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "UnknownKind").withArgs(9);
await expect(ev.submit(base(2, BIND - 1n, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "NoEpochAtHeight");
expect(await ev.recordCount()).to.equal(4n);
});
it("rowAt: each row proves into the pinned epoch as a plain call, and a tampered row does not", async function () {
for (let i = 0; i < 3; i++) {
const [id, index] = await ev.rowAt(BIND + 5n, "0x" + pre[i].toString("hex"), proof(levels, i));
expect(id).to.equal("0x" + ids[i].toString("hex"));
expect(index).to.equal(BigInt(i));
}
const t = Buffer.from(pre[2]); t[t.length - 1] ^= 1;
await expect(ev.rowAt(BIND + 5n, "0x" + t.toString("hex"), proof(levels, 2))).to.be.revertedWithCustomError(ev, "BadRow");
});
it("will not be built on a pin that is not the read-only getter, nor on unordered epochs", async function () {
const F = await ethers.getContractFactory("PqEquivocationEvidence");
await expect(F.deploy(CHAIN, [BIND], [await ev.getAddress()])).to.be.revertedWithCustomError(F, "BadPin");
await expect(F.deploy(CHAIN, [BIND, BIND], [pin, pin])).to.be.revertedWithCustomError(F, "BadEpochs");
await expect(F.deploy(CHAIN, [], [])).to.be.revertedWithCustomError(F, "BadEpochs");
});
});