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.
327 lines
16 KiB
Solidity
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
|
|
}
|
|
}
|