// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereDACommittee, M-of-N data availability attestation committee (validium-grade) * @notice A data availability committee for AERE rollups / validiums. N member * keys are registered; committee members sign availability attestations * off-chain over (rollupId, epoch, dataCommitment); the contract records * an (rollupId, epoch) as data-available only when at least M distinct * registered members present valid ECDSA signatures over that tuple. * * Reuses the ECDSA-recover attestation pattern already used by the AERE * RaaS contracts (AereRaaSFactory exit attestations): keccak256 of the * encoded tuple, wrapped in the EIP-191 personal-sign prefix, ecrecovered * against the registered member set. * * @dev HONEST SCOPE. This is VALIDIUM-GRADE data availability: a TRUSTED * committee attests that data is available. It is NOT trustless data * availability sampling (DAS) and provides NO cryptographic guarantee * that the data actually exists off-chain. Availability is exactly as * trustworthy as the committee is honest and live. It is "as centralized * as the committee." * * There is NO admin backdoor to forge availability: the ONLY path that * can set an epoch available is `attest`, which strictly requires >= M * distinct registered-member signatures. The `admin` role can only manage * committee membership / threshold (registrar duties, intended to be the * Foundation multisig in production) and can be permanently frozen; it can * never mark data available without member signatures. */ contract AereDACommittee { /* --------------------------------- roles -------------------------------- */ /// @notice Committee registrar. Can add/remove members and set the threshold, /// or freeze membership permanently. Cannot forge availability. address public admin; /// @notice Once true, membership and threshold are immutable forever. bool public frozen; /* -------------------------------- committee ----------------------------- */ uint256 public threshold; // M uint256 public memberCount; // N mapping(address => bool) public isMember; address[] public members; /* ------------------------------- attestations --------------------------- */ struct Attestation { bool available; bytes32 dataCommitment; uint64 attestedAt; uint256 signatures; } // rollupId => epoch => Attestation mapping(bytes32 => mapping(uint256 => Attestation)) private _att; /* --------------------------------- events ------------------------------- */ event AdminTransferred(address indexed prev, address indexed next); event MemberAdded(address indexed member); event MemberRemoved(address indexed member); event ThresholdSet(uint256 threshold); event Frozen(); event DataAvailable( bytes32 indexed rollupId, uint256 indexed epoch, bytes32 dataCommitment, uint256 signatures ); /* --------------------------------- errors ------------------------------- */ error NotAdmin(); error IsFrozen(); error ZeroAddress(); error AlreadyMember(); error NotMember(); error BadThreshold(); error AlreadyRecorded(); error ThresholdNotMet(uint256 got, uint256 need); error UnsortedOrDuplicate(); error NotACommitteeMember(address recovered); modifier onlyAdmin() { if (msg.sender != admin) revert NotAdmin(); _; } /* ----------------------------- constructor ------------------------------ */ constructor(address admin_, address[] memory members_, uint256 threshold_) { if (admin_ == address(0)) revert ZeroAddress(); admin = admin_; emit AdminTransferred(address(0), admin_); for (uint256 i = 0; i < members_.length; i++) { address m = members_[i]; if (m == address(0)) revert ZeroAddress(); if (isMember[m]) revert AlreadyMember(); isMember[m] = true; members.push(m); emit MemberAdded(m); } memberCount = members_.length; if (threshold_ == 0 || threshold_ > memberCount) revert BadThreshold(); threshold = threshold_; emit ThresholdSet(threshold_); } /* ------------------------ committee governance -------------------------- */ /* NOTE: none of these can mark data available. They only manage the */ /* member set / threshold, and are disabled forever once frozen. */ function addMember(address m) external onlyAdmin { if (frozen) revert IsFrozen(); if (m == address(0)) revert ZeroAddress(); if (isMember[m]) revert AlreadyMember(); isMember[m] = true; members.push(m); memberCount++; emit MemberAdded(m); } function removeMember(address m) external onlyAdmin { if (frozen) revert IsFrozen(); if (!isMember[m]) revert NotMember(); isMember[m] = false; uint256 n = members.length; for (uint256 i = 0; i < n; i++) { if (members[i] == m) { members[i] = members[n - 1]; members.pop(); break; } } memberCount--; if (threshold > memberCount) threshold = memberCount; // keep threshold valid emit MemberRemoved(m); } function setThreshold(uint256 t) external onlyAdmin { if (frozen) revert IsFrozen(); if (t == 0 || t > memberCount) revert BadThreshold(); threshold = t; emit ThresholdSet(t); } function transferAdmin(address next) external onlyAdmin { if (next == address(0)) revert ZeroAddress(); emit AdminTransferred(admin, next); admin = next; } /// @notice Permanently renounce all membership/threshold control. After this, /// the committee is immutable and only `attest` can act. function freeze() external onlyAdmin { frozen = true; emit Frozen(); } /* ------------------------------ attestation ----------------------------- */ /// @notice The EIP-191 personal-sign digest committee members sign off-chain. function attestationDigest( bytes32 rollupId, uint256 epoch, bytes32 dataCommitment ) public view returns (bytes32) { bytes32 h = keccak256( abi.encode("AereDACommittee:attest", block.chainid, address(this), rollupId, epoch, dataCommitment) ); return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", h)); } /// @notice Record (rollupId, epoch) as data-available. The ONLY path to /// availability. Requires >= M distinct registered-member signatures /// over (rollupId, epoch, dataCommitment). Signatures MUST be ordered /// by strictly ascending recovered signer address, which guarantees /// distinctness without an in-memory set. function attest( bytes32 rollupId, uint256 epoch, bytes32 dataCommitment, bytes[] calldata sigs ) external returns (bool) { Attestation storage a = _att[rollupId][epoch]; if (a.available) revert AlreadyRecorded(); bytes32 digest = attestationDigest(rollupId, epoch, dataCommitment); uint256 valid; address last = address(0); for (uint256 i = 0; i < sigs.length; i++) { address rec = _recover(digest, sigs[i]); if (rec == address(0) || !isMember[rec]) revert NotACommitteeMember(rec); if (rec <= last) revert UnsortedOrDuplicate(); // strictly ascending => distinct last = rec; valid++; } if (valid < threshold) revert ThresholdNotMet(valid, threshold); a.available = true; a.dataCommitment = dataCommitment; a.attestedAt = uint64(block.timestamp); a.signatures = valid; emit DataAvailable(rollupId, epoch, dataCommitment, valid); return true; } /* ---------------------------------- views ------------------------------- */ function isAvailable(bytes32 rollupId, uint256 epoch) external view returns (bool) { return _att[rollupId][epoch].available; } function getAttestation(bytes32 rollupId, uint256 epoch) external view returns (bool available, bytes32 dataCommitment, uint64 attestedAt, uint256 signatures) { Attestation storage a = _att[rollupId][epoch]; return (a.available, a.dataCommitment, a.attestedAt, a.signatures); } function getMembers() external view returns (address[] memory) { return members; } /* ------------------------------- internal ------------------------------- */ function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := calldataload(sig.offset) s := calldataload(add(sig.offset, 32)) v := byte(0, calldataload(add(sig.offset, 64))) } if (v < 27) v += 27; if (v != 27 && v != 28) return address(0); return ecrecover(hash, v, r, s); } }