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 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.
488 lines
22 KiB
Solidity
488 lines
22 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
/**
|
|
* @title AereThresholdRegistry, an on-chain registry of t-of-n MPC/TSS signing
|
|
* committees for threshold ECDSA custody (AERE chain 2800)
|
|
*
|
|
* @notice The on-chain anchor a non-custodial custody committee (e.g. a partner custody vault)
|
|
* uses to PUBLISH the composition of a t-of-n threshold-ECDSA signing group and
|
|
* to let any relying party VERIFY that a submitted signature was produced under
|
|
* that group's key. A committee is `{ members[], threshold t, groupPubKey }`.
|
|
* The group public key is a single secp256k1 point produced by an off-chain
|
|
* Distributed Key Generation (DKG); no single party ever holds the whole private
|
|
* key. On-chain we verify a signature under the group key with `ecrecover`, the
|
|
* same primitive that secures every EOA on this chain.
|
|
*
|
|
* Lifecycle covered:
|
|
* - registerCommittee: publish members, t, and the DKG group key.
|
|
* - verifyThresholdSignature / isValidGroupSignature: relying-party checks.
|
|
* - reshareCommittee: change membership/threshold, SAME group key (a proactive
|
|
* resharing / refresh), authorized by a threshold signature of the CURRENT
|
|
* committee over the exact reshare intent.
|
|
* - rotateKey: move to a NEW group key (post-compromise recovery), authorized by
|
|
* a threshold signature of the current committee.
|
|
* - slashMember: record misbehaviour of a named member, authorized by a
|
|
* threshold signature of the committee (or by an optional guardian).
|
|
* - guardian pause/unpause for emergency freeze.
|
|
*
|
|
* @dev HONEST SCOPE, read this before trusting anything.
|
|
*
|
|
* 1. WHAT ecrecover PROVES, AND WHAT IT DOES NOT. On-chain, a correctly produced
|
|
* t-of-n threshold-ECDSA signature is byte-for-byte INDISTINGUISHABLE from an
|
|
* ordinary single-key ECDSA signature: both are (r,s,v) that recover to the same
|
|
* address. Therefore this contract proves *authenticity under the group key*, it
|
|
* can NOT and does NOT prove that at least t distinct members actually
|
|
* participated. The "t-of-n" guarantee lives ENTIRELY in the off-chain DKG +
|
|
* signing protocol (no party ever reconstructs the full key). If someone
|
|
* reconstructs the group secret and signs alone, this contract would still
|
|
* accept it. That is a property of threshold ECDSA on the EVM in general, not a
|
|
* weakness unique to this contract. Do not read on-chain verification as a proof
|
|
* of live t-participation.
|
|
*
|
|
* 2. NOT POST-QUANTUM. The group key is classical secp256k1 ECDSA, verified by the
|
|
* chain's classical `ecrecover`. This contract has NOTHING to do with AERE's
|
|
* live PQC precompiles, and AERE consensus itself is classical ECDSA QBFT.
|
|
* Threshold PQC (threshold ML-DSA / Falcon) is an open research area and is NOT
|
|
* implemented here; see docs THRESHOLD_PQC.md for the honest maturity status.
|
|
*
|
|
* 3. CUSTODY. This registry holds NO funds. It is metadata + verification only. It
|
|
* never has custody of any key or asset and has no payable path and no
|
|
* selfdestruct. A vault contract would CONSUME this registry (call
|
|
* verifyThresholdSignature) to gate a withdrawal; that vault, not this, holds
|
|
* value.
|
|
*
|
|
* @dev AUTHORIZATION MODEL. Governance actions (reshare / rotate / slash) are authorized
|
|
* by the committee ITSELF: the caller must supply a valid threshold ECDSA signature
|
|
* by the CURRENT group over a domain-separated intent hash binding
|
|
* {chainId, this contract, committeeId, epoch, actionNonce, action params}. This is
|
|
* replay-safe across chains, committees, epochs, and repeated actions. An optional
|
|
* `guardian` address (set at registration, may be zero) can additionally pause the
|
|
* committee and slash members, as a break-glass control; the guardian can never
|
|
* move the key or forge a signature.
|
|
*/
|
|
contract AereThresholdRegistry {
|
|
// ==========================================================================
|
|
// Types
|
|
// ==========================================================================
|
|
|
|
enum Status {
|
|
NONE, // 0: unused id
|
|
ACTIVE, // 1: usable
|
|
PAUSED, // 2: frozen by guardian, verification returns false
|
|
RETIRED // 3: terminal, superseded or shut down
|
|
}
|
|
|
|
struct Committee {
|
|
Status status;
|
|
uint8 threshold; // t: signatures required off-chain to sign
|
|
uint8 size; // n: number of members
|
|
uint64 epoch; // increments on every reshare/rotate
|
|
uint64 actionNonce; // increments on every governance action (replay guard)
|
|
address groupAddress; // = address(keccak256(groupPubKey)[12:]) for ecrecover
|
|
address guardian; // optional break-glass (0 = none)
|
|
bytes32 membersHash; // keccak256(abi.encode(members)) integrity commitment
|
|
bytes groupPubKey; // 64-byte uncompressed secp256k1 point (x||y), no 0x04 prefix
|
|
}
|
|
|
|
// secp256k1 group order; s in the LOWER half prevents signature malleability.
|
|
uint256 internal constant SECP256K1_N =
|
|
0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
|
|
uint256 internal constant SECP256K1_N_HALF =
|
|
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
|
|
|
|
// Domain tags for the governance intent hash (kept distinct so a reshare auth can
|
|
// never be replayed as a rotate auth or a slash auth).
|
|
bytes32 internal constant ACTION_RESHARE = keccak256("AereThresholdRegistry.reshare.v1");
|
|
bytes32 internal constant ACTION_ROTATE = keccak256("AereThresholdRegistry.rotateKey.v1");
|
|
bytes32 internal constant ACTION_SLASH = keccak256("AereThresholdRegistry.slashMember.v1");
|
|
|
|
// ==========================================================================
|
|
// Storage
|
|
// ==========================================================================
|
|
|
|
uint256 public committeeCount;
|
|
mapping(uint256 => Committee) private _committees;
|
|
// committeeId => member address => index+1 (0 = not a member)
|
|
mapping(uint256 => mapping(address => uint256)) private _memberIndex1;
|
|
// committeeId => member address => slashed?
|
|
mapping(uint256 => mapping(address => bool)) public slashed;
|
|
// committeeId => ordered member list
|
|
mapping(uint256 => address[]) private _members;
|
|
|
|
// ==========================================================================
|
|
// Events
|
|
// ==========================================================================
|
|
|
|
event CommitteeRegistered(
|
|
uint256 indexed committeeId,
|
|
address indexed groupAddress,
|
|
uint8 threshold,
|
|
uint8 size,
|
|
address guardian
|
|
);
|
|
event CommitteeReshared(
|
|
uint256 indexed committeeId, uint64 indexed epoch, uint8 threshold, uint8 size, bytes32 membersHash
|
|
);
|
|
event KeyRotated(
|
|
uint256 indexed committeeId, uint64 indexed epoch, address indexed newGroupAddress, uint8 threshold, uint8 size
|
|
);
|
|
event MemberSlashed(uint256 indexed committeeId, address indexed member, bytes32 evidenceHash, address reporter);
|
|
event CommitteePaused(uint256 indexed committeeId, address indexed by);
|
|
event CommitteeUnpaused(uint256 indexed committeeId, address indexed by);
|
|
event CommitteeRetired(uint256 indexed committeeId, address indexed by);
|
|
|
|
// ==========================================================================
|
|
// Registration
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* @notice Publish a new t-of-n committee.
|
|
* @param members Ordered, unique, non-zero member identity addresses (n = length).
|
|
* @param threshold t, with 1 <= t <= n. Off-chain the protocol needs >= t signers.
|
|
* @param groupPubKey 64-byte uncompressed secp256k1 group key (x||y), no 0x04 prefix.
|
|
* @param guardian Optional break-glass address (0 for none).
|
|
* @return committeeId The id of the new committee.
|
|
*
|
|
* @dev The registrant does NOT become privileged: all later governance is by threshold
|
|
* signature of the committee (plus the optional guardian). We deliberately do not
|
|
* keep an "admin" EOA, so the on-chain trust model matches the off-chain one.
|
|
*/
|
|
function registerCommittee(
|
|
address[] calldata members,
|
|
uint8 threshold,
|
|
bytes calldata groupPubKey,
|
|
address guardian
|
|
) external returns (uint256 committeeId) {
|
|
uint256 n = members.length;
|
|
require(n >= 1 && n <= 255, "bad n");
|
|
require(threshold >= 1 && threshold <= n, "bad t");
|
|
address groupAddress = _pubKeyToAddress(groupPubKey);
|
|
require(groupAddress != address(0), "bad groupPubKey");
|
|
|
|
committeeId = ++committeeCount;
|
|
|
|
for (uint256 i = 0; i < n; i++) {
|
|
address m = members[i];
|
|
require(m != address(0), "zero member");
|
|
require(_memberIndex1[committeeId][m] == 0, "dup member");
|
|
_memberIndex1[committeeId][m] = i + 1;
|
|
_members[committeeId].push(m);
|
|
}
|
|
|
|
Committee storage c = _committees[committeeId];
|
|
c.status = Status.ACTIVE;
|
|
c.threshold = threshold;
|
|
c.size = uint8(n);
|
|
c.epoch = 1;
|
|
c.actionNonce = 0;
|
|
c.groupAddress = groupAddress;
|
|
c.guardian = guardian;
|
|
c.membersHash = keccak256(abi.encode(members));
|
|
c.groupPubKey = groupPubKey;
|
|
|
|
emit CommitteeRegistered(committeeId, groupAddress, threshold, uint8(n), guardian);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Relying-party verification
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* @notice Verify a threshold-ECDSA signature under a committee's group key.
|
|
* @param committeeId Target committee.
|
|
* @param messageHash 32-byte digest that was signed (caller's responsibility to bind
|
|
* domain separation into this digest for its own use case).
|
|
* @param signature 65-byte (r||s||v) ECDSA signature, v in {27,28}, s in lower half.
|
|
* @return ok True iff the committee is ACTIVE and `signature` recovers to its group
|
|
* address. See the contract-level HONEST SCOPE note: this proves authenticity
|
|
* under the group key, NOT that >= t members actually participated.
|
|
*
|
|
* @dev Fail-closed: returns false (never reverts) for a paused/retired/unknown
|
|
* committee, a malformed signature, high-s, or a bad v.
|
|
*/
|
|
function verifyThresholdSignature(uint256 committeeId, bytes32 messageHash, bytes calldata signature)
|
|
public
|
|
view
|
|
returns (bool ok)
|
|
{
|
|
Committee storage c = _committees[committeeId];
|
|
if (c.status != Status.ACTIVE) return false;
|
|
address rec = _recover(messageHash, signature);
|
|
return rec != address(0) && rec == c.groupAddress;
|
|
}
|
|
|
|
/**
|
|
* @notice EIP-1271-style helper: returns the magic value if the committee's group key
|
|
* signed `hash`, else 0xffffffff. Lets a committee's group key be used anywhere
|
|
* an EIP-1271 signer is accepted (via a thin proxy that forwards here).
|
|
*/
|
|
function isValidGroupSignature(uint256 committeeId, bytes32 hash, bytes calldata signature)
|
|
external
|
|
view
|
|
returns (bytes4)
|
|
{
|
|
return verifyThresholdSignature(committeeId, hash, signature) ? bytes4(0x1626ba7e) : bytes4(0xffffffff);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Governance (threshold-authorized)
|
|
// ==========================================================================
|
|
|
|
/**
|
|
* @notice Change membership and/or threshold while KEEPING the same group key (a
|
|
* proactive resharing / share refresh). Authorized by a threshold signature of
|
|
* the CURRENT committee over the reshare intent.
|
|
* @param newMembers New ordered, unique, non-zero member set.
|
|
* @param newThreshold New t (1..newMembers.length).
|
|
* @param authSig 65-byte threshold ECDSA signature by the CURRENT group over the
|
|
* intent hash (see {reshareIntent}).
|
|
*
|
|
* @dev groupPubKey is unchanged: an honest off-chain resharing preserves the group key.
|
|
* We increment epoch (invalidating any earlier resharing) and actionNonce.
|
|
*/
|
|
function reshareCommittee(
|
|
uint256 committeeId,
|
|
address[] calldata newMembers,
|
|
uint8 newThreshold,
|
|
bytes calldata authSig
|
|
) external {
|
|
Committee storage c = _committees[committeeId];
|
|
require(c.status == Status.ACTIVE, "not active");
|
|
uint256 n = newMembers.length;
|
|
require(n >= 1 && n <= 255, "bad n");
|
|
require(newThreshold >= 1 && newThreshold <= n, "bad t");
|
|
|
|
bytes32 intent = reshareIntent(committeeId, newMembers, newThreshold);
|
|
require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth");
|
|
|
|
_replaceMembers(committeeId, newMembers);
|
|
|
|
c.threshold = newThreshold;
|
|
c.size = uint8(n);
|
|
c.epoch += 1;
|
|
c.actionNonce += 1;
|
|
c.membersHash = keccak256(abi.encode(newMembers));
|
|
|
|
emit CommitteeReshared(committeeId, c.epoch, newThreshold, uint8(n), c.membersHash);
|
|
}
|
|
|
|
/**
|
|
* @notice Rotate to a NEW group key (post-compromise recovery), optionally with new
|
|
* members / threshold. Authorized by a threshold signature of the CURRENT
|
|
* (pre-rotation) committee over the rotation intent.
|
|
* @param newGroupPubKey 64-byte uncompressed secp256k1 key for the new group.
|
|
* @param authSig 65-byte threshold ECDSA signature by the CURRENT group over the
|
|
* rotation intent hash (see {rotateIntent}).
|
|
*/
|
|
function rotateKey(
|
|
uint256 committeeId,
|
|
bytes calldata newGroupPubKey,
|
|
address[] calldata newMembers,
|
|
uint8 newThreshold,
|
|
bytes calldata authSig
|
|
) external {
|
|
Committee storage c = _committees[committeeId];
|
|
require(c.status == Status.ACTIVE, "not active");
|
|
uint256 n = newMembers.length;
|
|
require(n >= 1 && n <= 255, "bad n");
|
|
require(newThreshold >= 1 && newThreshold <= n, "bad t");
|
|
address newGroupAddress = _pubKeyToAddress(newGroupPubKey);
|
|
require(newGroupAddress != address(0), "bad newGroupPubKey");
|
|
require(newGroupAddress != c.groupAddress, "key unchanged");
|
|
|
|
bytes32 intent = rotateIntent(committeeId, newGroupPubKey, newMembers, newThreshold);
|
|
require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth");
|
|
|
|
_replaceMembers(committeeId, newMembers);
|
|
|
|
c.threshold = newThreshold;
|
|
c.size = uint8(n);
|
|
c.epoch += 1;
|
|
c.actionNonce += 1;
|
|
c.groupAddress = newGroupAddress;
|
|
c.groupPubKey = newGroupPubKey;
|
|
c.membersHash = keccak256(abi.encode(newMembers));
|
|
|
|
emit KeyRotated(committeeId, c.epoch, newGroupAddress, newThreshold, uint8(n));
|
|
}
|
|
|
|
/**
|
|
* @notice Record that a named member misbehaved. Authorized EITHER by a threshold
|
|
* signature of the committee over the slash intent, OR by the guardian.
|
|
* @param member The member being slashed (must be current member).
|
|
* @param evidenceHash Off-chain evidence commitment (e.g. hash of an equivocation).
|
|
* @param authSig Threshold ECDSA signature over the slash intent; ignored (may be
|
|
* empty) when the caller is the guardian.
|
|
*
|
|
* @dev This is a HOOK: it records the fact and emits an event for an external
|
|
* staking/insurance contract to act on. It does not itself move stake (this
|
|
* registry holds no funds). Slashing a member does not change t/n or the key; the
|
|
* committee should follow with a reshare that drops the member.
|
|
*/
|
|
function slashMember(uint256 committeeId, address member, bytes32 evidenceHash, bytes calldata authSig)
|
|
external
|
|
{
|
|
Committee storage c = _committees[committeeId];
|
|
require(c.status == Status.ACTIVE || c.status == Status.PAUSED, "gone");
|
|
require(_memberIndex1[committeeId][member] != 0, "not a member");
|
|
require(!slashed[committeeId][member], "already slashed");
|
|
|
|
bool byGuardian = (c.guardian != address(0) && msg.sender == c.guardian);
|
|
if (!byGuardian) {
|
|
bytes32 intent = slashIntent(committeeId, member, evidenceHash);
|
|
require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth");
|
|
}
|
|
|
|
slashed[committeeId][member] = true;
|
|
c.actionNonce += 1;
|
|
emit MemberSlashed(committeeId, member, evidenceHash, msg.sender);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Guardian break-glass
|
|
// ==========================================================================
|
|
|
|
function pauseCommittee(uint256 committeeId) external {
|
|
Committee storage c = _committees[committeeId];
|
|
require(c.status == Status.ACTIVE, "not active");
|
|
require(c.guardian != address(0) && msg.sender == c.guardian, "not guardian");
|
|
c.status = Status.PAUSED;
|
|
emit CommitteePaused(committeeId, msg.sender);
|
|
}
|
|
|
|
function unpauseCommittee(uint256 committeeId) external {
|
|
Committee storage c = _committees[committeeId];
|
|
require(c.status == Status.PAUSED, "not paused");
|
|
require(c.guardian != address(0) && msg.sender == c.guardian, "not guardian");
|
|
c.status = Status.ACTIVE;
|
|
emit CommitteeUnpaused(committeeId, msg.sender);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Intent hashes (domain-separated)
|
|
// ==========================================================================
|
|
|
|
function reshareIntent(uint256 committeeId, address[] calldata newMembers, uint8 newThreshold)
|
|
public
|
|
view
|
|
returns (bytes32)
|
|
{
|
|
Committee storage c = _committees[committeeId];
|
|
return keccak256(
|
|
abi.encode(
|
|
ACTION_RESHARE, block.chainid, address(this), committeeId, c.epoch, c.actionNonce, newMembers, newThreshold
|
|
)
|
|
);
|
|
}
|
|
|
|
function rotateIntent(
|
|
uint256 committeeId,
|
|
bytes calldata newGroupPubKey,
|
|
address[] calldata newMembers,
|
|
uint8 newThreshold
|
|
) public view returns (bytes32) {
|
|
Committee storage c = _committees[committeeId];
|
|
return keccak256(
|
|
abi.encode(
|
|
ACTION_ROTATE,
|
|
block.chainid,
|
|
address(this),
|
|
committeeId,
|
|
c.epoch,
|
|
c.actionNonce,
|
|
keccak256(newGroupPubKey),
|
|
newMembers,
|
|
newThreshold
|
|
)
|
|
);
|
|
}
|
|
|
|
function slashIntent(uint256 committeeId, address member, bytes32 evidenceHash) public view returns (bytes32) {
|
|
Committee storage c = _committees[committeeId];
|
|
return keccak256(
|
|
abi.encode(ACTION_SLASH, block.chainid, address(this), committeeId, c.epoch, c.actionNonce, member, evidenceHash)
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Views
|
|
// ==========================================================================
|
|
|
|
function getCommittee(uint256 committeeId)
|
|
external
|
|
view
|
|
returns (
|
|
Status status,
|
|
uint8 threshold,
|
|
uint8 size,
|
|
uint64 epoch,
|
|
uint64 actionNonce,
|
|
address groupAddress,
|
|
address guardian,
|
|
bytes32 membersHash,
|
|
bytes memory groupPubKey
|
|
)
|
|
{
|
|
Committee storage c = _committees[committeeId];
|
|
return (c.status, c.threshold, c.size, c.epoch, c.actionNonce, c.groupAddress, c.guardian, c.membersHash, c.groupPubKey);
|
|
}
|
|
|
|
function getMembers(uint256 committeeId) external view returns (address[] memory) {
|
|
return _members[committeeId];
|
|
}
|
|
|
|
function isMember(uint256 committeeId, address who) external view returns (bool) {
|
|
return _memberIndex1[committeeId][who] != 0;
|
|
}
|
|
|
|
function groupAddressOf(uint256 committeeId) external view returns (address) {
|
|
return _committees[committeeId].groupAddress;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Internals
|
|
// ==========================================================================
|
|
|
|
function _replaceMembers(uint256 committeeId, address[] calldata newMembers) internal {
|
|
// Clear old membership index.
|
|
address[] storage old = _members[committeeId];
|
|
for (uint256 i = 0; i < old.length; i++) {
|
|
delete _memberIndex1[committeeId][old[i]];
|
|
// Note: `slashed` flags persist by design (history of a slashed address).
|
|
}
|
|
delete _members[committeeId];
|
|
|
|
// Install new membership.
|
|
for (uint256 i = 0; i < newMembers.length; i++) {
|
|
address m = newMembers[i];
|
|
require(m != address(0), "zero member");
|
|
require(_memberIndex1[committeeId][m] == 0, "dup member");
|
|
_memberIndex1[committeeId][m] = i + 1;
|
|
_members[committeeId].push(m);
|
|
}
|
|
}
|
|
|
|
/// @dev secp256k1 uncompressed pubkey (64 bytes, x||y) -> ethereum address.
|
|
function _pubKeyToAddress(bytes calldata pubKey) internal pure returns (address) {
|
|
if (pubKey.length != 64) return address(0);
|
|
return address(uint160(uint256(keccak256(pubKey))));
|
|
}
|
|
|
|
/// @dev Strict ECDSA recover: 65-byte sig, low-s, v in {27,28}. 0 on any failure.
|
|
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 (uint256(s) > SECP256K1_N_HALF) return address(0);
|
|
if (v != 27 && v != 28) return address(0);
|
|
return ecrecover(hash, v, r, s);
|
|
}
|
|
}
|