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.
308 lines
16 KiB
Solidity
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);
|
|
}
|
|
}
|