// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "./lib/BLS12381.sol"; import "./lib/SSZ.sol"; /** * @title AereEthLightClient — trust-minimized INBOUND light client: Ethereum -> AERE. * * @notice An on-chain Ethereum beacon-chain (Altair) light client running on AERE * Network (chain 2800). It tracks Ethereum's sync committee and finalized * beacon headers by verifying sync-committee BLS signatures against AERE's * LIVE EIP-2537 BLS12-381 precompiles. No bridge multisig, no relayer trust: * an AERE contract can learn "Ethereum finalized this header" from math alone. * * Per update it checks, entirely on chain: * 1. the supplied 512 committee pubkeys hash (SSZ) to the committed * sync-committee root — binding them to the trusted committee; * 2. participation >= 2/3 of 512, and aggregates the participants (G1ADD); * 3. the aggregate BLS signature over the SSZ signing root of the attested * header verifies (hash-to-curve + pairing on the precompiles); * 4. the finalized header is in the attested state (SSZ Merkle branch); * 5. finality advances monotonically; committee rotation via next-committee * branch. * * @dev SECURITY MODEL — read before quoting. * * Security reduces to the honesty of >= 2/3 of Ethereum's 512-member sync * committee, exactly like every other Altair light client. It is NOT full * Ethereum consensus and NOT a fraud-proof/zk bridge. * * The inbound signature scheme is BLS12-381 — a CLASSICAL, pairing-friendly * curve. It is quantum-VULNERABLE. This is Ethereum's crypto, inherited; it * says nothing about AERE's own PQC work. Do not claim this path is * post-quantum. * * Bootstrap is weakly subjective: the constructor pins a trusted committee * root + finalized header (a checkpoint). From there it is self-securing. * * The DST is a constructor parameter. For Ethereum MAINNET set the sync * protocol DST "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_". The genesis * validators root and fork version must match the target Ethereum network. */ contract AereEthLightClient { using BLS12381 for bytes; // ---- constants ---------------------------------------------------------- uint256 public constant SYNC_COMMITTEE_SIZE = 512; uint256 public constant MIN_PARTICIPANTS = 342; // ceil(2 * 512 / 3) bytes4 public constant DOMAIN_SYNC_COMMITTEE = 0x07000000; // ELECTRA generalized indices (Ethereum mainnet is on the Electra/Fulu BeaconState // layout: >32 top-level fields pushed the container tree one level deeper vs Altair, // so every gindex through the state root gained one depth). Confirmed against the // consensus-specs Electra light-client constants AND empirically against a real live // mainnet finality_update / update (finality branch len 7 verifies at gindex 169; // next-committee branch len 6 verifies at gindex 87; current-committee gindex 86). // finalized_checkpoint.root: FINALIZED_ROOT_GINDEX_ELECTRA = 169 -> depth 7, index 41. uint256 internal constant FINALITY_DEPTH = 7; uint256 internal constant FINALITY_INDEX = 41; // next_sync_committee: NEXT_SYNC_COMMITTEE_GINDEX_ELECTRA = 87 -> depth 6, index 23. uint256 internal constant NEXT_COMMITTEE_DEPTH = 6; uint256 internal constant NEXT_COMMITTEE_INDEX = 23; // (p-1)/2 as a 48-byte big-endian value, for the compressed-pubkey sign bit. bytes internal constant HALF_P = hex"0d0088f51cbff34d258dd3db21a5d66bb23ba5c279c2895fb39869507b587b120f55ffff58a9ffffdcff7fffffffd555"; // ---- immutable config --------------------------------------------------- bytes32 public immutable genesisValidatorsRoot; bytes public dst; // RFC-9380 domain separation tag used by the signing scheme // ---- tracked state ------------------------------------------------------ bytes32 public currentSyncCommitteeRoot; bytes32 public nextSyncCommitteeRoot; uint64 public finalizedSlot; bytes32 public finalizedHeaderRoot; bytes32 public finalizedStateRoot; bytes32 public finalizedBodyRoot; /// @notice Every beacon body root this client has ever finalized. Retained so a /// message whose block is no longer the finalized HEAD can still be proven /// (finality is monotonic, so a once-finalized root stays valid forever). mapping(bytes32 => bool) public finalizedBodyRootSeen; struct BeaconHeader { uint64 slot; uint64 proposerIndex; bytes32 parentRoot; bytes32 stateRoot; bytes32 bodyRoot; } struct LightClientUpdate { BeaconHeader attestedHeader; BeaconHeader finalizedHeader; bytes32[] finalityBranch; // len FINALITY_DEPTH bytes participationBits; // 64 bytes (SSZ Bitvector[512]) bytes signature; // 256-byte G2 bytes pubkeys; // SYNC_COMMITTEE_SIZE * 128 (uncompressed G1), signing committee bytes aggregatePubkey; // 128 bytes bytes4 signatureForkVersion; bool signedByNextCommittee; // true when the NEXT committee produced this signature // optional committee rotation payload (proven against attestedHeader.stateRoot) bool hasNextCommittee; bytes nextPubkeys; bytes nextAggregatePubkey; bytes32[] nextSyncCommitteeBranch; // len NEXT_COMMITTEE_DEPTH } event Bootstrapped(bytes32 committeeRoot, uint64 finalizedSlot, bytes32 finalizedHeaderRoot); event FinalityUpdated(uint64 finalizedSlot, bytes32 finalizedHeaderRoot, bytes32 finalizedBodyRoot); event NextCommitteeStored(bytes32 nextCommitteeRoot); event CommitteeRotated(bytes32 newCurrentCommitteeRoot); constructor( bytes memory dst_, bytes32 genesisValidatorsRoot_, bytes32 bootstrapCommitteeRoot, BeaconHeader memory bootstrapFinalized ) { require(bootstrapCommitteeRoot != bytes32(0), "LC:zero committee"); dst = dst_; genesisValidatorsRoot = genesisValidatorsRoot_; currentSyncCommitteeRoot = bootstrapCommitteeRoot; finalizedSlot = bootstrapFinalized.slot; finalizedHeaderRoot = SSZ.beaconBlockHeaderRoot( bootstrapFinalized.slot, bootstrapFinalized.proposerIndex, bootstrapFinalized.parentRoot, bootstrapFinalized.stateRoot, bootstrapFinalized.bodyRoot ); finalizedStateRoot = bootstrapFinalized.stateRoot; finalizedBodyRoot = bootstrapFinalized.bodyRoot; finalizedBodyRootSeen[bootstrapFinalized.bodyRoot] = true; emit Bootstrapped(bootstrapCommitteeRoot, finalizedSlot, finalizedHeaderRoot); } // ------------------------------------------------------------------------- // update processing // ------------------------------------------------------------------------- function processUpdate(LightClientUpdate calldata u) external { // 1. bind the supplied committee to the committed root. bytes32 committeeRoot = _syncCommitteeRoot(u.pubkeys, u.aggregatePubkey); bytes32 expected = u.signedByNextCommittee ? nextSyncCommitteeRoot : currentSyncCommitteeRoot; require(expected != bytes32(0), "LC:no committee"); require(committeeRoot == expected, "LC:committee mismatch"); // 2. participation threshold + participant aggregation. bytes memory aggPk = _aggregateParticipants(u.pubkeys, u.participationBits); // 3. verify the aggregate BLS signature over the signing root. bytes32 attestedRoot = _headerRoot(u.attestedHeader); bytes32 domain = _computeDomain(u.signatureForkVersion); bytes32 signingRoot = SSZ.hashPair(attestedRoot, domain); require( BLS12381.verify(aggPk, abi.encodePacked(signingRoot), u.signature, dst), "LC:bad signature" ); // 4. finalized header must be committed in the attested state. bytes32 finalRoot = _headerRoot(u.finalizedHeader); require( SSZ.isValidMerkleBranch( finalRoot, u.finalityBranch, FINALITY_DEPTH, FINALITY_INDEX, u.attestedHeader.stateRoot ), "LC:bad finality branch" ); // 5. monotonic finality advance. require(u.finalizedHeader.slot > finalizedSlot, "LC:stale finality"); require(u.attestedHeader.slot >= u.finalizedHeader.slot, "LC:attested> 32); } /// @dev Aggregate the participating pubkeys (per the SSZ Bitvector[512]) via G1ADD, /// requiring at least MIN_PARTICIPANTS distinct set bits. function _aggregateParticipants(bytes calldata pubkeys, bytes calldata bits) internal view returns (bytes memory acc) { require(pubkeys.length == SYNC_COMMITTEE_SIZE * 128, "LC:pubkeys len"); require(bits.length == SYNC_COMMITTEE_SIZE / 8, "LC:bits len"); acc = new bytes(128); // point at infinity uint256 count = 0; for (uint256 i = 0; i < SYNC_COMMITTEE_SIZE; i++) { if ((uint8(bits[i >> 3]) >> (i & 7)) & 1 == 1) { acc = BLS12381.g1Add(acc, _pubkeyAt(pubkeys, i)); count++; } } require(count >= MIN_PARTICIPANTS, "LC:insufficient participation"); } function _pubkeyAt(bytes calldata pubkeys, uint256 i) internal pure returns (bytes memory pk) { pk = new bytes(128); uint256 off = i * 128; for (uint256 j = 0; j < 128; j++) pk[j] = pubkeys[off + j]; } /// @dev hash_tree_root(SyncCommittee{ Vector[Bytes48,512] pubkeys, Bytes48 aggregate }). /// Pubkeys are supplied UNCOMPRESSED (128B G1) and compressed on chain to the /// 48-byte form the SSZ root commits to. function _syncCommitteeRoot(bytes calldata pubkeys, bytes calldata aggregate) internal view returns (bytes32) { require(pubkeys.length == SYNC_COMMITTEE_SIZE * 128, "LC:pubkeys len"); require(aggregate.length == 128, "LC:agg len"); bytes32[] memory leaves = new bytes32[](SYNC_COMMITTEE_SIZE); for (uint256 i = 0; i < SYNC_COMMITTEE_SIZE; i++) { leaves[i] = _pubkeyLeaf(_compressAt(pubkeys, i * 128)); } bytes32 pubkeysRoot = SSZ.merkleize(leaves); bytes32 aggLeaf = _pubkeyLeaf(_compressCalldata(aggregate)); return SSZ.hashPair(pubkeysRoot, aggLeaf); } /// @dev SSZ htr of a Bytes48: two chunks, second right-zero-padded. function _pubkeyLeaf(bytes memory compressed48) internal view returns (bytes32) { bytes32 c0; bytes32 c1; assembly { c0 := mload(add(compressed48, 32)) // bytes 0..31 } // bytes 32..47 into the high 16 bytes of c1, low 16 zero. uint256 hi; for (uint256 k = 0; k < 16; k++) { hi |= uint256(uint8(compressed48[32 + k])) << (8 * (31 - k)); } c1 = bytes32(hi); return SSZ.hashPair(c0, c1); } /// @dev Compress the uncompressed G1 pubkey at `pubkeys[off:off+128]` to 48 bytes. function _compressAt(bytes calldata pubkeys, uint256 off) internal pure returns (bytes memory out) { out = new bytes(48); // x value is bytes [off+16 : off+64]; y value is [off+80 : off+128] for (uint256 i = 0; i < 48; i++) out[i] = pubkeys[off + 16 + i]; out[0] = bytes1(uint8(out[0]) | 0x80); // compression flag if (_yIsLarger(pubkeys, off + 80)) out[0] = bytes1(uint8(out[0]) | 0x20); } function _compressCalldata(bytes calldata point) internal pure returns (bytes memory out) { out = new bytes(48); for (uint256 i = 0; i < 48; i++) out[i] = point[16 + i]; out[0] = bytes1(uint8(out[0]) | 0x80); if (_yIsLarger(point, 80)) out[0] = bytes1(uint8(out[0]) | 0x20); } /// @dev True iff the 48-byte big-endian y value at `data[yOff:yOff+48]` exceeds (p-1)/2. function _yIsLarger(bytes calldata data, uint256 yOff) internal pure returns (bool) { for (uint256 i = 0; i < 48; i++) { uint8 a = uint8(data[yOff + i]); uint8 b = uint8(HALF_P[i]); if (a != b) return a > b; } return false; // exactly equal -> not larger } }