// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereDACommitteeV2, M-of-N data availability attestation committee (validium-grade) * @notice Bug-fix redeploy of AereDACommittee. Same validium-grade M-of-N model: * 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. * * ─────────────────────────── WHY V2 ─────────────────────────── * V1 (0xC2a0e6f38084A08CcA768C01af3bFB52C5e99A43) had a FAIL-OPEN hole: * - removeMember() clamped `threshold = memberCount` on the way down, so * an admin removing ALL members drove threshold to 0. * - attest() then accepted an EMPTY signature set: valid(0) < threshold(0) * is false, so `attest(rollupId, epoch, dc, [])` recorded availability * with ZERO committee signatures — breaking the documented "no admin * backdoor to forge availability" invariant. * * V2 fixes: * (1) INVARIANT memberCount >= threshold >= 1 is preserved everywhere. * threshold can never be driven to 0. * (2) removeMember() REVERTS (WouldBreakThreshold) if removing the member * would leave memberCount < threshold. The admin must first LOWER the * threshold explicitly via setThreshold (which still forbids 0), and * can never remove the last member of a threshold-1 committee. * (3) attest() REQUIRES sigs.length > 0 (EmptySignatureSet) in addition to * valid >= threshold. Belt-and-suspenders against any future path to * a zero threshold. * * Everything else is byte-for-byte the same behavior as V1: M distinct, * strictly-ascending-recovered-address member signatures; AlreadyRecorded * idempotency; the same EIP-191 attestation digest. * * @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 with M >= 1. */ contract AereDACommitteeV2 { /* --------------------------------- 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 (invariant: memberCount >= threshold >= 1) 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 WouldBreakThreshold(uint256 newMemberCount, uint256 threshold); // V2 error EmptySignatureSet(); // V2 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; // INVARIANT: memberCount >= threshold >= 1 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); } /// @notice Remove a committee member. V2 FIX: reverts if the removal would /// leave memberCount < threshold (which in V1 silently clamped the /// threshold, all the way to 0 when the last member was removed). The /// admin must LOWER the threshold explicitly (setThreshold, min 1) /// before shrinking the committee below the current threshold. The /// last member of a threshold-1 committee can never be removed, so /// threshold can never reach 0. function removeMember(address m) external onlyAdmin { if (frozen) revert IsFrozen(); if (!isMember[m]) revert NotMember(); uint256 newCount = memberCount - 1; if (newCount < threshold) revert WouldBreakThreshold(newCount, threshold); 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 = newCount; emit MemberRemoved(m); } function setThreshold(uint256 t) external onlyAdmin { if (frozen) revert IsFrozen(); // INVARIANT: memberCount >= threshold >= 1 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 a NON-EMPTY set of >= 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) { // V2 FIX: never accept an empty signature set, independent of threshold. if (sigs.length == 0) revert EmptySignatureSet(); 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); } }