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