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.
283 lines
11 KiB
Solidity
283 lines
11 KiB
Solidity
// 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);
|
|
}
|
|
}
|