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.
299 lines
15 KiB
Solidity
299 lines
15 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
/// @dev Canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS
|
|
/// NOTHING and REVERTS on an invalid proof. On chain 2800 the deployed
|
|
/// SP1VerifierGateway is 0x9ca479C8c52C0EbB4599319a36a5a017BCC70628 and the
|
|
/// SP1 Groth16 route is selected by the proof's leading 4-byte selector,
|
|
/// exactly as AereRollupValidity / AereEVMValidity use it.
|
|
interface ISp1Verifier {
|
|
function verifyProof(
|
|
bytes32 programVKey,
|
|
bytes calldata publicValues,
|
|
bytes calldata proof
|
|
) external view;
|
|
}
|
|
|
|
/**
|
|
* @title AereZkEthLightClientV2 — succinct (zk) Ethereum beacon light client on AERE.
|
|
* CORRECTED fork of AereZkEthLightClient 0x2a2b6D93…29E9d (see THE FIX below).
|
|
*
|
|
* @notice Advances a trust-minimized view of Ethereum finality on AERE (chain 2800)
|
|
* by verifying an SP1 zkVM **Groth16 proof** that ONE Ethereum beacon
|
|
* `LightClientUpdate` is valid against the currently-trusted sync committee.
|
|
* The proof (guest `eth-lightclient-guest`, UNCHANGED — same ELF, same vkey
|
|
* 0x00f03e1a…d191) checks, INSIDE the zkVM:
|
|
* 1. the supplied 512 committee pubkeys hash (SSZ) to the trusted root;
|
|
* 2. participation >= 2/3 of 512, and aggregates the participants (G1);
|
|
* 3. the aggregate BLS12-381 signature over the SSZ signing root of the
|
|
* attested header verifies (hash-to-G2 + pairing);
|
|
* 4. the finalized header is committed in the attested state (Electra
|
|
* finality Merkle branch, gindex 169);
|
|
* 5. the delivered next sync committee is committed in the attested state
|
|
* (next-committee branch, gindex 87);
|
|
* 6. the attested slot is not behind the finalized slot.
|
|
*
|
|
* @dev ------------------------------------------------------------------
|
|
* THE FIX (vs V1 0x2a2b6D93…29E9d) — UNBOUND TRUST ANCHOR IN verify().
|
|
*
|
|
* V1's stateful `processProof` was SAFE: it bound the proof's committed
|
|
* `gvr` to the immutable `genesisValidatorsRoot` and its committed
|
|
* `prevCommitteeRoot` to the stored `trustedCommitteeRoot` before advancing.
|
|
*
|
|
* V1's PUBLIC `verify()` view did NOT. It ran the Groth16 verify and
|
|
* abi.decoded the public values with NO anchor binding at all. A proof is
|
|
* only ever a statement of the form "SOME committee, whose root is
|
|
* `prevCommitteeRoot`, signed this update" — the guest is honest, but it
|
|
* cannot know WHICH committee is Ethereum's real one. So a prover could
|
|
* generate a perfectly VALID proof over a sync committee of their OWN 512
|
|
* keys, self-sign the update, and V1's `verify()` would return the decoded
|
|
* tuple without reverting. Any consumer treating `verify()` as a finality
|
|
* oracle ("it didn't revert => this Ethereum header is final") could be
|
|
* handed an arbitrary attacker-chosen `finalizedHeaderRoot`.
|
|
*
|
|
* V2 makes `verify()` apply the SAME anchor binds as `processProof`:
|
|
* * `gvr == genesisValidatorsRoot` (IMMUTABLE anchor), and
|
|
* * `prevCommitteeRoot == trustedCommitteeRoot` (this client's lineage), and
|
|
* * `nextCommitteeRoot != 0`.
|
|
* An unbound proof over a prover-chosen committee now REVERTS with
|
|
* `CommitteeMismatch`. This mirrors the sibling AereZkQbftLightClient
|
|
* 0xCaDA54FA…6488, whose `verify()` was always bound, and the same
|
|
* anchor-binding fix shipped as AereOutboundVerifierV2 0x08b68bd5…a96F.
|
|
*
|
|
* WHAT V2 DELIBERATELY DOES NOT BIND IN verify(): the monotonic slot
|
|
* advance (`newFinalizedSlot > finalizedSlot`). That is a property of THIS
|
|
* client's head, not of the proof's trust anchor, and a stateless view has
|
|
* no head to advance. Omitting it is intentional and matches
|
|
* AereZkQbftLightClient.verify(). CONSUMER NOTE: `verify()` returning means
|
|
* "this update was signed by the committee this client currently trusts" —
|
|
* it does NOT mean the update is newer than the tracked head. A consumer
|
|
* that needs recency must read `finalizedSlot()` / use `processProof`.
|
|
* ------------------------------------------------------------------
|
|
*
|
|
* @dev WHY THIS CONTRACT EXISTS AT ALL. The sibling `AereEthLightClient` verifies
|
|
* the SAME update DIRECTLY on chain against AERE's EIP-2537 BLS12-381
|
|
* precompiles. That path is correct but its 512-pubkey committee-root
|
|
* recomputation + BLS aggregation costs ~13.2M+ gas and the full update OOGs
|
|
* at AERE's EIP-7825 2^24 (16,777,216) per-tx gas cap. Moving the heavy
|
|
* verification into a succinct proof makes the on-chain step a single Groth16
|
|
* verify — well under the cap.
|
|
*
|
|
* @dev SECURITY MODEL — read before quoting.
|
|
* * What is proven ON CHAIN here is the Groth16 proof + the public-value
|
|
* binding (committee lineage, slot advance, gvr). What is proven OFF CHAIN
|
|
* (inside the zkVM) is the sync-committee signature + SSZ branches.
|
|
* * Security reduces to the honesty of >= 2/3 of Ethereum's 512-member sync
|
|
* committee, exactly like every Altair light client. NOT full Ethereum
|
|
* consensus, NOT a fraud/zk execution bridge.
|
|
* * TRUST-MINIMIZED, NOT TRUSTLESS and NOT QUANTUM-SAFE: the inbound signature
|
|
* scheme is BLS12-381 (classical, Ethereum's inherited crypto) and the proof
|
|
* wrap is an SP1 Groth16 SNARK over BN254 (classical). Both are
|
|
* quantum-vulnerable. This says nothing about AERE's own PQC work, and this
|
|
* contract does NOT touch AERE consensus (still classical ECDSA QBFT).
|
|
* * Bootstrap is weakly subjective: the constructor pins a trusted committee
|
|
* root + finalized slot/header (a checkpoint). From there it self-advances,
|
|
* one committee per accepted proof.
|
|
* * COMMITTEE LINEAGE IS AN EXPIRING CREDENTIAL. `trustedCommitteeRoot` only
|
|
* moves when someone submits a proof. If nobody advances it for > ~1 sync
|
|
* period, this client's trusted committee falls behind Ethereum's live one
|
|
* and `verify()` will (correctly) reject proofs for the current committee.
|
|
* A consumer MUST check `finalizedSlot()` for recency; a bound `verify()`
|
|
* is not a freshness guarantee.
|
|
* * PROGRAM BINDING: PROGRAM_VKEY is the SP1 vkey of the exact guest ELF that
|
|
* runs the update verification. Binding the vkey binds the exact program.
|
|
*
|
|
* PUBLIC-VALUES CONVENTION (exactly 192 bytes = 6 x 32) — IDENTICAL to V1,
|
|
* because the guest is UNCHANGED:
|
|
* abi.encode(
|
|
* bytes32 gvr, // genesis validators root used for domain
|
|
* bytes32 prevCommitteeRoot, // trusted signing-committee SSZ root
|
|
* uint64 finalizedSlot, // new finalized slot
|
|
* bytes32 finalizedHeaderRoot, // new finalized beacon header root
|
|
* bytes32 nextCommitteeRoot, // delivered next sync-committee root
|
|
* uint64 participation // participants (guest enforces >= 342)
|
|
* )
|
|
*
|
|
* IMMUTABILITY / TRUST: SP1_VERIFIER, PROGRAM_VKEY and genesisValidatorsRoot
|
|
* are fixed at deploy. There is NO owner and NO admin: advancing is
|
|
* permissionless. Soundness rests entirely on the SP1 Groth16 proof plus the
|
|
* on-chain lineage/monotonicity checks.
|
|
*/
|
|
contract AereZkEthLightClientV2 {
|
|
/// @notice SP1 verifier gateway (routes to the Groth16 verifier by selector).
|
|
address public immutable SP1_VERIFIER;
|
|
|
|
/// @notice SP1 vkey binding the exact eth-lightclient guest ELF.
|
|
bytes32 public immutable PROGRAM_VKEY;
|
|
|
|
/// @notice Ethereum genesis validators root (mainnet), bound in every proof's
|
|
/// domain. A proof for a different network's gvr is rejected.
|
|
bytes32 public immutable genesisValidatorsRoot;
|
|
|
|
/// @notice The sync-committee root a valid next proof MUST have signed with.
|
|
bytes32 public trustedCommitteeRoot;
|
|
|
|
/// @notice The highest finalized beacon slot advanced so far.
|
|
uint64 public finalizedSlot;
|
|
|
|
/// @notice The finalized beacon block header root at `finalizedSlot`.
|
|
bytes32 public finalizedHeaderRoot;
|
|
|
|
/// @notice Number of proofs accepted (finality advances).
|
|
uint64 public updateCount;
|
|
|
|
event Bootstrapped(bytes32 committeeRoot, uint64 finalizedSlot, bytes32 finalizedHeaderRoot, bytes32 gvr);
|
|
event FinalityAdvanced(
|
|
uint64 indexed finalizedSlot,
|
|
bytes32 finalizedHeaderRoot,
|
|
bytes32 rotatedFromCommittee,
|
|
bytes32 rotatedToCommittee,
|
|
uint64 participation,
|
|
address prover
|
|
);
|
|
|
|
error InvalidProof();
|
|
error BadPublicValuesLength(uint256 got);
|
|
error WrongGenesisValidatorsRoot(bytes32 inProof, bytes32 expected);
|
|
error CommitteeMismatch(bytes32 inProof, bytes32 trusted);
|
|
error StaleFinality(uint64 inProof, uint64 current);
|
|
error ZeroNextCommittee();
|
|
error ZeroAddress();
|
|
error ZeroValue();
|
|
error VerifierHasNoCode();
|
|
|
|
constructor(
|
|
address sp1Verifier,
|
|
bytes32 programVKey,
|
|
bytes32 gvr,
|
|
bytes32 bootstrapCommitteeRoot,
|
|
uint64 bootstrapFinalizedSlot,
|
|
bytes32 bootstrapFinalizedHeaderRoot
|
|
) {
|
|
if (sp1Verifier == address(0)) revert ZeroAddress();
|
|
// A code-less verifier would make verifyProof (which returns no data) succeed
|
|
// silently, accepting every "proof". Mirrors the AereZkQbftLightClient /
|
|
// AereOutboundVerifierV2 guard: removes a deploy-time footgun.
|
|
if (sp1Verifier.code.length == 0) revert VerifierHasNoCode();
|
|
if (programVKey == bytes32(0) || gvr == bytes32(0) || bootstrapCommitteeRoot == bytes32(0)) {
|
|
revert ZeroValue();
|
|
}
|
|
SP1_VERIFIER = sp1Verifier;
|
|
PROGRAM_VKEY = programVKey;
|
|
genesisValidatorsRoot = gvr;
|
|
trustedCommitteeRoot = bootstrapCommitteeRoot;
|
|
finalizedSlot = bootstrapFinalizedSlot;
|
|
finalizedHeaderRoot = bootstrapFinalizedHeaderRoot;
|
|
emit Bootstrapped(bootstrapCommitteeRoot, bootstrapFinalizedSlot, bootstrapFinalizedHeaderRoot, gvr);
|
|
}
|
|
|
|
/// @dev The trust-anchor bind, applied IDENTICALLY by `processProof` and `verify`.
|
|
/// This is the whole point of V2: there is exactly ONE place that decides
|
|
/// whether a proof is about the committee/network this client trusts, and
|
|
/// BOTH entry points go through it.
|
|
function _bindToAnchor(
|
|
bytes32 gvr,
|
|
bytes32 prevCommitteeRoot,
|
|
bytes32 nextCommitteeRoot
|
|
) internal view {
|
|
if (gvr != genesisValidatorsRoot) revert WrongGenesisValidatorsRoot(gvr, genesisValidatorsRoot);
|
|
if (prevCommitteeRoot != trustedCommitteeRoot) {
|
|
revert CommitteeMismatch(prevCommitteeRoot, trustedCommitteeRoot);
|
|
}
|
|
if (nextCommitteeRoot == bytes32(0)) revert ZeroNextCommittee();
|
|
}
|
|
|
|
/// @dev Verify the Groth16 proof through the gateway and decode the 192-byte
|
|
/// public values. Reverts on a bad length or an invalid proof.
|
|
function _verifyAndDecode(bytes calldata publicValues, bytes calldata proof)
|
|
internal
|
|
view
|
|
returns (
|
|
bytes32 gvr,
|
|
bytes32 prevCommitteeRoot,
|
|
uint64 newFinalizedSlot,
|
|
bytes32 newFinalizedHeaderRoot,
|
|
bytes32 nextCommitteeRoot,
|
|
uint64 participation
|
|
)
|
|
{
|
|
if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length);
|
|
|
|
try ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof) {
|
|
// verified — did not revert
|
|
} catch {
|
|
revert InvalidProof();
|
|
}
|
|
|
|
(gvr, prevCommitteeRoot, newFinalizedSlot, newFinalizedHeaderRoot, nextCommitteeRoot, participation) =
|
|
abi.decode(publicValues, (bytes32, bytes32, uint64, bytes32, bytes32, uint64));
|
|
}
|
|
|
|
/// @notice Verify a zk proof of one Ethereum LightClientUpdate and, on success,
|
|
/// advance finality + rotate the trusted committee. Permissionless.
|
|
/// @param publicValues abi.encode(gvr, prevCommitteeRoot, finalizedSlot,
|
|
/// finalizedHeaderRoot, nextCommitteeRoot, participation), 192 bytes.
|
|
/// @param proof SP1 Groth16 proof bytes.
|
|
function processProof(bytes calldata publicValues, bytes calldata proof) external {
|
|
(
|
|
bytes32 gvr,
|
|
bytes32 prevCommitteeRoot,
|
|
uint64 newFinalizedSlot,
|
|
bytes32 newFinalizedHeaderRoot,
|
|
bytes32 nextCommitteeRoot,
|
|
uint64 participation
|
|
) = _verifyAndDecode(publicValues, proof);
|
|
|
|
// 1) bind the proof to THIS client's trust anchor (gvr + committee lineage).
|
|
_bindToAnchor(gvr, prevCommitteeRoot, nextCommitteeRoot);
|
|
|
|
// 2) stateful-only: the head must strictly advance.
|
|
if (newFinalizedSlot <= finalizedSlot) revert StaleFinality(newFinalizedSlot, finalizedSlot);
|
|
|
|
// 3) advance finality + rotate the trusted committee to the delivered next.
|
|
bytes32 from = trustedCommitteeRoot;
|
|
finalizedSlot = newFinalizedSlot;
|
|
finalizedHeaderRoot = newFinalizedHeaderRoot;
|
|
trustedCommitteeRoot = nextCommitteeRoot;
|
|
unchecked {
|
|
updateCount += 1;
|
|
}
|
|
|
|
emit FinalityAdvanced(
|
|
newFinalizedSlot,
|
|
newFinalizedHeaderRoot,
|
|
from,
|
|
nextCommitteeRoot,
|
|
participation,
|
|
msg.sender
|
|
);
|
|
}
|
|
|
|
/// @notice Stateless verification, BOUND TO THIS CLIENT'S TRUST ANCHOR (the V2 fix).
|
|
/// Reverts if the proof is invalid for PROGRAM_VKEY, if the committed gvr is
|
|
/// not this client's immutable `genesisValidatorsRoot`, or if the committed
|
|
/// `prevCommitteeRoot` is not the committee this client currently trusts.
|
|
/// Otherwise returns the decoded fields. Records nothing.
|
|
/// @dev NO monotonicity constraint (a stateless view has no head to advance) —
|
|
/// see the contract-level note. Returning means "signed by the committee this
|
|
/// client trusts", NOT "newer than the tracked head".
|
|
function verify(bytes calldata publicValues, bytes calldata proof)
|
|
external
|
|
view
|
|
returns (
|
|
bytes32 gvr,
|
|
bytes32 prevCommitteeRoot,
|
|
uint64 newFinalizedSlot,
|
|
bytes32 newFinalizedHeaderRoot,
|
|
bytes32 nextCommitteeRoot,
|
|
uint64 participation
|
|
)
|
|
{
|
|
(gvr, prevCommitteeRoot, newFinalizedSlot, newFinalizedHeaderRoot, nextCommitteeRoot, participation) =
|
|
_verifyAndDecode(publicValues, proof);
|
|
|
|
// THE V2 FIX: the same anchor bind processProof applies. V1 omitted this,
|
|
// so verify() would return true for a proof over a prover-chosen committee.
|
|
_bindToAnchor(gvr, prevCommitteeRoot, nextCommitteeRoot);
|
|
}
|
|
}
|