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.

82 changes: 69 additions & 13 deletions src/consensus/ConsensusRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
/// @notice When true, `topUpSlashedStake` is restricted to governance; when false, validators
/// and their delegators may also restore their own slashed stake
bool public topUpAuthorityRequired;
/// @dev The Issuance-backed half of the credit ledger, keyed by recipient: the reward leg of a
/// withdrawal whose push failed. Held separately from `claimableRefunds` because the two are
/// backed by different contracts - stake by this one, rewards by Issuance - and `claimRefund`
/// pays each from its own source. Appended at the storage tail to preserve every existing slot
mapping(address => uint256) public claimableRewards;

/// @dev Signals a validator's pending status until activation/exit to correctly apply incentives
uint32 internal constant PENDING_EPOCH = type(uint32).max;
Expand Down Expand Up @@ -149,6 +154,9 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
// (restorable via `topUpSlashedStake`). Rewards above the stake amount never add weight
uint256 versionStakeAmount = versions[rewardeeVersion].stakeAmount;
uint256 balance = balances[reward.validatorAddress];
// this cap is also what makes an entry naming a never-staked address inert: its balance
// is zero, so it carries no weight and receives nothing. `applySlashes` cannot rely on
// the same property and screens those entries explicitly
uint256 stakeAmount = balance < versionStakeAmount ? balance : versionStakeAmount;
uint256 weight = stakeAmount * reward.consensusHeaderCount;

Expand Down Expand Up @@ -193,6 +201,15 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
// unless validator was forcibly retired & ejected via burn: skip
if (isRetired(slash.validatorAddress)) continue;

// an `Undefined` entry never staked, so it backs no balance and there is nothing to
// decrement: it either holds no ConsensusNFT at all or was whitelisted by governance
// and never staked. `isRetired` is false in both cases, so without this skip the zero
// balance falls through to the ejection branch, which would burn a nonexistent token
// and stall the boundary, or ship a full stake amount drawn from other validators'
// collateral to Issuance. The system caller does no membership filtering, so entries
// are screened here, the same short-circuit `burn` applies before ejecting
if (validators[slash.validatorAddress].currentStatus == ValidatorStatus.Undefined) continue;

if (balances[slash.validatorAddress] > slash.amount) {
// ledger-only decrement: the confiscated native TEL remains held by this contract
// and is consolidated on Issuance at settlement (unstake, burn, or a queued
Expand Down Expand Up @@ -639,7 +656,7 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
}

/// @inheritdoc IConsensusRegistry
function topUpSlashedStake(address validatorAddress) external payable override whenNotPaused {
function topUpSlashedStake(address validatorAddress) external payable override whenNotPaused nonReentrant {
// require `validatorAddress` is known & whitelisted, having been issued a ConsensusNFT by governance
_checkConsensusNFTOwner(validatorAddress);

Expand Down Expand Up @@ -684,7 +701,7 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
}

/// @inheritdoc IConsensusRegistry
function activate() external override whenNotPaused {
function activate() external override whenNotPaused nonReentrant {
// require caller is whitelisted, having been issued a ConsensusNFT by governance
_checkConsensusNFTOwner(msg.sender);

Expand Down Expand Up @@ -753,17 +770,22 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
// 5. `Staked` validators settle immediately: they are not in service, never members of a
// committee, and can already reclaim their full stake at any time via `unstake`
if (status == ValidatorStatus.Staked) {
// record the new version before settling. The decrease branch debits the balance and
// then pushes the surplus to the recipient, and every function the recipient reenters
// during that push resolves the stake amount through the recorded version: were the
// version still the old one, the debited balance would read as a slash of exactly the
// surplus, and `topUpSlashedStake` would restore stake the validator never lost
validator.stakeVersion = targetVersion;
if (_isDelegated(validatorAddress)) {
delegations[validatorAddress].validatorVersion = targetVersion;
}

if (deficit > 0) {
balances[validatorAddress] += deficit;
} else if (newStakeAmount < oldStakeAmount) {
_settleStakeDecrease(validatorAddress, recipient, oldStakeAmount, newStakeAmount);
}

validator.stakeVersion = targetVersion;
if (_isDelegated(validatorAddress)) {
delegations[validatorAddress].validatorVersion = targetVersion;
}

emit ValidatorStakeVersionUpgraded(
validatorAddress, oldVersion, targetVersion, oldStakeAmount, newStakeAmount
);
Expand Down Expand Up @@ -799,15 +821,27 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,

/// @inheritdoc IStakeManager
function claimRefund() external override whenNotPaused nonReentrant {
uint256 amount = claimableRefunds[msg.sender];
if (amount == 0) revert NoClaimableRefund();
uint256 stakeLeg = claimableRefunds[msg.sender];
uint256 rewardLeg = claimableRewards[msg.sender];
if (stakeLeg + rewardLeg == 0) revert NoClaimableRefund();

// the stake leg is this contract's own native TEL and is always payable; the reward leg is
// paid out of Issuance, so cap it at what Issuance holds and leave the remainder credited
// rather than reverting the whole claim over a reward pool that ran dry
uint256 payableRewards = rewardLeg > issuance.balance ? issuance.balance : rewardLeg;
uint256 payout = stakeLeg + payableRewards;
// a reward-only credit against a pool that has run dry has nothing to deliver yet; say so
// rather than completing as a transfer of zero and reporting it as a claim
if (payout == 0) revert NoClaimableRefund();

claimableRefunds[msg.sender] = 0;
claimableRewards[msg.sender] = rewardLeg - payableRewards;

// full-gas push through Issuance; a revert here affects only the caller, whose credit is
// preserved by the transaction reverting as a whole
Issuance(issuance).distributeStakeReward{ value: amount }(msg.sender, 0);
Issuance(issuance).distributeStakeReward{ value: stakeLeg }(msg.sender, payableRewards);

emit RefundClaimed(msg.sender, amount);
emit RefundClaimed(msg.sender, payout);
}

/// @notice Returns the validators with a queued stake version change, in set order
Expand Down Expand Up @@ -840,8 +874,12 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,

if (refundAmount > 0) {
balances[validatorAddress] -= refundAmount;
// Route through Issuance (same pattern as _unstake)
Issuance(issuance).distributeStakeReward{ value: refundAmount }(recipient, 0);
// push with a gas cap and fall back to a `claimRefund` credit, matching what the
// boundary lane does for this same settlement. The recipient of a delegated validator's
// refund is its delegator, an address the validator can neither change nor remove, so
// the two lanes must agree that a recipient which stops accepting value defers its own
// payout rather than blocking the operation
_settleValue(recipient, refundAmount);
}

// consolidate confiscated slash remainder on Issuance (same as _unstake pattern)
Expand Down Expand Up @@ -906,6 +944,11 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
revert AlreadyDefined(validatorAddress);
}

// stamp the address onto the still-`Undefined` record so a governance burn before the
// validator ever stakes emits lifecycle events naming it; every other field is left at its
// default and `_recordStaked` overwrites the record wholesale at stake time
validators[validatorAddress].validatorAddress = validatorAddress;

// issue the ConsensusNFT
_mint(validatorAddress, _getTokenId(validatorAddress));
}
Expand Down Expand Up @@ -1205,6 +1248,19 @@ contract ConsensusRegistry is StakeManager, Pausable, Ownable, ReentrancyGuard,
}
}

/// @inheritdoc StakeManager
function _settleStakePayout(address recipient, uint256 unstakeAmt, uint256 rewards) internal override {
try Issuance(issuance).distributeStakeReward{ value: unstakeAmt, gas: REFUND_GAS_LIMIT }(recipient, rewards) { }
catch {
// the recipient rejected the push, so each leg stays where it already sits and backs its
// half of the credit: the stake on this contract, the rewards on Issuance. `claimRefund`
// then delivers both in the single transfer this push would have made
if (unstakeAmt > 0) claimableRefunds[recipient] += unstakeAmt;
if (rewards > 0) claimableRewards[recipient] += rewards;
emit RefundQueued(recipient, unstakeAmt + rewards);
}
}

/// @dev The single point that mutates per-status set membership and the cached eligible count. Removes
/// the validator from its old status set and adds it to the new one, keeping the sets in lockstep with
/// `currentStatus`. `Undefined` and `Any` carry no set (the latter is the retired sentinel), so they are
Expand Down
17 changes: 16 additions & 1 deletion src/consensus/StakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,27 @@ abstract contract StakeManager is ERC721Enumerable, EIP712, IStakeManager {
// shortfall, so an underfunded reward pool can never block a stake withdrawal
if (acceptRewardShortfall && rewards > issuance.balance) rewards = issuance.balance;

// a reward pool that cannot cover the rewards leg is the caller's decision to make, so raise it
// here rather than let the payout's credit fallback absorb it: settling for less than the full
// amount stays reachable only by accepting the shortfall
uint256 issuanceBal = issuance.balance;
if (rewards > issuanceBal) {
revert Issuance.InsufficientBalance(issuanceBal + unstakeAmt, unstakeAmt + rewards);
}

// debit `unstakeAmt` due to recipient from this contract balance, debit rewards from Issuance balance
Issuance(issuance).distributeStakeReward{ value: unstakeAmt }(recipient, rewards);
_settleStakePayout(recipient, unstakeAmt, rewards);

return unstakeAmt + rewards;
}

/// @dev Delivers a settled withdrawal of `unstakeAmt` from this contract's balance plus `rewards`
/// from Issuance's to `recipient`. Left to the inheriting registry so the push can degrade to a
/// pull-based credit: the recipient of a delegated validator's stake is its delegator, an address
/// the validator cannot change or remove, so a delegator that becomes uncallable after the
/// delegation is formed must not be able to hold the validator's stake hostage.
function _settleStakePayout(address recipient, uint256 unstakeAmt, uint256 rewards) internal virtual;

function _checkRewards(address validatorAddress, uint8 validatorVersion) internal virtual returns (uint256) {
uint256 initialStake = versions[validatorVersion].stakeAmount;
uint256 rewards = _getRewards(validatorAddress, initialStake);
Expand Down
7 changes: 6 additions & 1 deletion src/consensus/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- `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
- neither system call may act on an address that never staked, whether it holds no ConsensusNFT at all or was whitelisted and never staked; the system caller does no membership filtering of its own. `applySlashes` screens those entries by status, since a zero balance otherwise falls through to its ejection branch, which would burn a nonexistent token and stall the boundary or ship a stake amount the address never deposited to Issuance out of other validators' collateral. `applyIncentives` needs no such check: capping weight at the outstanding balance already leaves a zero-balance entry weightless, and screening by status there costs measurable gas per rewardee on the boundary path
- 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
- a queued stake increase's escrow lives in the queue entry, never in `balances`, so it is invisible to reward accounting until the boundary flip credits it and raises the reward floor atomically
- stake decreases age `STAKE_DECREASE_DELAY_EPOCHS` boundaries before settling; settlement refunds are computed from post-slash balances, and no request or cancellation timing can move value ahead of a slash
Expand All @@ -66,9 +67,13 @@
- validators with both accrued rewards and a pending slash should claim rewards before requesting a stake-decreasing change; a stake-decreasing settlement on a partially slashed validator may zero claimable rewards (the recipient still receives the correct total ETH via the refund)
- boundary settlement refunds always accrue as `claimRefund` credits, so concludeEpoch performs no recipient-facing external calls and no recipient can revert or grief it; credits are keyed by recipient and survive validator retirement; user-initiated escrow returns push with a gas cap and fall back to the same credit
- retiring a validator (unstake, governance burn, slash-to-zero) drops its queue entry and credits any escrow back to the funder; escrow is never confiscable
- the registry's native balance always backs the sum of stake balances (up to lazily consolidated slash remainders), queue escrows, and unclaimed refund credits
- the registry's native balance always backs the sum of stake balances (up to lazily consolidated slash remainders), queue escrows, and unclaimed refund credits; the Issuance balance backs unclaimed reward credits, the reward leg of a withdrawal whose push failed
- every recipient-facing push in a user operation carries a gas cap and a `claimRefund` credit fallback: the withdrawal payout, the immediate `Staked` stake-decrease surplus, and user-initiated escrow returns. A recipient that stops accepting value therefore defers only its own payout and can never block the operation, matching what the boundary lane already does by crediting unconditionally
- a withdrawal payout is pushed with a gas cap and falls back to a `claimRefund` credit for the full amount, so no recipient can be stranded by its own account code. This matters for a delegated validator specifically: the recipient is the delegator, an address the validator can neither change nor remove, and a delegator that becomes uncallable after the delegation is formed would otherwise leave `burn` (which confiscates) as governance's only remedy. The credit splits along the two funding sources - stake leg on the registry, reward leg on Issuance - and `claimRefund` pays both in the single transfer the push would have made, capping the reward leg at Issuance's balance so a dry reward pool defers that leg rather than blocking the stake leg
- an uncoverable rewards leg still reverts `unstake` outright rather than degrading to a credit, so `acceptRewardShortfall` remains the only way to settle for less than the full amount
- `requestStakeVersionChange` only permits forward version changes (targetVersion > currentVersion); moving to an earlier version index is rejected
- `requestStakeVersionChange` is restricted to validators with status Staked, PendingActivation, or Active; `Staked` validators settle immediately since they are not in service and can already unstake in full at any time
- the immediate `Staked` settlement records the new stake version before pushing the surplus, so the recorded version and the balance never disagree across an external call. A validator whose balance has been debited while the old version is still recorded reads as slashed by exactly the surplus, which `topUpSlashedStake` would let it restore as stake it never lost; `topUpSlashedStake` and `activate` are additionally `nonReentrant` so neither can be entered from within another guarded function's push

**protocol**

Expand Down
10 changes: 8 additions & 2 deletions src/interfaces/IStakeManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ interface IStakeManager {
/// @param acceptRewardShortfall When true, caps the rewards payout at the Issuance contract's available
/// balance and permanently forfeits only the shortfall, so an underfunded reward pool can never
/// block a stake withdrawal; identical to a normal unstake whenever Issuance can cover the rewards
/// @notice The payout is pushed with a bounded gas stipend and falls back to a `claimRefund` credit
/// for the full amount if the recipient rejects it, so a recipient that becomes uncallable cannot
/// strand the stake. The withdrawal settles and the validator retires either way
function unstake(address validatorAddress, bool acceptRewardShortfall) external;

/// @notice Returns the delegation digest that a validator should sign to accept a delegation
Expand Down Expand Up @@ -256,8 +259,11 @@ interface IStakeManager {

/// @dev Transfers the caller's accumulated refund credit
/// @notice Credits accrue from boundary settlement refunds, escrow returns on retirement, and
/// user-initiated escrow returns whose push failed; they are detached from the validator
/// lifecycle and survive burns and retirement
/// user-initiated escrow returns and withdrawal payouts whose push failed; they are detached
/// from the validator lifecycle and survive burns and retirement
/// @notice A withdrawal payout credit carries a reward leg paid from Issuance. That leg is capped
/// at Issuance's balance and any remainder stays credited, so a reward pool that has run dry
/// defers part of a claim rather than blocking it
function claimRefund() external;

/// @dev Permissioned function to withdraw TEL from the Issuance contract to the caller
Expand Down
Loading
Loading