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

327 lines
16 KiB
Solidity

// 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<final");
// If this signature came from the next committee, rotate current <- next.
if (u.signedByNextCommittee) {
currentSyncCommitteeRoot = nextSyncCommitteeRoot;
nextSyncCommitteeRoot = bytes32(0);
emit CommitteeRotated(currentSyncCommitteeRoot);
}
finalizedSlot = u.finalizedHeader.slot;
finalizedHeaderRoot = finalRoot;
finalizedStateRoot = u.finalizedHeader.stateRoot;
finalizedBodyRoot = u.finalizedHeader.bodyRoot;
finalizedBodyRootSeen[u.finalizedHeader.bodyRoot] = true;
emit FinalityUpdated(finalizedSlot, finalRoot, finalizedBodyRoot);
// 6. optional: store the next sync committee, proven in the attested state.
if (u.hasNextCommittee) {
bytes32 nextRoot = _syncCommitteeRoot(u.nextPubkeys, u.nextAggregatePubkey);
require(
SSZ.isValidMerkleBranch(
nextRoot,
u.nextSyncCommitteeBranch,
NEXT_COMMITTEE_DEPTH,
NEXT_COMMITTEE_INDEX,
u.attestedHeader.stateRoot
),
"LC:bad next-committee branch"
);
nextSyncCommitteeRoot = nextRoot;
emit NextCommitteeStored(nextRoot);
}
}
// -------------------------------------------------------------------------
// execution-payload attestation (for the inbox)
// -------------------------------------------------------------------------
/// @notice Verify that `receiptsRoot` is committed under an Ethereum beacon body
/// that THIS client has finalized (`bodyRoot` must have been seen). The
/// (depth, subtreeIndex) locate the execution payload's receipts_root under
/// body_root (fork-dependent; the caller/inbox supplies the correct
/// generalized index for the target Ethereum fork). Because finality is
/// monotonic, any once-finalized body root stays valid, so a message stays
/// deliverable after finality advances past its block.
function verifyReceiptsRootAgainstFinalized(
bytes32 receiptsRoot,
bytes32 bodyRoot,
bytes32[] calldata branch,
uint256 depth,
uint256 subtreeIndex
) external view returns (bool) {
if (!finalizedBodyRootSeen[bodyRoot]) return false;
return SSZ.isValidMerkleBranch(receiptsRoot, branch, depth, subtreeIndex, bodyRoot);
}
// -------------------------------------------------------------------------
// internals
// -------------------------------------------------------------------------
function _headerRoot(BeaconHeader calldata h) internal view returns (bytes32) {
return SSZ.beaconBlockHeaderRoot(h.slot, h.proposerIndex, h.parentRoot, h.stateRoot, h.bodyRoot);
}
/// @dev compute_domain(DOMAIN_SYNC_COMMITTEE, forkVersion, genesisValidatorsRoot).
function _computeDomain(bytes4 forkVersion) internal view returns (bytes32) {
bytes32 forkDataRoot = SSZ.hashPair(bytes32(forkVersion), genesisValidatorsRoot);
// domain = domainType(4) || forkDataRoot[0:28]
return (bytes32(DOMAIN_SYNC_COMMITTEE)) | (forkDataRoot >> 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
}
}