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

216 lines
10 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 AereZkEthLightClient — succinct (zk) Ethereum beacon light client on AERE.
*
* @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`) 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.
*
* This contract then does the CHEAP part on chain: it verifies the ~300k-gas
* Groth16 proof through the live SP1 gateway, binds the proof's public values
* to its own trusted state (committee lineage + strict slot advance + genesis
* validators root), and on success advances the finalized header + rotates the
* trusted committee to the delivered next committee.
*
* @dev WHY THIS EXISTS. 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.
* The off-chain verification math is byte-identical to `AereEthLightClient`
* (SYNC_COMMITTEE_SIZE=512, MIN_PARTICIPANTS=342, DOMAIN 0x07000000, Electra
* gindices) and was proven against REAL live mainnet data before proving.
*
* @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.
* * 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.
* * 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):
* 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 AereZkEthLightClient {
/// @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();
constructor(
address sp1Verifier,
bytes32 programVKey,
bytes32 gvr,
bytes32 bootstrapCommitteeRoot,
uint64 bootstrapFinalizedSlot,
bytes32 bootstrapFinalizedHeaderRoot
) {
if (sp1Verifier == address(0)) revert ZeroAddress();
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);
}
/// @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 {
if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length);
// 1) zk proof: reverts if the proof is not valid for PROGRAM_VKEY.
try ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof) {
// verified — did not revert
} catch {
revert InvalidProof();
}
(
bytes32 gvr,
bytes32 prevCommitteeRoot,
uint64 newFinalizedSlot,
bytes32 newFinalizedHeaderRoot,
bytes32 nextCommitteeRoot,
uint64 participation
) = abi.decode(publicValues, (bytes32, bytes32, uint64, bytes32, bytes32, uint64));
// 2) bind the proof to THIS client's trusted state.
if (gvr != genesisValidatorsRoot) revert WrongGenesisValidatorsRoot(gvr, genesisValidatorsRoot);
if (prevCommitteeRoot != trustedCommitteeRoot) {
revert CommitteeMismatch(prevCommitteeRoot, trustedCommitteeRoot);
}
if (newFinalizedSlot <= finalizedSlot) revert StaleFinality(newFinalizedSlot, finalizedSlot);
if (nextCommitteeRoot == bytes32(0)) revert ZeroNextCommittee();
// 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: reverts if the proof is invalid for
/// PROGRAM_VKEY, otherwise returns the decoded fields. Records nothing.
function verify(bytes calldata publicValues, bytes calldata proof)
external
view
returns (
bytes32 gvr,
bytes32 prevCommitteeRoot,
uint64 newFinalizedSlot,
bytes32 newFinalizedHeaderRoot,
bytes32 nextCommitteeRoot,
uint64 participation
)
{
if (publicValues.length != 192) revert BadPublicValuesLength(publicValues.length);
ISp1Verifier(SP1_VERIFIER).verifyProof(PROGRAM_VKEY, publicValues, proof);
(gvr, prevCommitteeRoot, newFinalizedSlot, newFinalizedHeaderRoot, nextCommitteeRoot, participation) =
abi.decode(publicValues, (bytes32, bytes32, uint64, bytes32, bytes32, uint64));
}
}