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