aere-contracts/contracts/raas/AereDACommittee.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

251 lines
9.3 KiB
Solidity

// 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 account in production; a single-key EOA today, not yet a multisig) 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);
}
}