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

308 lines
16 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @notice Minimal Succinct SP1 on-chain verifier interface.
/// @dev The canonical `SP1VerifierGateway` / `SP1Verifier` exposes exactly
/// this. `verifyProof` REVERTS if `proofBytes` is not a valid Groth16
/// proof for `(programVKey, publicValues)`; it returns nothing on success.
interface ISP1Verifier {
function verifyProof(
bytes32 programVKey,
bytes calldata publicValues,
bytes calldata proofBytes
) external view;
}
/// @notice Destination-chain application handler. `AereOutboundVerifierV2` calls
/// this once, and only once, per proven+unused message.
interface IAereMessageHandlerV2 {
/// @param srcChainId the AERE source chain id (2800).
/// @param sender the original `msg.sender` on the AERE Outbox.
/// @param payload the application bytes from the Outbox message.
function handleAereMessage(uint256 srcChainId, address sender, bytes calldata payload) external;
}
/// @title AereOutboundVerifierV2
/// @notice Destination-chain verifier that completes the trust-minimised OUTBOUND
/// interop loop (AERE -> this chain). It accepts an SP1/Groth16 proof that
/// an `AereOutboundOutbox` message log was included in a QBFT-final AERE
/// block and — if the proof verifies against the pinned finality vkey AND
/// attests the ANCHORED validator set — delivers the message exactly once.
///
/// @dev ─────────────────────────────────────────────────────────────────────────
/// WHY V2 EXISTS: V1 (`0x8E893686b6B2509C5f7Fe477CE530Bc868B0074e`) WAS
/// FORGEABLE BY DESIGN. Do not reuse it; do not pin a vkey into it.
/// ─────────────────────────────────────────────────────────────────────────
/// The zk-interop guest takes its ENTIRE trust anchor as private,
/// prover-supplied input: `InteropInput { chain_id, finality: FinalityInput {
/// known_validators, .. }, .. }`. `verify_finality` checks the header's
/// `extraData` validator list against *the prover's own* `known_validators`
/// — never against anything external. V1's guest then committed
/// `(chainId, blockNumber, blockHash, root, claimKind, commitment, finalized,
/// falconVerified)`: NO validator-set commitment. V1 bound only
/// `chainId == 2800` — and `chainId` was itself the echoed prover input.
///
/// So the attack was: generate N keypairs, fabricate a header whose
/// `extraData` lists those keys and whose `receiptsRoot` is a trie the
/// attacker built containing a forged `AereCrossChainMessage` log ascribed to
/// the real Outbox address, self-seal a quorum over it, set `chain_id = 2800`,
/// and prove. Every check passes HONESTLY — the proof is cryptographically
/// valid — and V1 delivers an arbitrary message. `finalized == true` meant
/// only "final under a set the prover chose", which is no statement at all.
/// (Found by code-read 2026-07-16; never exploited — V1 shipped with
/// `programVKey == 0x0`, which is fail-closed, so every proof reverted.)
///
/// THE FIX, mirroring `AereZkQbftLightClient` (`0xCaDA54FA…6488`), which was
/// always safe precisely because it does this:
/// 1. the guest COMMITS `validatorSetRoot` — keccak256 over the set the
/// finality obligation was actually discharged against — into its public
/// values, and commits `chainId` from a compile-time constant rather than
/// echoing the prover's;
/// 2. this contract BINDS that committed root to `TRUSTED_VALIDATOR_SET_ROOT`,
/// an IMMUTABLE on-chain anchor, and rejects anything else.
/// The guest cannot know which set is real; the anchor decides. The guest's
/// only job is to state, unforgeably, WHICH set it used.
///
/// @dev PUBLIC-VALUES ENCODING (must match the guest's committed struct):
/// abi.encode(
/// uint64 chainId, // must be AERE_CHAIN_ID (2800)
/// uint64 blockNumber, // source block height; must be >= MIN_SOURCE_BLOCK
/// bytes32 blockHash, // the QBFT-final block that carries the log
/// bytes32 root, // receiptsRoot (informational; bound via proof)
/// uint8 claimKind, // must be 1 (receipt/log claim)
/// bytes32 commitment, // canonical message commitment (see below)
/// bytes32 validatorSetRoot, // MUST equal TRUSTED_VALIDATOR_SET_ROOT <-- THE FIX
/// bool finalized, // must be true
/// bool falconVerified // must be false (mainnet consensus is ECDSA)
/// )
/// All nine fields are static 32-byte words, so this is a plain 288-byte
/// concatenation — identical to `alloy` `SolType::abi_encode` of the guest's
/// `PublicValues` struct. The length is checked exactly: a shorter/longer blob
/// is rejected rather than silently abi.decode'd.
///
/// COMMITMENT RE-DERIVATION (must match interop_core log_commitment(0)):
/// commitment = keccak256(
/// bytes32(AERE_CHAIN_ID) ‖ blockHash ‖ bytes32(outbox) ‖
/// EVENT_SIG ‖ messageId ‖ bytes32(destChainId) ‖ keccak256(data))
/// where data = abi.encode(nonce, sender, targetHandler, payload) and
/// messageId = keccak256(abi.encode(AERE_CHAIN_ID, destChainId, nonce,
/// sender, targetHandler, payload)).
///
/// SECURITY MODEL — trust-minimised, NOT trustless. Read before quoting.
/// * APPLICATION / INTEROP LAYER ONLY. This does not change, gate or touch AERE
/// consensus. Mainnet QBFT remains classical secp256k1 ECDSA.
/// * A valid proof attests that `>= ceil(2N/3)` of the ANCHORED validator set
/// sealed the source block. With the live N=7 set that is a 5-of-7 quorum
/// (2f+1, f=2). Delivery security == that validator set.
/// * NOT POST-QUANTUM. The committed seals are secp256k1 ECDSA and the SP1 wrap
/// is Groth16 over BN254 — both classical and quantum-vulnerable. A Falcon slot
/// is reserved but inactive; `falconVerified` MUST be `false` so this contract
/// can never launder an unbacked post-quantum claim.
/// * Security reduces to the CONJUNCTION of: >= 5-of-7 AERE validators honest
/// (all Foundation-operated today — an operator assumption, not an economic
/// one) + the pinned PROGRAM_VKEY binding the intended program + the SP1
/// gateway/Groth16 verifier being sound + TRUSTED_VALIDATOR_SET_ROOT being the
/// right set.
/// * THE ANCHOR DOES NOT TRACK ROTATION. It is immutable by design (a mutable
/// anchor would reintroduce a trusted setter). A validator-set change requires
/// a fresh deployment. Consumers MUST check TRUSTED_VALIDATOR_SET_ROOT still
/// equals keccak256 over the live `qbft_getValidatorsByBlockNumber` set before
/// trusting this contract. Treat the anchor as an expiring credential.
contract AereOutboundVerifierV2 {
/// @notice AERE mainnet chain id the pinned vkey attests finality for.
uint256 public constant AERE_CHAIN_ID = 2800;
/// @notice Exact byte length of the guest's 9-word committed public values.
uint256 public constant PUBLIC_VALUES_LENGTH = 288;
/// @notice topic0 preimage hash of `AereOutboundOutbox.AereCrossChainMessage`.
bytes32 public constant EVENT_SIG =
keccak256("AereCrossChainMessage(bytes32,uint256,uint256,address,address,bytes)");
/// @notice The SP1 verifier (gateway) this contract calls. Immutable.
ISP1Verifier public immutable sp1Verifier;
/// @notice The pinned AERE QBFT-finality+inclusion program vkey. A proof for any
/// other program fails `sp1Verifier.verifyProof`. Immutable, non-zero.
bytes32 public immutable programVKey;
/// @notice THE FIX. Immutable commitment to the trusted AERE validator set:
/// keccak256 over the addresses in stored (ascending) order, identical
/// to the guest's `validator_set_root`. Every accepted proof must attest
/// THIS set. Without this bind, the prover picks the set and the bridge
/// is forgeable — see the V1 postmortem in the contract NatSpec above.
bytes32 public immutable TRUSTED_VALIDATOR_SET_ROOT;
/// @notice Lower bound of the anchored set's validity window: the first block
/// sealed by TRUSTED_VALIDATOR_SET_ROOT's set. Defence in depth — a
/// header sealed by an older set already fails the anchor bind, since
/// its committed root differs.
uint64 public immutable MIN_SOURCE_BLOCK;
/// @notice The trusted AERE-side Outbox address bound into every commitment.
/// A log from any other contract yields a different commitment and is
/// rejected. Immutable.
address public immutable outbox;
/// @notice Replay protection: messageId => delivered.
mapping(bytes32 => bool) public delivered;
/// @notice Number of messages delivered.
uint64 public deliveryCount;
event MessageDelivered(
bytes32 indexed messageId,
address indexed sender,
address indexed targetHandler,
uint64 sourceBlockNumber,
bytes32 sourceBlockHash
);
error BadPublicValuesLength(uint256 got);
error WrongSourceChain(uint64 got);
error NotReceiptClaim(uint8 got);
error NotFinal();
error UnexpectedFalconFlag();
error WrongDestinationChain(uint256 got);
error CommitmentMismatch(bytes32 expected, bytes32 got);
error AlreadyDelivered(bytes32 messageId);
/// @notice THE FIX firing: the proof attested a validator set that is not the
/// anchored one (e.g. an attacker's self-nominated keys).
error ValidatorSetMismatch(bytes32 inProof, bytes32 trusted);
error SourceBlockTooOld(uint64 got, uint64 minimum);
error InvalidProof();
error ProgramNotConfigured();
error ZeroAddress();
error ZeroValue();
error VerifierHasNoCode();
/// @param _sp1Verifier the SP1 verifier / gateway on this destination chain.
/// @param _programVKey the zk-interop guest vkey to pin (MUST be non-zero).
/// @param _validatorSetRoot keccak256 over AERE's trusted validator set, in
/// stored ascending order (MUST be non-zero).
/// @param _minSourceBlock first block sealed by that set.
/// @param _outbox the AERE-side `AereOutboundOutbox` address to trust.
constructor(
ISP1Verifier _sp1Verifier,
bytes32 _programVKey,
bytes32 _validatorSetRoot,
uint64 _minSourceBlock,
address _outbox
) {
if (address(_sp1Verifier) == address(0)) revert ZeroAddress();
// A code-less verifier would make verifyProof (which returns no data) succeed
// silently, accepting every "proof". Removes a deploy-time footgun.
if (address(_sp1Verifier).code.length == 0) revert VerifierHasNoCode();
if (_outbox == address(0)) revert ZeroAddress();
// Fail-closed by construction: V1 was deployable with a zero vkey. A zero
// vkey is safe (every proof reverts) but it is a trap — it invites someone
// to "fix" it later by pinning a vkey into an unbound contract. Refuse it.
if (_programVKey == bytes32(0) || _validatorSetRoot == bytes32(0)) revert ZeroValue();
sp1Verifier = _sp1Verifier;
programVKey = _programVKey;
TRUSTED_VALIDATOR_SET_ROOT = _validatorSetRoot;
MIN_SOURCE_BLOCK = _minSourceBlock;
outbox = _outbox;
}
/// @notice Verify an AERE outbound-message proof and deliver it exactly once.
/// @param publicValues the guest's ABI-encoded committed public values (288 bytes).
/// @param proofBytes the SP1 Groth16 proof over `(programVKey, publicValues)`.
/// @param destChainId the message's destination chain id (must be this chain).
/// @param nonce the Outbox message nonce.
/// @param sender the original Outbox `msg.sender`.
/// @param targetHandler the destination handler to deliver to.
/// @param payload the application payload.
function deliver(
bytes calldata publicValues,
bytes calldata proofBytes,
uint256 destChainId,
uint256 nonce,
address sender,
address targetHandler,
bytes calldata payload
) external {
// Belt-and-suspenders: unreachable (the constructor rejects a zero vkey),
// but keeps the fail-closed property explicit and local.
if (programVKey == bytes32(0)) revert ProgramNotConfigured();
if (publicValues.length != PUBLIC_VALUES_LENGTH) {
revert BadPublicValuesLength(publicValues.length);
}
(
uint64 chainId,
uint64 blockNumber,
bytes32 blockHash,
, // root (informational; bound into the proof, not re-checked here)
uint8 claimKind,
bytes32 commitment,
bytes32 validatorSetRoot,
bool finalized,
bool falconVerified
) = abi.decode(
publicValues,
(uint64, uint64, bytes32, bytes32, uint8, bytes32, bytes32, bool, bool)
);
// ---- Cheap public-input gates (before the expensive proof check). ----
// THE FIX. Bind the set the proof ACTUALLY used to this contract's immutable
// anchor. An attacker who self-nominates a validator set produces a valid
// proof carrying a DIFFERENT root, and dies here. This single check is what
// turns "final under some set" into "final on AERE".
if (validatorSetRoot != TRUSTED_VALIDATOR_SET_ROOT) {
revert ValidatorSetMismatch(validatorSetRoot, TRUSTED_VALIDATOR_SET_ROOT);
}
// Now meaningful: the guest commits chainId from a constant, not the prover's input.
if (chainId != AERE_CHAIN_ID) revert WrongSourceChain(chainId);
if (blockNumber < MIN_SOURCE_BLOCK) revert SourceBlockTooOld(blockNumber, MIN_SOURCE_BLOCK);
if (claimKind != 1) revert NotReceiptClaim(claimKind);
if (!finalized) revert NotFinal();
// Honesty invariant: mainnet consensus is classical ECDSA. A `true` here
// would be an unbacked post-quantum claim; refuse it.
if (falconVerified) revert UnexpectedFalconFlag();
if (destChainId != block.chainid) revert WrongDestinationChain(destChainId);
// ---- Replay gate (cheap; re-asserted with the effect below). ----
bytes32 messageId = keccak256(
abi.encode(AERE_CHAIN_ID, destChainId, nonce, sender, targetHandler, payload)
);
if (delivered[messageId]) revert AlreadyDelivered(messageId);
// ---- The zk check: proof valid for (pinned vkey, these publicValues). ----
// Reverts if the proof is not a genuine QBFT-finality+inclusion proof, if
// `publicValues` were tampered, or if it was produced for another program.
try sp1Verifier.verifyProof(programVKey, publicValues, proofBytes) {
// verified — did not revert
} catch {
revert InvalidProof();
}
// ---- Bind the proven commitment to the delivered message fields. ----
bytes32 dataHash = keccak256(abi.encode(nonce, sender, targetHandler, payload));
bytes32 expected = keccak256(
abi.encodePacked(
bytes32(AERE_CHAIN_ID), // cid, left-padded
blockHash, // from the proof
bytes32(uint256(uint160(outbox))), // trusted outbox, left-padded
EVENT_SIG, // topic0
messageId, // topic1 (indexed)
bytes32(destChainId), // topic2 (indexed)
dataHash // keccak(log data)
)
);
if (expected != commitment) revert CommitmentMismatch(expected, commitment);
// ---- Effects before interaction. ----
delivered[messageId] = true;
unchecked {
deliveryCount += 1;
}
emit MessageDelivered(messageId, sender, targetHandler, blockNumber, blockHash);
// ---- Deliver to the application handler. ----
IAereMessageHandlerV2(targetHandler).handleAereMessage(AERE_CHAIN_ID, sender, payload);
}
}