Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion artifacts/ConsensusRegistry.json

Large diffs are not rendered by default.

54 changes: 50 additions & 4 deletions src/consensus/ConsensusRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableS
import { SlotDerivation } from "@openzeppelin/contracts/utils/SlotDerivation.sol";
import { TransientSlot } from "@openzeppelin/contracts/utils/TransientSlot.sol";
import { SignatureCheckerLib } from "solady/utils/SignatureCheckerLib.sol";
import { ECDSA } from "solady/utils/ECDSA.sol";
import { ReentrancyGuard } from "solady/utils/ReentrancyGuard.sol";
import { RewardInfo, Slash, IStakeManager } from "../interfaces/IStakeManager.sol";
import { StakeManager } from "./StakeManager.sol";
Expand Down Expand Up @@ -620,7 +621,7 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
)
);
bytes32 digest = _hashTypedData(structHash);
if (!SignatureCheckerLib.isValidSignatureNowCalldata(validatorAddress, digest, validatorEIP712Signature)) {
if (!_isValidValidatorSignature(validatorAddress, digest, validatorEIP712Signature)) {
revert NotValidator(validatorAddress);
}
}
Expand Down Expand Up @@ -1052,6 +1053,50 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
}
}

/// @notice Authorizes a delegated stake by proving control of `validatorAddress` over `digest`
/// @dev Which authority is competent to answer depends on what kind of account the validator is:
/// an externally owned account answers with its secp256k1 key, a contract account answers through
/// ERC-1271. Code size no longer separates the two. An EIP-7702 delegated EOA carries a 23-byte
/// designator, so a bare code-size probe reads it as a contract and defers to whatever the EOA
/// currently points at - a revocable wallet program, not the identity governance whitelisted. That
/// substitution is wrong in both directions: it locks out delegated validators whose wallet has no
/// ERC-1271 handler despite a perfectly valid key signature, and it lets a permissive handler
/// authorize a delegation the key holder never signed. Delegated EOAs are therefore held to their
/// key, exactly as undelegated EOAs are, and only genuine contract accounts reach ERC-1271.
function _isValidValidatorSignature(
address validatorAddress,
bytes32 digest,
bytes calldata signature
)
internal
view
returns (bool)
{
if (_isDelegationDesignator(validatorAddress)) {
// `tryRecoverCalldata` yields the zero address for a malformed or non-matching signature,
// which never equals a validator address (the zero token id is rejected at mint)
return ECDSA.tryRecoverCalldata(digest, signature) == validatorAddress;
}

return SignatureCheckerLib.isValidSignatureNowCalldata(validatorAddress, digest, signature);
}

/// @dev Byte length of an EIP-7702 delegation designator: the 3-byte prefix plus an address
uint256 private constant EIP7702_DESIGNATOR_LENGTH = 23;

/// @notice Returns whether `account`'s code is an EIP-7702 delegation designator, ie whether the
/// account is an EOA that has delegated execution to another address
/// @dev A designator is exactly `0xef0100 || implementation`. The prefix is unambiguous: EIP-3541
/// forbids deployed contract code from beginning with `0xEF`, so nothing else can wear it.
/// `address.code.length` compiles to a bare `EXTCODESIZE`, so the copy below only ever runs
/// against the 23 bytes it has already matched.
function _isDelegationDesignator(address account) internal view returns (bool) {
if (account.code.length != EIP7702_DESIGNATOR_LENGTH) return false;

bytes memory code = account.code;
return code[0] == bytes1(0xef) && code[1] == bytes1(0x01) && code[2] == bytes1(0x00);
}

/// @notice Enters a validator into the activation queue upon receiving stake
/// @dev Stores the new validator in the `validators` vector
function _recordStaked(
Expand Down Expand Up @@ -1286,11 +1331,12 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
// this is believed to be impossible
if (!r) revert IssuanceTransferFailed();

// exit, retire, and unstake + burn validator immediately
// exit, retire, and burn the validator's token immediately. The ledgers above already
// routed every last wei to Issuance, so nothing is owed and nothing is pushed: this path
// makes no call to a validator-controlled address and cannot be blocked by one
_exit(validator, currentEpoch);
_retire(validator);
address recipient = _getRecipient(validatorAddress);
_unstake(validatorAddress, recipient, true);
_burnConsensusNFT(validatorAddress);
}

/// @dev Stores the number of blocks finalized in previous epoch and the voter committee for the new epoch
Expand Down
20 changes: 15 additions & 5 deletions src/consensus/StakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,19 @@ abstract contract StakeManager is ERC721Enumerable, EIP712, IStakeManager {
return rewards;
}

/// @dev Retires the validator's ConsensusNFT and delegation record without moving any value.
/// Split out from `_unstake` so the confiscating burn path, which settles its own ledgers and
/// owes the validator nothing, can close out the token without touching a payout: no value is
/// pushed to a validator-controlled address, so account code the validator attaches after being
/// whitelisted - an EIP-7702 delegation with a reverting handler, say - cannot block a
/// governance burn or a slash-to-zero ejection inside the epoch-boundary system call.
function _burnConsensusNFT(address validatorAddress) internal {
_burn(_getTokenId(validatorAddress));
if (totalSupply() == 0) revert InvalidSupply();

delete delegations[validatorAddress];
}

function _unstake(
address validatorAddress,
address recipient,
Expand All @@ -215,13 +228,10 @@ abstract contract StakeManager is ERC721Enumerable, EIP712, IStakeManager {
virtual
returns (uint256)
{
_burn(_getTokenId(validatorAddress));
if (totalSupply() == 0) revert InvalidSupply();

delete delegations[validatorAddress];
_burnConsensusNFT(validatorAddress);

(uint256 bal, uint256 stakeAmt, uint256 rewards) = getBalanceBreakdown(validatorAddress);
// zero outstanding balance implies burn context, no further action needed- ledgers are already settled
// a fully settled ledger leaves nothing to return
if (bal == 0) return bal;

// otherwise wipe existing balance & identify the amount of stake due to recipient
Expand Down
2 changes: 2 additions & 0 deletions src/consensus/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ At the epoch boundary, the protocol performs gasless system calls to the Consens
- **Issuance Contract**: Accepts TEL for rewards distribution, using TEL "burnt" for epoch rewards. For simplicity, the Issuance contract offloads accounting to the EVM native ledger.
- **Delegation**: DPOS is currently supported though expected to be used sparingly for delegators and validators with ongoing offchain relationships or agreements.
- **Delegation Rewards**: Delegators receive all stake rewards and the staked balance upon unstaking, so schemas for splitting stake rewards between validator and delegator are assumed to be agreed upon offchain by those parties and settled externally to the protocol
- **Delegation Authorization and Account Abstraction**: `delegateStake` binds a delegator who becomes the recipient of every subsequent claim and unstake, so it must ask the authority that actually owns the validator address. Since EIP-7702, code size no longer answers that question: a delegated EOA carries a 23-byte `0xef0100 || implementation` designator, and a code-size probe would route the check to whatever wallet program the account currently points at. That program is revocable and, in the permissive case, willing to approve a public digest, so it is not a substitute for the key. The registry matches the designator prefix explicitly and holds EOAs to their secp256k1 key whether delegated or not; only genuine contract accounts authorize through ERC-1271. An operator who has moved to a delegated smart account and no longer holds the underlying key can still onboard: `stake` authorizes off `msg.sender` rather than a signature, and governance may onboard the delegation itself, since the owner path skips the signature check entirely.
- **Ejection Cannot Be Blocked by Validator Account Code**: governance screening at mint time is not durable - a whitelisted holder can attach account code afterwards, including code that rejects every incoming call. Both ejection paths are therefore push-free. `burn` and the slash-to-zero branch of `applySlashes` route the validator's entire stake-backed balance to Issuance and close out the ConsensusNFT without a single call to a validator-controlled address; queued version-change escrow, the one balance an ejection owes back, accrues as a `claimRefund` credit. This matters most for the slash path, which runs inside an epoch-boundary system call where a revert would stall the closing block rather than merely inconvenience governance.

## Geographic Diversity

Expand Down
2 changes: 2 additions & 0 deletions src/consensus/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@
- consensus burns slash all the validator's remaining stake
- slashes are applied until the validator outstanding balance reaches 0,
- consensus burns and slashes-to-zero immediately retire the validator and eject it from all upcoming committees
- neither ejection path makes a recipient-facing external call: `burn` and the slash-to-zero branch consolidate the validator's entire stake-backed balance on Issuance and then close the ConsensusNFT out through `_burnConsensusNFT`, which moves no value. Governance screening at mint time is not durable (a whitelisted holder can attach account code afterwards, including an EIP-7702 delegation that reverts on every call), so ejection must never depend on the validator or its delegator being callable. Queued version-change escrow, the one balance an ejection owes back, accrues as a `claimRefund` credit
- ConsensusRegistry only ever holds staked funds, including on behalf of the initial validator set at network genesis
- Issuance only ever holds epoch reward funds, less claims
- when unstaking, stake is sourced from the registry
- when claiming or unstaking, rewards are sourced from Issuance
- claims can revert if Issuance contract runs dry (eg TAO governance problem) but the rewards ledger must continue being updated by applyIncentives and applySlashes
- all capital flows to the stake originator, ie the recipient for both stake and rewards is either a validator's delegator if one exists, or the validator itself if not; queue escrow returns are the one exception and flow to the recorded funder
- `delegateStake` accepts a delegation only from the authority that owns the validator address: an EOA is held to its secp256k1 key whether or not it carries an EIP-7702 delegation designator, and only a genuine contract account authorizes through ERC-1271. Code size does not decide this - a delegated EOA has 23 bytes of code - so the designator is matched by its `0xef0100` prefix, which EIP-3541 makes unambiguous. A validator's delegated wallet program is never accepted as a stand-in for its key, since that program is revocable and the digest is public
- the boundary is three system calls the protocol sequences within the closing block - applyIncentives, then applySlashes, then concludeEpoch - and that ordering is a security invariant: slashes land on full old-version collateral before the version-queue settlement inside concludeEpoch reads post-slash balances, and the protocol assembles the committee after slashes so ejections are reflected in both the committee contents and the size check
- applyIncentives and applySlashes skip sentinel-address and retired entries rather than reverting, so no malformed system-call input can stall the boundary
- in-service validator stake versions change only inside concludeEpoch, so reward weights are stable within an epoch: applyIncentives always reads the version that was active for the entire closing epoch
Expand Down
4 changes: 4 additions & 0 deletions src/interfaces/IStakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ interface IStakeManager {
/// @notice Ensuring `uncompressedPubkey` corresponds to `ValidatorInfo::blsPubkey` is better
/// performed externally in Rust by the protocol due to EIP2537 precompile & EVM limitations
/// so this contract does not perform any (un)compression checks
/// @notice `validatorSig` must come from the authority that owns the validator address itself. For an
/// externally owned account that is its secp256k1 key, whether or not the account has attached an
/// EIP-7702 delegation; a delegated wallet program is not accepted as a stand-in for the key. Only a
/// genuine contract account authorizes through ERC-1271.
/// @param deadline Unix timestamp past which `validatorSig` is rejected (ignored for governance calls)
function delegateStake(
bytes calldata blsPubkey,
Expand Down
Loading
Loading