aere-contracts/contracts/mpc/AereThresholdRegistry.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

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 Bank28 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);
}
}