From 1b5dbd80a48aa55152ed05528ce99b6a56e7ddc1 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 13 Apr 2026 23:00:54 +0200 Subject: [PATCH 01/58] feat: add queued stake updates and withdrawals Implement the SWIP-40 and SWIP-41 staking flow with delayed queue-based stake updates, withdrawals, and exits while keeping redistribution aligned with effective active stake. --- deploy/local/003_deploy_staking.ts | 12 +- deploy/main/003_deploy_staking.ts | 12 +- deploy/main/010_deploy_verify.ts | 11 +- deploy/test/003_deploy_staking.ts | 12 +- deploy/test/010_deploy_verify.ts | 11 +- helper-hardhat-config.ts | 39 +- src/Redistribution.sol | 16 +- src/Staking.sol | 644 +++++++++++++++--------- test/Redistribution.test.ts | 15 +- test/Staking.test.ts | 779 +++++++---------------------- test/Stats.test.ts | 2 +- 11 files changed, 687 insertions(+), 866 deletions(-) diff --git a/deploy/local/003_deploy_staking.ts b/deploy/local/003_deploy_staking.ts index fbf1f9db..42bc25be 100644 --- a/deploy/local/003_deploy_staking.ts +++ b/deploy/local/003_deploy_staking.ts @@ -4,12 +4,18 @@ import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { const { deploy, get, log } = deployments; const { deployer } = await getNamedAccounts(); - const swarmNetworkID = networkConfig[network.name]?.swarmNetworkId; + const config = networkConfig[network.name] || {}; + const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); - const oracleAddress = (await get('PriceOracle')).address; - const args = [token.address, swarmNetworkID, oracleAddress]; + const args = [ + token.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; await deploy('StakeRegistry', { from: deployer, args: args, diff --git a/deploy/main/003_deploy_staking.ts b/deploy/main/003_deploy_staking.ts index 526f009d..7c65be2e 100644 --- a/deploy/main/003_deploy_staking.ts +++ b/deploy/main/003_deploy_staking.ts @@ -4,11 +4,17 @@ import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { const { deploy, log, get } = deployments; const { deployer } = await getNamedAccounts(); - const swarmNetworkID = networkConfig[network.name]?.swarmNetworkId; + const config = networkConfig[network.name] || {}; + const swarmNetworkID = config.swarmNetworkId; const token = await get('Token'); - const oracleAddress = (await get('PriceOracle')).address; - const args = [token.address, swarmNetworkID, oracleAddress]; + const args = [ + token.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; await deploy('StakeRegistry', { from: deployer, args: args, diff --git a/deploy/main/010_deploy_verify.ts b/deploy/main/010_deploy_verify.ts index a054cf1e..d6c6fd8f 100644 --- a/deploy/main/010_deploy_verify.ts +++ b/deploy/main/010_deploy_verify.ts @@ -6,7 +6,8 @@ const func: DeployFunction = async function ({ deployments, network }) { const { log, get } = deployments; if (network.name == 'mainnet' && process.env.MAINNET_ETHERSCAN_KEY) { - const swarmNetworkID = networkConfig[network.name]?.swarmNetworkId; + const config = networkConfig[network.name] || {}; + const swarmNetworkID = config.swarmNetworkId; const token = await get('Token'); // Verify postageStamp @@ -27,7 +28,13 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); - const argStaking = [token.address, swarmNetworkID, priceOracle.address]; + const argStaking = [ + token.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; log('Verifying...'); await verify(staking.address, argStaking); diff --git a/deploy/test/003_deploy_staking.ts b/deploy/test/003_deploy_staking.ts index d9559c49..b82b2f20 100644 --- a/deploy/test/003_deploy_staking.ts +++ b/deploy/test/003_deploy_staking.ts @@ -4,11 +4,17 @@ import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { const { deploy, log, get } = deployments; const { deployer } = await getNamedAccounts(); - const swarmNetworkID = networkConfig[network.name]?.swarmNetworkId; + const config = networkConfig[network.name] || {}; + const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); - const oracleAddress = (await get('PriceOracle')).address; - const args = [token.address, swarmNetworkID, oracleAddress]; + const args = [ + token.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; await deploy('StakeRegistry', { from: deployer, args: args, diff --git a/deploy/test/010_deploy_verify.ts b/deploy/test/010_deploy_verify.ts index fc5d6d0c..2433f17c 100644 --- a/deploy/test/010_deploy_verify.ts +++ b/deploy/test/010_deploy_verify.ts @@ -6,7 +6,8 @@ const func: DeployFunction = async function ({ deployments, network }) { const { log, get } = deployments; if (process.env.TESTNET_ETHERSCAN_KEY) { - const swarmNetworkID = networkConfig[network.name]?.swarmNetworkId; + const config = networkConfig[network.name] || {}; + const swarmNetworkID = config.swarmNetworkId; // Verify TestNet token const token = await get('TestToken'); @@ -34,7 +35,13 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); - const argStaking = [token.address, swarmNetworkID, priceOracle.address]; + const argStaking = [ + token.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; log('Staking'); await verify(staking.address, argStaking); diff --git a/helper-hardhat-config.ts b/helper-hardhat-config.ts index ee7ef0bb..2e22695f 100644 --- a/helper-hardhat-config.ts +++ b/helper-hardhat-config.ts @@ -2,34 +2,67 @@ export interface networkConfigItem { blockConfirmations?: number; swarmNetworkId?: number; multisig?: string; + stakeWaitBase?: number; + stakeWaitOverlayChange?: number; + stakeWaitWithdrawal?: number; } export interface networkConfigInfo { [key: string]: networkConfigItem; } export const networkConfig: networkConfigInfo = { - localhost: { swarmNetworkId: 0, multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5' }, - hardhat: { swarmNetworkId: 0, multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5' }, - localcluster: { swarmNetworkId: 0, multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5' }, + localhost: { + swarmNetworkId: 0, + multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, + }, + hardhat: { + swarmNetworkId: 0, + multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, + }, + localcluster: { + swarmNetworkId: 0, + multisig: '0x62cab2b3b55f341f10348720ca18063cdb779ad5', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, + }, testnetlight: { blockConfirmations: 6, swarmNetworkId: 5, multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, }, testnet: { blockConfirmations: 6, swarmNetworkId: 10, multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, }, tenderly: { blockConfirmations: 1, swarmNetworkId: 1, multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, }, mainnet: { blockConfirmations: 6, swarmNetworkId: 1, multisig: '0xD5C070FEb5EA883063c183eDFF10BA6836cf9816', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: 2, }, }; diff --git a/src/Redistribution.sol b/src/Redistribution.sol index e168aa05..b6ea568b 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -230,7 +230,6 @@ contract Redistribution is AccessControl, Pausable { error CommitRoundOver(); // Commit phase in this round is over error CommitRoundNotStarted(); // Commit phase in this round has not started yet error NotMatchingOwner(); // Sender of commit is not matching the overlay address - error MustStake2Rounds(); // Before entering the game node must stake 2 rounds prior error NotStaked(); // Node didn't add any staking error WrongPhase(); // Checking in wrong phase, need to check duing claim phase of current round for next round or commit in current round error AlreadyCommitted(); // Node already committed in this round @@ -293,17 +292,12 @@ contract Redistribution is AccessControl, Pausable { uint64 cr = currentRound(); bytes32 _overlay = Stakes.overlayOfAddress(msg.sender); uint256 _stake = Stakes.nodeEffectiveStake(msg.sender); - uint256 _lastUpdate = Stakes.lastUpdatedBlockNumberOfAddress(msg.sender); uint8 _height = Stakes.heightOfAddress(msg.sender); - if (_lastUpdate == 0) { + if (_stake == 0) { revert NotStaked(); } - if (_lastUpdate >= block.number - 2 * ROUND_LENGTH) { - revert MustStake2Rounds(); - } - if (cr > _roundNumber) { revert CommitRoundOver(); } @@ -828,21 +822,17 @@ contract Redistribution is AccessControl, Pausable { * @param _depth The storage depth the applicant intends to report. */ function isParticipatingInUpcomingRound(address _owner, uint8 _depth) public view returns (bool) { - uint256 _lastUpdate = Stakes.lastUpdatedBlockNumberOfAddress(_owner); + uint256 _stake = Stakes.nodeEffectiveStake(_owner); uint8 _depthResponsibility = _depth - Stakes.heightOfAddress(_owner); if (currentPhaseReveal()) { revert WrongPhase(); } - if (_lastUpdate == 0) { + if (_stake == 0) { revert NotStaked(); } - if (_lastUpdate >= block.number - 2 * ROUND_LENGTH) { - revert MustStake2Rounds(); - } - return inProximity(Stakes.overlayOfAddress(_owner), currentRoundAnchor(), _depthResponsibility); } diff --git a/src/Staking.sol b/src/Staking.sol index ae127717..92a8e228 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -4,10 +4,6 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; -interface IPriceOracle { - function currentPrice() external view returns (uint32); -} - /** * @title Staking contract for the Swarm storage incentives * @author The Swarm Authors @@ -17,223 +13,232 @@ interface IPriceOracle { */ contract StakeRegistry is AccessControl, Pausable { - // ----------------------------- State variables ------------------------------ + uint256 private constant ROUND_LENGTH = 152; + uint256 private constant MIN_STAKE = 100000000000000000; + uint256 private constant UPDATE_QUEUE_MAX_LENGTH = 10; + + enum UpdateKind { + CreateDeposit, + AddTokens, + IncreaseHeight, + ChangeOverlay, + WithdrawTokens, + ExitStake + } struct Stake { - // Overlay of the node that is being staked bytes32 overlay; - // Stake balance expressed through price oracle - uint256 committedStake; - // Stake balance expressed in BZZ - uint256 potentialStake; - // Block height the stake was updated, also used as flag to check if the stake is set + uint256 balance; uint256 lastUpdatedBlockNumber; - // Node indicating its increased reserve + uint256 frozenUntilBlock; uint8 height; } - // Associate every stake id with node address data. - mapping(address => Stake) public stakes; + struct StakeState { + bytes32 overlay; + uint256 balance; + uint256 lastUpdatedBlockNumber; + uint256 frozenUntilBlock; + uint8 height; + bool initialized; + } - // Role allowed to freeze and slash entries - bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); + struct ScheduledUpdate { + UpdateKind kind; + uint64 effectiveFromRound; + bytes32 nonce; + uint256 amount; + uint8 height; + } - // Swarm network ID - uint64 NetworkId; + mapping(address => StakeState) private _stakes; + mapping(address => ScheduledUpdate[]) private _updateQueues; + mapping(address => uint256) private _queueHeads; - // The miniumum stake allowed to be staked using the Staking contract. - uint64 private constant MIN_STAKE = 100000000000000000; + bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); - // Address of the staked ERC20 token + uint64 public NetworkId; address public immutable bzzToken; + uint64 public immutable WAIT_BASE; + uint64 public immutable WAIT_OVERLAY_CHANGE; + uint64 public immutable WAIT_WITHDRAWAL; - // The address of the linked PriceOracle contract. - IPriceOracle public OracleContract; - - // ----------------------------- Events ------------------------------ - - /** - * @dev Emitted when a stake is created or updated by `owner` of the `overlay`. - */ - event StakeUpdated( - address indexed owner, - uint256 committedStake, - uint256 potentialStake, - bytes32 overlay, - uint256 lastUpdatedBlock, - uint8 height - ); - - /** - * @dev Emitted when a stake for address `slashed` is slashed by `amount`. - */ + event Deposit(address indexed owner, uint64 registeredFromRound, uint256 amount); + event Withdrawal(address indexed owner, uint64 registeredFromRound, uint256 amount); + event ServiceCommitmentUpdate(address indexed owner, uint64 registeredFromRound, bytes32 overlay, uint8 height); event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); - - /** - * @dev Emitted when a stake for address `frozen` is frozen for `time` blocks. - */ event StakeFrozen(address frozen, bytes32 overlay, uint256 time); - /** - * @dev Emitted when a address changes overlay it uses - */ - event OverlayChanged(address owner, bytes32 overlay); - - /** - * @dev Emitted when a stake for address is withdrawn - */ - event StakeWithdrawn(address node, uint256 amount); - - // ----------------------------- Errors ------------------------------ - - error TransferFailed(); // Used when token transfers fail - error Frozen(); // Used when an action cannot proceed because the overlay is frozen - error Unauthorized(); // Used where only the owner can perform the action - error OnlyRedistributor(); // Used when only the redistributor role is allowed - error OnlyPauser(); // Used when only the pauser role is allowed - error BelowMinimumStake(); // Node participating in game has stake below minimum treshold - error DecreasedCommitment(); // When new commitment would be lower than previous one - - // ----------------------------- CONSTRUCTOR ------------------------------ - - /** - * @param _bzzToken Address of the staked ERC20 token - * @param _NetworkId Swarm network ID - */ - constructor(address _bzzToken, uint64 _NetworkId, address _oracleContract) { + error TransferFailed(); + error Frozen(); + error Unauthorized(); + error OnlyRedistributor(); + error OnlyPauser(); + error BelowMinimumStake(); + error NotStaked(); + error HeightDecreaseNotAllowed(); + error InvalidWithdrawalAmount(); + error UpdateQueueFull(); + + constructor( + address _bzzToken, + uint64 _NetworkId, + uint64 _waitBase, + uint64 _waitOverlayChange, + uint64 _waitWithdrawal + ) { NetworkId = _NetworkId; bzzToken = _bzzToken; - OracleContract = IPriceOracle(_oracleContract); + WAIT_BASE = _waitBase; + WAIT_OVERLAY_CHANGE = _waitOverlayChange; + WAIT_WITHDRAWAL = _waitWithdrawal; _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } - //////////////////////////////////////// - // STATE SETTING // - //////////////////////////////////////// - - /** - * @notice Create a new stake or update an existing one, change overlay of node - * @dev At least `_initialBalancePerChunk*2^depth` number of tokens need to be preapproved for this contract. - * @param _setNonce Nonce that was used for overlay calculation. - * @param _addAmount Deposited amount of ERC20 tokens, equals to added Potential stake value - * @param _height increased reserve by registering the number of doublings - */ function manageStake(bytes32 _setNonce, uint256 _addAmount, uint8 _height) external whenNotPaused { - bytes32 _previousOverlay = stakes[msg.sender].overlay; - uint256 _stakingSet = stakes[msg.sender].lastUpdatedBlockNumber; - bytes32 _newOverlay = keccak256(abi.encodePacked(msg.sender, reverse(NetworkId), _setNonce)); - - // First time adding stake, check the minimum is added, take into account height - if (_addAmount < MIN_STAKE * 2 ** _height && _stakingSet == 0) { - revert BelowMinimumStake(); - } + if (!addressNotFrozen(msg.sender)) revert Frozen(); - if (_stakingSet != 0 && !addressNotFrozen(msg.sender)) revert Frozen(); - // Set current values, used also when changing overlay - uint256 updatedPotentialStake = stakes[msg.sender].potentialStake; - uint256 updatedCommittedStake = stakes[msg.sender].committedStake; - uint256 previousCommittedStake = updatedCommittedStake; + StakeState memory plannedStake = _previewStake(msg.sender, true); + bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); - // Only update stake values if _addAmount is greater than 0 - if (_addAmount > 0) { - updatedPotentialStake = stakes[msg.sender].potentialStake + _addAmount; + if (!plannedStake.initialized || plannedStake.balance == 0) { + if (_addAmount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); - // Calculate new committed stake - uint256 newCommittedStake = updatedPotentialStake / (OracleContract.currentPrice() * 2 ** _height); + _pullTokens(msg.sender, _addAmount); - // Never allow commitment to decrease - if (newCommittedStake < previousCommittedStake) { - revert DecreasedCommitment(); - } + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.CreateDeposit, + WAIT_BASE, + _setNonce, + _addAmount, + _height + ); - updatedCommittedStake = newCommittedStake; + emit Deposit(msg.sender, effectiveFromRound, _addAmount); + emit ServiceCommitmentUpdate(msg.sender, effectiveFromRound, newOverlay, _height); + return; } - stakes[msg.sender] = Stake({ - overlay: _newOverlay, - committedStake: updatedCommittedStake, - potentialStake: updatedPotentialStake, - lastUpdatedBlockNumber: block.number, - height: _height - }); + if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); - // Transfer tokens and emit event that stake has been updated if (_addAmount > 0) { - if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _addAmount)) revert TransferFailed(); - emit StakeUpdated( + _pullTokens(msg.sender, _addAmount); + uint64 depositRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _addAmount, 0); + emit Deposit(msg.sender, depositRound, _addAmount); + } + + if (_height > plannedStake.height) { + uint64 heightRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); + emit ServiceCommitmentUpdate(msg.sender, heightRound, plannedStake.overlay, _height); + } + + if (newOverlay != plannedStake.overlay) { + uint64 overlayRound = _enqueueUpdate( msg.sender, - updatedCommittedStake, - updatedPotentialStake, - _newOverlay, - block.number, - _height + UpdateKind.ChangeOverlay, + WAIT_OVERLAY_CHANGE, + _setNonce, + 0, + plannedStake.height ); + emit ServiceCommitmentUpdate(msg.sender, overlayRound, newOverlay, plannedStake.height); } + } - // Emit overlay change event - if (_previousOverlay != _newOverlay) { - emit OverlayChanged(msg.sender, _newOverlay); - } + function withdraw(uint256 _amount) external whenNotPaused { + if (!addressNotFrozen(msg.sender)) revert Frozen(); + if (_amount == 0) revert InvalidWithdrawalAmount(); + + StakeState memory plannedStake = _previewStake(msg.sender, true); + if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (_amount >= plannedStake.balance) revert BelowMinimumStake(); + if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); + + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.WithdrawTokens, + WAIT_WITHDRAWAL, + 0, + _amount, + 0 + ); + emit Withdrawal(msg.sender, effectiveFromRound, _amount); } - /** - * @dev Withdraw node stake surplus - */ - function withdrawFromStake() external { - uint256 _potentialStake = stakes[msg.sender].potentialStake; - uint256 _surplusStake = _potentialStake - - calculateEffectiveStake(stakes[msg.sender].committedStake, _potentialStake, stakes[msg.sender].height); + function exit() external whenNotPaused { + if (!addressNotFrozen(msg.sender)) revert Frozen(); - if (_surplusStake > 0) { - stakes[msg.sender].potentialStake -= _surplusStake; - if (!ERC20(bzzToken).transfer(msg.sender, _surplusStake)) revert TransferFailed(); - emit StakeWithdrawn(msg.sender, _surplusStake); - } + StakeState memory plannedStake = _previewStake(msg.sender, true); + if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + + uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); + emit Withdrawal(msg.sender, effectiveFromRound, plannedStake.balance); + } + + function applyUpdates(address _owner) public { + _applyReadyUpdates(_owner); } - /** - * @dev Migrate stake only when the staking contract is paused, - * can only be called by the owner of the stake - */ function migrateStake() external whenPaused { - // We take out all the stake so user can migrate stake to other contract - if (lastUpdatedBlockNumberOfAddress(msg.sender) != 0) { - if (!ERC20(bzzToken).transfer(msg.sender, stakes[msg.sender].potentialStake)) revert TransferFailed(); - delete stakes[msg.sender]; + _applyReadyUpdates(msg.sender); + + uint256 payout = _stakes[msg.sender].balance; + ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; + uint256 head = _queueHeads[msg.sender]; + + for (uint256 i = head; i < queue.length; ) { + ScheduledUpdate storage scheduled = queue[i]; + if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { + payout += scheduled.amount; + } + + unchecked { + ++i; + } + } + + delete _stakes[msg.sender]; + delete _updateQueues[msg.sender]; + delete _queueHeads[msg.sender]; + + if (payout > 0) { + if (!ERC20(bzzToken).transfer(msg.sender, payout)) revert TransferFailed(); } } - /** - * @dev Freeze an existing stake, can only be called by the redistributor - * @param _owner the addres selected - * @param _time penalty length in blocknumbers - */ function freezeDeposit(address _owner, uint256 _time) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - if (stakes[_owner].lastUpdatedBlockNumber != 0) { - stakes[_owner].lastUpdatedBlockNumber = block.number + _time; - emit StakeFrozen(_owner, stakes[_owner].overlay, _time); + _applyReadyUpdates(_owner); + + if (_stakes[_owner].initialized) { + _stakes[_owner].frozenUntilBlock = block.number + _time; + emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); } } - /** - * @dev Slash an existing stake, can only be called by the `redistributor` - * @param _owner the _owner adress selected - * @param _amount the amount to be slashed - */ function slashDeposit(address _owner, uint256 _amount) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - if (stakes[_owner].lastUpdatedBlockNumber != 0) { - if (stakes[_owner].potentialStake > _amount) { - stakes[_owner].potentialStake -= _amount; - stakes[_owner].lastUpdatedBlockNumber = block.number; + _applyReadyUpdates(_owner); + + StakeState storage stake = _stakes[_owner]; + bytes32 previousOverlay = stake.overlay; + + if (stake.initialized) { + if (stake.balance > _amount) { + stake.balance -= _amount; + stake.lastUpdatedBlockNumber = block.number; + } else if (_queueLength(_owner) > 0) { + stake.balance = 0; + stake.lastUpdatedBlockNumber = block.number; } else { - delete stakes[_owner]; + delete _stakes[_owner]; } } - emit StakeSlashed(_owner, stakes[_owner].overlay, _amount); + + emit StakeSlashed(_owner, previousOverlay, _amount); } function changeNetworkId(uint64 _NetworkId) external { @@ -241,115 +246,274 @@ contract StakeRegistry is AccessControl, Pausable { NetworkId = _NetworkId; } - /** - * @dev Pause the contract. The contract is provably stopped by renouncing - the pauser role and the admin role after pausing, can only be called by the `PAUSER` - */ function pause() public { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); _pause(); } - /** - * @dev Unpause the contract, can only be called by the pauser when paused - */ function unPause() public { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); _unpause(); } - //////////////////////////////////////// - // STATE READING // - //////////////////////////////////////// - - /** - * @dev Checks to see if `address` is frozen. - * @param _owner owner of staked address - * - * Returns a boolean value indicating whether the operation succeeded. - */ - function addressNotFrozen(address _owner) internal view returns (bool) { - return stakes[_owner].lastUpdatedBlockNumber < block.number; + function stakes(address _owner) public view returns (Stake memory) { + return _toStakeView(_previewStake(_owner, false)); } - /** - * @dev Returns the current `effectiveStake` of `address`. previously usable stake - * @param _owner _owner of node - */ function nodeEffectiveStake(address _owner) public view returns (uint256) { - return - addressNotFrozen(_owner) - ? calculateEffectiveStake( - stakes[_owner].committedStake, - stakes[_owner].potentialStake, - stakes[_owner].height - ) - : 0; - } - - /** - * @dev Check the amount that is possible to withdraw as surplus - */ - function withdrawableStake() public view returns (uint256) { - uint256 _potentialStake = stakes[msg.sender].potentialStake; - return - _potentialStake - - calculateEffectiveStake(stakes[msg.sender].committedStake, _potentialStake, stakes[msg.sender].height); + if (!addressNotFrozen(_owner)) return 0; + + StakeState memory preview = _previewStake(_owner, false); + return preview.initialized ? preview.balance : 0; } - /** - * @dev Returns the `lastUpdatedBlockNumber` of `address`. - */ function lastUpdatedBlockNumberOfAddress(address _owner) public view returns (uint256) { - return stakes[_owner].lastUpdatedBlockNumber; + return _stakes[_owner].initialized ? _stakes[_owner].lastUpdatedBlockNumber : 0; } - /** - * @dev Returns the currently used overlay of the address. - * @param _owner address of node - */ function overlayOfAddress(address _owner) public view returns (bytes32) { - return stakes[_owner].overlay; + StakeState memory preview = _previewStake(_owner, false); + return preview.initialized ? preview.overlay : bytes32(0); } - /** - * @dev Returns the currently height of the address. - * @param _owner address of node - */ function heightOfAddress(address _owner) public view returns (uint8) { - return stakes[_owner].height; + StakeState memory preview = _previewStake(_owner, false); + return preview.initialized ? preview.height : 0; + } + + function currentRound() public view returns (uint64) { + return uint64(block.number / ROUND_LENGTH); + } + + function addressNotFrozen(address _owner) internal view returns (bool) { + StakeState storage stake = _stakes[_owner]; + return !stake.initialized || stake.frozenUntilBlock < block.number; } - function calculateEffectiveStake( - uint256 committedStake, - uint256 potentialStakeBalance, - uint8 height - ) internal view returns (uint256) { - // Calculate the product of committedStake and unitPrice to get price in BZZ - uint256 committedStakeBzz = (2 ** height) * committedStake * OracleContract.currentPrice(); + function _applyReadyUpdates(address _owner) internal { + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + uint64 roundNumber = currentRound(); - // Return the minimum value between committedStakeBzz and potentialStakeBalance - if (committedStakeBzz < potentialStakeBalance) { - return committedStakeBzz; + while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { + _applyStoredUpdate(_owner, queue[head]); + delete queue[head]; + unchecked { + ++head; + } + } + + if (head == queue.length) { + delete _updateQueues[_owner]; + delete _queueHeads[_owner]; } else { - return potentialStakeBalance; + _queueHeads[_owner] = head; + } + } + + function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { + StakeState storage stake = _stakes[_owner]; + + if (scheduled.kind == UpdateKind.CreateDeposit) { + stake.overlay = _deriveOverlay(_owner, scheduled.nonce); + stake.balance = scheduled.amount; + stake.height = scheduled.height; + stake.lastUpdatedBlockNumber = block.number; + stake.initialized = true; + return; } + + if (scheduled.kind == UpdateKind.AddTokens) { + stake.balance += scheduled.amount; + stake.lastUpdatedBlockNumber = block.number; + stake.initialized = true; + return; + } + + if (scheduled.kind == UpdateKind.IncreaseHeight) { + if (stake.initialized && scheduled.height > stake.height) { + stake.height = scheduled.height; + stake.lastUpdatedBlockNumber = block.number; + } + return; + } + + if (scheduled.kind == UpdateKind.ChangeOverlay) { + if (stake.initialized) { + stake.overlay = _deriveOverlay(_owner, scheduled.nonce); + stake.lastUpdatedBlockNumber = block.number; + } + return; + } + + if (scheduled.kind == UpdateKind.WithdrawTokens) { + if (stake.initialized) { + if (scheduled.amount >= stake.balance) { + stake.balance = 0; + } else { + stake.balance -= scheduled.amount; + } + stake.lastUpdatedBlockNumber = block.number; + + if (!ERC20(bzzToken).transfer(_owner, scheduled.amount)) revert TransferFailed(); + } + return; + } + + if (scheduled.kind == UpdateKind.ExitStake) { + uint256 balance = stake.balance; + delete _stakes[_owner]; + if (balance > 0 && !ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); + } + } + + function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (StakeState memory preview) { + preview = _stakes[_owner]; + + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + uint64 roundNumber = currentRound(); + + for (uint256 i = head; i < queue.length; ) { + ScheduledUpdate storage scheduled = queue[i]; + if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { + break; + } + + preview = _applyPreviewUpdate(_owner, preview, scheduled); + + unchecked { + ++i; + } + } + } + + function _applyPreviewUpdate( + address _owner, + StakeState memory preview, + ScheduledUpdate storage scheduled + ) internal view returns (StakeState memory) { + if (scheduled.kind == UpdateKind.CreateDeposit) { + preview.overlay = _deriveOverlay(_owner, scheduled.nonce); + preview.balance = scheduled.amount; + preview.height = scheduled.height; + preview.lastUpdatedBlockNumber = block.number; + preview.initialized = true; + return preview; + } + + if (scheduled.kind == UpdateKind.AddTokens) { + preview.balance += scheduled.amount; + preview.lastUpdatedBlockNumber = block.number; + preview.initialized = true; + return preview; + } + + if (scheduled.kind == UpdateKind.IncreaseHeight) { + if (preview.initialized && scheduled.height > preview.height) { + preview.height = scheduled.height; + preview.lastUpdatedBlockNumber = block.number; + } + return preview; + } + + if (scheduled.kind == UpdateKind.ChangeOverlay) { + if (preview.initialized) { + preview.overlay = _deriveOverlay(_owner, scheduled.nonce); + preview.lastUpdatedBlockNumber = block.number; + } + return preview; + } + + if (scheduled.kind == UpdateKind.WithdrawTokens) { + if (preview.initialized) { + if (scheduled.amount >= preview.balance) { + preview.balance = 0; + } else { + preview.balance -= scheduled.amount; + } + preview.lastUpdatedBlockNumber = block.number; + } + return preview; + } + + if (scheduled.kind == UpdateKind.ExitStake) { + delete preview; + } + + return preview; + } + + function _enqueueUpdate( + address _owner, + UpdateKind _kind, + uint64 _minimumWait, + bytes32 _nonce, + uint256 _amount, + uint8 _height + ) internal returns (uint64 effectiveFromRound) { + if (_queueLength(_owner) >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(); + + uint64 candidateRound = currentRound() + _minimumWait; + uint64 lastRound = _lastScheduledRound(_owner); + effectiveFromRound = candidateRound > lastRound ? candidateRound : lastRound; + + _updateQueues[_owner].push( + ScheduledUpdate({ + kind: _kind, + effectiveFromRound: effectiveFromRound, + nonce: _nonce, + amount: _amount, + height: _height + }) + ); + } + + function _lastScheduledRound(address _owner) internal view returns (uint64) { + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + if (_queueHeads[_owner] == queue.length) { + return 0; + } + return queue[queue.length - 1].effectiveFromRound; + } + + function _queueLength(address _owner) internal view returns (uint256) { + return _updateQueues[_owner].length - _queueHeads[_owner]; + } + + function _pullTokens(address _owner, uint256 _amount) internal { + if (_amount == 0) revert InvalidWithdrawalAmount(); + if (!ERC20(bzzToken).transferFrom(_owner, address(this), _amount)) revert TransferFailed(); + } + + function _minimumStakeForHeight(uint8 _height) internal pure returns (uint256) { + return MIN_STAKE * (2 ** _height); + } + + function _deriveOverlay(address _owner, bytes32 _setNonce) internal view returns (bytes32) { + return keccak256(abi.encodePacked(_owner, reverse(NetworkId), _setNonce)); + } + + function _toStakeView(StakeState memory _stake) internal pure returns (Stake memory) { + if (!_stake.initialized) { + return Stake({overlay: 0, balance: 0, lastUpdatedBlockNumber: 0, frozenUntilBlock: 0, height: 0}); + } + + return + Stake({ + overlay: _stake.overlay, + balance: _stake.balance, + lastUpdatedBlockNumber: _stake.lastUpdatedBlockNumber, + frozenUntilBlock: _stake.frozenUntilBlock, + height: _stake.height + }); } - /** - * @dev Please both Endians 🥚. - * @param input Eth address used for overlay calculation. - */ function reverse(uint64 input) internal pure returns (uint64 v) { v = input; - // swap bytes v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); - - // swap 2-byte long pairs v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); - - // swap 4-byte long pairs v = (v >> 32) | (v << 32); } } diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index b6e00995..8e1a8a75 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -59,7 +59,7 @@ const depth_0 = '0x06'; const reveal_nonce_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const stakeAmount_0 = '100000000000000000'; const stakeAmount_0_n_2 = '400000000000000000'; -const effectiveStakeAmount_0 = '99999999999984000'; +const effectiveStakeAmount_0 = '100000000000000000'; const obfuscatedHash_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const height_0 = 0; const height_0_n_2 = 2; @@ -83,7 +83,7 @@ const height_1 = 0; let node_2: string; const overlay_2 = '0xa40db58e368ea6856a24c0264ebd73b049f3dc1c2347b1babc901d3e09842dec'; const stakeAmount_2 = '100000000000000000'; -const effectiveStakeAmount_2 = '99999999999984000'; +const effectiveStakeAmount_2 = '100000000000000000'; const effectiveStakeAmount_2_n_2 = '100000000000000000'; const nonce_2 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const hash_2 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; @@ -112,7 +112,7 @@ const height_4 = 0; let node_5: string; const overlay_5 = '0x676720d79d609ed462fadf6f14eb1bf9ec1a90999dd45a671d79a89c7b5ac9d8'; const stakeAmount_5 = '100000000000000000'; -const effectiveStakeAmount_5 = '99999999999984000'; +const effectiveStakeAmount_5 = '100000000000000000'; const nonce_5 = '0x0000000000000000000000000000000000000000000000000000000000003ba6'; const reveal_nonce_5 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const { depth: depth_5, hash: hash_5 } = node5_proof1; @@ -173,7 +173,6 @@ const errors = { commit: { notOwner: 'NotMatchingOwner()', notStaked: 'NotStaked()', - mustStake2Rounds: 'MustStake2Rounds()', alreadyCommitted: 'AlreadyCommitted()', }, reveal: { @@ -259,7 +258,7 @@ describe('Redistribution', function () { const r_node_0 = await ethers.getContract('Redistribution', node_0); await expect(r_node_0['isParticipatingInUpcomingRound(address,uint8)'](node_0, depth_0)).to.be.revertedWith( - errors.commit.mustStake2Rounds + errors.commit.notStaked ); }); @@ -272,7 +271,7 @@ describe('Redistribution', function () { const r_node_0 = await ethers.getContract('Redistribution', node_0); await expect(r_node_0['isParticipatingInUpcomingRound(address,uint8)'](node_0, depth_0)).to.be.revertedWith( - errors.commit.mustStake2Rounds + errors.commit.notStaked ); }); @@ -294,7 +293,7 @@ describe('Redistribution', function () { const r_node_0 = await ethers.getContract('Redistribution', node_0); await expect(r_node_0['isParticipatingInUpcomingRound(address,uint8)'](node_0, depth_0)).to.be.revertedWith( - errors.commit.mustStake2Rounds + errors.commit.notStaked ); }); }); @@ -781,7 +780,7 @@ describe('Redistribution', function () { await expect(r_node_2.reveal(depth_2, hash_2, reveal_nonce_2)) .to.emit(redistribution, 'Revealed') - .withArgs(currentRound, overlay_2, effectiveStakeAmount_2, '6399999999998976000', hash_2, parseInt(depth_2)); + .withArgs(currentRound, overlay_2, effectiveStakeAmount_2, '6400000000000000000', hash_2, parseInt(depth_2)); }); }); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 10eb3f12..38c39d04 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -1,23 +1,29 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts } from 'hardhat'; import { Contract } from 'ethers'; -import { mineNBlocks, getBlockNumber } from './util/tools'; +import { mineNBlocks } from './util/tools'; const { read, execute } = deployments; + let deployer: string; let redistributor: string; let pauser: string; -const roundLength = 152; +let staker_0: string; +let staker_1: string; +const roundLength = 152; const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000'; const freezeTime = 3; const errors = { deposit: { noBalance: 'ERC20: insufficient allowance', - noZeroAddress: 'owner cannot be the zero address', - onlyOwner: 'Unauthorized()', belowMinimum: 'BelowMinimumStake()', + heightDecrease: 'HeightDecreaseNotAllowed()', + }, + withdraw: { + invalid: 'InvalidWithdrawalAmount()', + notStaked: 'NotStaked()', }, slash: { noRole: 'OnlyRedistributor()', @@ -32,39 +38,27 @@ const errors = { notCurrentlyPaused: 'Pausable: not paused', onlyPauseCanUnPause: 'OnlyPauser()', }, - commitment: { - decrease: 'DecreasedCommitment()', - }, }; -let staker_0: string; const overlay_0 = '0xa602fa47b3e8ce39ffc2017ad9069ff95eb58c051b1cfa2b0d86bc44a5433733'; +const overlay_1 = '0xa6f955c72d7053f96b91b5470491a0c732b0175af56dcfb7a604b82b16719406'; +const overlay_1_n_25 = '0x676766bbae530fd0483e4734e800569c95929b707b9c50f8717dc99f9f91e915'; +const nonce_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; +const nonce_1 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; +const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; const stakeAmount_0 = '100000000000000000'; +const doubleStakeAmount_0 = '200000000000000000'; +const stakeAmount_1 = '100000000000000000'; const updateStakeAmount_0 = '633633'; const updatedStakeAmount_0 = '100000000000633633'; -const committedStakeAmount_0 = '4166666666666'; -const updatedCommittedStakeAmount_0 = '4166666666693'; -const doubled_stakeAmount_0 = '200000000000000000'; -const doubled_committedStakeAmount_0 = '8333333333333'; -const nonce_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; +const withdrawAmount = '100000000000000000'; +const slashAmount = '50000000000000000'; +const partialSlashBalance = '50000000000000000'; const height_0 = 0; const height_0_n_1 = 1; - -let staker_1: string; -const overlay_1 = '0xa6f955c72d7053f96b91b5470491a0c732b0175af56dcfb7a604b82b16719406'; -const overlay_1_n_25 = '0x676766bbae530fd0483e4734e800569c95929b707b9c50f8717dc99f9f91e915'; -const stakeAmount_1 = '100000000000000000'; -const stakeAmount_1_n = '100000000000000000'; -const doubled_stakeAmount_1 = '200000000000000000'; -const nonce_1 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; const height_1 = 0; const height_1_n_1 = 1; -const zeroStake = '0'; -const zeroAmount = '0'; - -// Before the tests, set named accounts and read deployments. before(async function () { const namedAccounts = await getNamedAccounts(); deployer = namedAccounts.deployer; @@ -84,627 +78,236 @@ async function mintAndApprove(payee: string, beneficiary: string, transferAmount await payeeTokenInstance.approve(beneficiary, transferAmount); } -describe('Staking', function () { - describe('when deploying contract', function () { - beforeEach(async function () { - await deployments.fixture(); - stakeRegistry = await ethers.getContract('StakeRegistry'); - - const pauserRole = await read('StakeRegistry', 'DEFAULT_ADMIN_ROLE'); - await execute('StakeRegistry', { from: deployer }, 'grantRole', pauserRole, pauser); - }); - - it('should deploy StakeRegistry', async function () { - expect(stakeRegistry.address).to.be.properAddress; - }); - - it('should set the pauser role', async function () { - const pauserRole = await stakeRegistry.DEFAULT_ADMIN_ROLE(); - expect(await stakeRegistry.hasRole(pauserRole, pauser)).to.be.true; - }); - - it('should set the redistributor role', async function () { - const redistributorRole = await stakeRegistry.REDISTRIBUTOR_ROLE(); - const redistribution = await ethers.getContract('Redistribution'); - expect(await stakeRegistry.hasRole(redistributorRole, redistribution.address)).to.be.true; - }); - - it('should set the correct token', async function () { - const token = await ethers.getContract('TestToken'); - expect(await stakeRegistry.bzzToken()).to.be.eq(token.address); - }); - }); - - describe('depositing stake', function () { - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry', staker_0); - }); - - it('should not deposit stake if funds are unavailable', async function () { - await expect(stakeRegistry.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.deposit.noBalance - ); - }); - - it('should deposit stake correctly if funds are available', async function () { - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); - - const updatedBlockNumber = (await getBlockNumber()) + 3; +async function advanceRounds(rounds = 2) { + await mineNBlocks(roundLength * rounds); +} - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); +async function activateStake(contract: Contract, owner: string, nonce: string, amount: string, height: number) { + await mintAndApprove(owner, contract.address, amount); + await contract.manageStake(nonce, amount, height); + await advanceRounds(); + await contract.applyUpdates(owner); +} - await expect(sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0)) - .to.emit(stakeRegistry, 'StakeUpdated') - .withArgs(staker_0, committedStakeAmount_0, stakeAmount_0, overlay_0, updatedBlockNumber, height_0); +describe('Staking', function () { + beforeEach(async function () { + await deployments.fixture(); + token = await ethers.getContract('TestToken', deployer); + stakeRegistry = await ethers.getContract('StakeRegistry'); - expect(await token.balanceOf(staker_0)).to.be.eq(0); + const pauserRole = await read('StakeRegistry', 'DEFAULT_ADMIN_ROLE'); + await execute('StakeRegistry', { from: deployer }, 'grantRole', pauserRole, pauser); + }); - const staked = await sr_staker_0.stakes(staker_0); + it('should deploy StakeRegistry with queue wait parameters', async function () { + expect(stakeRegistry.address).to.be.properAddress; + expect(await stakeRegistry.WAIT_BASE()).to.be.eq(2); + expect(await stakeRegistry.WAIT_OVERLAY_CHANGE()).to.be.eq(2); + expect(await stakeRegistry.WAIT_WITHDRAWAL()).to.be.eq(2); + }); - expect(staked.overlay).to.be.eq(overlay_0); - expect(staked.potentialStake).to.be.eq(stakeAmount_0); - expect(staked.committedStake).to.be.eq(committedStakeAmount_0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); + it('should schedule a new deposit and activate it after the base delay', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const currentRound = await srStaker0.currentRound(); - expect(await token.balanceOf(stakeRegistry.address)).to.be.eq(stakeAmount_0); - }); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)) + .to.emit(srStaker0, 'Deposit') + .withArgs(staker_0, currentRound.add(2), stakeAmount_0); - it('should update stake correctly if funds are available', async function () { - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await advanceRounds(); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + }); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + it('should keep a scheduled deposit inactive until the delay elapses', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const currentRound = await srStaker0.currentRound(); - sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)) + .to.emit(srStaker0, 'ServiceCommitmentUpdate') + .withArgs(staker_0, currentRound.add(2), overlay_0, height_0); - const lastUpdatedBlockNumber = (await getBlockNumber()) + 3; + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await srStaker0.overlayOfAddress(staker_0)).to.be.eq(zeroBytes32); - await mintAndApprove(staker_0, stakeRegistry.address, updateStakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(updateStakeAmount_0); + await advanceRounds(); - await expect(sr_staker_0.manageStake(nonce_0, updateStakeAmount_0, zeroAmount)) - .to.emit(stakeRegistry, 'StakeUpdated') - .withArgs( - staker_0, - updatedCommittedStakeAmount_0, - updatedStakeAmount_0, - overlay_0, - lastUpdatedBlockNumber + 1, - height_0 - ); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + expect(await srStaker0.overlayOfAddress(staker_0)).to.be.eq(overlay_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); - const staked = await stakeRegistry.stakes(staker_0); - expect(staked.overlay).to.be.eq(overlay_0); - expect(staked.potentialStake).to.be.eq(updatedStakeAmount_0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(lastUpdatedBlockNumber + 1); - expect(await token.balanceOf(stakeRegistry.address)).to.be.eq(updatedStakeAmount_0); - }); + await srStaker0.applyUpdates(staker_0); + expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.not.be.eq(0); }); - describe('slashing stake', function () { - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); - - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + it('should not allow first stake below minimum for the requested height', async function () { + const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); + await mintAndApprove(staker_1, srStaker1.address, stakeAmount_1); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); - - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); - await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - }); - - it('should not slash staked deposit without redistributor role', async function () { - const stakeRegistry = await ethers.getContract('StakeRegistry', staker_0); - await expect(stakeRegistry.slashDeposit(staker_0, stakeAmount_0)).to.be.revertedWith(errors.slash.noRole); - }); - - it('should slash staked deposit with redistributor role', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(srStaker1.manageStake(nonce_1, stakeAmount_1, height_1_n_1)).to.be.revertedWith( + errors.deposit.belowMinimum + ); + }); - await stakeRegistryRedistributor.slashDeposit(staker_0, stakeAmount_0); + it('should schedule top ups and height increases without changing the active stake immediately', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - const staked = await stakeRegistry.stakes(staker_0); - expect(staked.overlay).to.be.eq(zeroBytes32); - expect(staked.potentialStake).to.be.eq(0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(0); - }); + await mintAndApprove(staker_0, srStaker0.address, updateStakeAmount_0); + await srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0_n_1); - it('should restake slashed deposit', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0); - await stakeRegistryRedistributor.slashDeposit(staker_0, stakeAmount_0); + await advanceRounds(); - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(updatedStakeAmount_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + }); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + it('should schedule overlay changes and expose them after the overlay delay', async function () { + const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); + await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); - const lastUpdatedBlockNumber = await getBlockNumber(); - const staked = await stakeRegistry.stakes(staker_0); - expect(staked.overlay).to.be.eq(overlay_0); + await srStaker1.manageStake(nonce_1_n_25, 0, height_1); + expect(await srStaker1.overlayOfAddress(staker_1)).to.be.eq(overlay_1); - expect(staked.potentialStake).to.be.eq(stakeAmount_0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(lastUpdatedBlockNumber); - }); + await advanceRounds(); + expect(await srStaker1.overlayOfAddress(staker_1)).to.be.eq(overlay_1_n_25); }); - describe('freezing stake', function () { - let sr_staker_0: Contract; + it('should reject height decreases on active stake', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0_n_1); - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); - - sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); - - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + await expect(srStaker0.manageStake(nonce_0, 0, height_0)).to.be.revertedWith(errors.deposit.heightDecrease); + }); - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); - await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - }); + it('should keep effective stake equal to balance after oracle price changes', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - it('should not freeze staked deposit without redistributor role', async function () { - const stakeRegistryStaker1 = await ethers.getContract('StakeRegistry', staker_0); - await expect(stakeRegistryStaker1.freezeDeposit(staker_0, freezeTime)).to.be.revertedWith(errors.freeze.noRole); - }); + const priceOracle = await ethers.getContract('PriceOracle', deployer); + await priceOracle.setPrice(24000); + await mineNBlocks(1); - it('should freeze staked deposit with redistributor role', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + }); - await expect(stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime)) - .to.emit(stakeRegistry, 'StakeFrozen') - .withArgs(staker_0, overlay_0, freezeTime); + it('should schedule withdrawals and transfer tokens on applyUpdates', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); - const staked = await stakeRegistryRedistributor.stakes(staker_0); - const updatedBlockNumber = (await getBlockNumber()) + 3; + await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'Withdrawal'); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - }); + await advanceRounds(); - it('should not allow update of a frozen staked deposit', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); - await stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); - const staked = await stakeRegistryRedistributor.stakes(staker_0); - const updatedBlockNumber = (await getBlockNumber()) + 3; + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + }); - expect(staked.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); + it('should schedule exits and clear the stake on applyUpdates', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await expect(sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.freeze.currentlyFrozen - ); + await expect(srStaker0.exit()).to.emit(srStaker0, 'Withdrawal'); - mineNBlocks(3); + await advanceRounds(); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); - const newUpdatedBlockNumber = (await getBlockNumber()) + 2; - await expect(sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0)) - .to.emit(stakeRegistry, 'StakeUpdated') - .withArgs( - staker_0, - doubled_committedStakeAmount_0, - doubled_stakeAmount_0, - overlay_0, - newUpdatedBlockNumber, - height_0 - ); - }); + await srStaker0.applyUpdates(staker_0); + const stakedAfter = await srStaker0.stakes(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect(stakedAfter.overlay).to.be.eq(zeroBytes32); + expect(stakedAfter.balance).to.be.eq(0); + expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); }); - describe('pause contract', function () { - let sr_staker_0: Contract; - - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); - - sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); - - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); - - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const pauserRole = await stakeRegistryDeployer.DEFAULT_ADMIN_ROLE(); - await stakeRegistryDeployer.grantRole(pauserRole, pauser); - }); - - it('should not pause contract without pauser role', async function () { - const stakeRegistryStaker1 = await ethers.getContract('StakeRegistry', staker_0); - await expect(stakeRegistryStaker1.pause()).to.be.revertedWith(errors.pause.noRole); - }); - - it('should pause contract with pauser role', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); - - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await expect(stakeRegistry.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.pause.currentlyPaused - ); - }); - - it('should not unpause contract without pauser role', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); - - const stakeRegistryStaker1 = await ethers.getContract('StakeRegistry', staker_0); - await expect(stakeRegistryStaker1.unPause()).to.be.revertedWith(errors.pause.onlyPauseCanUnPause); - }); - - it('should not allow staking while paused', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); - - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await expect(stakeRegistry.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.pause.currentlyPaused - ); - }); - - it('should allow staking once unpaused', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); - - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - - await expect(sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.pause.currentlyPaused - ); - - await stakeRegistryPauser.unPause(); - - const newUpdatedBlockNumber = (await getBlockNumber()) + 3; - await mintAndApprove(staker_0, stakeRegistry.address, updateStakeAmount_0); - await expect(sr_staker_0.manageStake(nonce_0, updateStakeAmount_0, height_0)) - .to.emit(stakeRegistry, 'StakeUpdated') - .withArgs( - staker_0, - updatedCommittedStakeAmount_0, - updatedStakeAmount_0, - overlay_0, - newUpdatedBlockNumber, - height_0 - ); - }); + it('should not allow invalid withdrawals', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await expect(srStaker0.withdraw(0)).to.be.revertedWith(errors.withdraw.invalid); + await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); }); - describe('stake surplus withdrawl and stake migrate', function () { - let sr_staker_0: Contract; - let sr_staker_1: Contract; - let updatedBlockNumber: number; - - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); - sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); - sr_staker_1 = await ethers.getContract('StakeRegistry', staker_1); - const priceOracle = await ethers.getContract('PriceOracle', deployer); - - // Bump up the price so we can test surplus withdrawls - await priceOracle.setPrice(32000); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); - - updatedBlockNumber = await getBlockNumber(); - - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const pauserRole = await stakeRegistryDeployer.DEFAULT_ADMIN_ROLE(); - await stakeRegistryDeployer.grantRole(pauserRole, pauser); - }); - - it('should not allow stake migration while unpaused', async function () { - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); - await expect(sr_staker_0.migrateStake()).to.be.revertedWith(errors.pause.notCurrentlyPaused); - }); - - it('should allow stake migration while paused', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + it('should freeze active stake and block mutations until the freeze expires', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - await sr_staker_0.migrateStake(); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime)) + .to.emit(srStaker0, 'StakeFrozen') + .withArgs(staker_0, overlay_0, freezeTime); - const staked_after = await sr_staker_0.stakes(staker_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); - expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + await mintAndApprove(staker_0, srStaker0.address, updateStakeAmount_0); + await expect(srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0)).to.be.revertedWith( + errors.freeze.currentlyFrozen + ); - expect(staked_after.overlay).to.be.eq(zeroBytes32); - expect(staked_after.potentialStake).to.be.eq(zeroStake); - expect(staked_after.lastUpdatedBlockNumber).to.be.eq(0); - - await stakeRegistryPauser.unPause(); - }); - - it('should not allow deposit stake below minimum', async function () { - await mintAndApprove(staker_1, stakeRegistry.address, stakeAmount_1_n); - await expect(sr_staker_1.manageStake(nonce_1, stakeAmount_1_n, height_1_n_1)).to.be.revertedWith( - errors.deposit.belowMinimum - ); - }); - - it('should make stake surplus withdrawal', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - const priceOracle = await ethers.getContract('PriceOracle', deployer); - - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - // Check that balance of wallet is 0 in the begining and lower the price - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); - await priceOracle.setPrice(24000); - - await sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_0); - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - - expect(staked_before.potentialStake.gt(staked_after.potentialStake)).to.be.true; - expect(staked_after.potentialStake.toString()).to.be.eq(effectiveStake); - expect(await token.balanceOf(staker_0)).to.not.eq(zeroAmount); - }); - - it('should make stake surplus withdrawal when height increases', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - const priceOracle = await ethers.getContract('PriceOracle', deployer); - - await priceOracle.setPrice(24000); - - // We are doubling here as we are adding another "amount" with another stakeAmount_0 - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0_n_1); - const staked_after = await sr_staker_0.stakes(staker_0); - - expect(staked_after.overlay).to.be.eq(overlay_0); - expect(staked_after.potentialStake).to.be.eq(doubled_stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - // Check that balance of wallet is 0 in the begining - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); - - await sr_staker_0.withdrawFromStake(); - - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - const tokenBalance = (await token.balanceOf(staker_0)).toString(); - const potentialStakeBalance = staked_after.potentialStake.toString(); + await mineNBlocks(freezeTime + 1); + await expect(srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0)).to.not.be.reverted; + }); - expect(staked_after.potentialStake.gt(staked_before.potentialStake)).to.be.true; - expect(String(potentialStakeBalance - tokenBalance)).to.be.eq(effectiveStake); - expect(tokenBalance).to.not.eq(zeroAmount); - }); + it('should slash active stake balances', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - it('should make stake surplus withdrawal when height increases and then decreases', async function () { - const priceOracle = await ethers.getContract('PriceOracle', deployer); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - await priceOracle.setPrice(24000); - const price = await priceOracle.currentPrice(); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(stakeRegistryRedistributor.slashDeposit(staker_0, slashAmount)) + .to.emit(srStaker0, 'StakeSlashed') + .withArgs(staker_0, overlay_0, slashAmount); - // We are doubling here as we are adding another "amount" with another stakeAmount_0 - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0_n_1); - const staked_before = await sr_staker_0.stakes(staker_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(partialSlashBalance); - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(doubled_stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); + await stakeRegistryRedistributor.slashDeposit(staker_0, partialSlashBalance); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); + expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); + }); - // Mine 2 rounds so that values are valid, before that effectiveStake is zero as nodes cant play - await mineNBlocks(roundLength * 2); - const withdrawbleStakeBefore = await sr_staker_0.withdrawableStake(); + it('should not allow stake migration while unpaused and should include queued deposits when paused', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await srStaker0.manageStake(nonce_0, stakeAmount_0, height_0); - // We are lowering height to 0 and again mining 2 rounds so values are valid - await sr_staker_0.manageStake(nonce_0, 0, height_0); - await mineNBlocks(roundLength * 2); - const staked_after = await sr_staker_0.stakes(staker_0); - // await priceOracle.setPrice(24000); + await expect(srStaker0.migrateStake()).to.be.revertedWith(errors.pause.notCurrentlyPaused); - const withdrawbleStakeAfter = await sr_staker_0.withdrawableStake(); - expect(withdrawbleStakeAfter.gt(withdrawbleStakeBefore)).to.be.true; - await sr_staker_0.withdrawFromStake(); + const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); + await stakeRegistryPauser.pause(); + await srStaker0.migrateStake(); - const potentialStakeBalance = staked_after.potentialStake.toString(); - const tokenBalance = await token.balanceOf(staker_0); - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - - expect(String(potentialStakeBalance - tokenBalance)).to.be.eq(effectiveStake); - expect(tokenBalance).to.not.eq(zeroAmount); - }); - - it('should make stake surplus withdrawal and not withdraw again after', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - const priceOracle = await ethers.getContract('PriceOracle', deployer); - - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - // Check that balance of wallet is 0 in the begining and lower the price - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); - await priceOracle.setPrice(24000); - - await sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_0); - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - - expect(staked_before.potentialStake.gt(staked_after.potentialStake)).to.be.true; - expect(staked_after.potentialStake.toString()).to.be.eq(effectiveStake); - expect(await token.balanceOf(staker_0)).to.not.eq(zeroAmount); - - // Check repeated withdrawl and that it stays the same - await sr_staker_0.withdrawFromStake(); - await sr_staker_0.withdrawFromStake(); - expect(await token.balanceOf(staker_0)).to.eq('25000000000000000'); - - const effectiveStakeRepeated = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - const staked_repeated = await sr_staker_0.stakes(staker_0); - expect(staked_repeated.potentialStake.toString()).to.be.eq(effectiveStakeRepeated); - }); - - it('should not make stake surplus withdrawal when price from oracle stays the same', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - // Check that balance of wallet is 0 in the begining and lower the price - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); - - await sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_0); - - expect(staked_before.potentialStake).to.be.eq(staked_after.potentialStake); - expect(await token.balanceOf(staker_0)).to.eq(zeroAmount); - }); - - it('should not make stake surplus withdrawal when price from oracle is higher', async function () { - const staked_before = await sr_staker_0.stakes(staker_0); - const priceOracle = await ethers.getContract('PriceOracle', deployer); - - expect(staked_before.overlay).to.be.eq(overlay_0); - expect(staked_before.potentialStake).to.be.eq(stakeAmount_0); - expect(staked_before.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - - // Check that balance of wallet is 0 in the begining and lower the price - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); - await priceOracle.setPrice(44000); - - await sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_0); - - expect(staked_before.potentialStake).to.be.eq(staked_after.potentialStake); - expect(await token.balanceOf(staker_0)).to.eq(zeroAmount); - }); - - it('should check withdrawable stake of node if we increse and decrease height', async function () { - // Situation where we have 10 BZZ, then change height to 1 and add 10 more BZZ, then lower height to 0 and withdraw 10 - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0_n_1); - await mineNBlocks(roundLength * 2); - - // We should not be able to withdraw anything - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - const withdrawableStakeBefore = (await sr_staker_0.withdrawableStake()).toString(); - expect(withdrawableStakeBefore).to.be.eq('0'); - - // We should be able to withdraw inital stake of 10 BZZ - await sr_staker_0.manageStake(nonce_0, 0, height_0); - await mineNBlocks(roundLength * 2); - const withdrawableStakeAfter = (await sr_staker_0.withdrawableStake()).toString(); - expect(withdrawableStakeAfter).to.be.eq(stakeAmount_0); - }); - - it('should check withdrawable stake of node if we decrease height, height starting from 1 ', async function () { - // Situation where we have 20 BZZ and 1 height, then change back to 0, and withdrawl should be 10 BZZ - await mintAndApprove(staker_1, stakeRegistry.address, doubled_stakeAmount_1); - await sr_staker_1.manageStake(nonce_1, doubled_stakeAmount_1, height_1_n_1); - await mineNBlocks(roundLength * 2); - const withdrawableStakeBefore1 = (await sr_staker_1.withdrawableStake()).toString(); - expect(withdrawableStakeBefore1).to.be.eq('0'); - - // Decrease height and check withdrawable stake, should be 10 BZZ - await sr_staker_1.manageStake(nonce_1, 0, height_1); - await mineNBlocks(roundLength * 2); - const withdrawableStakeAfter2 = (await sr_staker_1.withdrawableStake()).toString(); - expect(withdrawableStakeAfter2).to.be.eq(stakeAmount_1); - }); - - it('should check withdrawable stake of node if we increrase then, decrease height, height starting from 0', async function () { - // Situation where we have 20 BZZ and 1 height, then change back to 0, and withdrawl should be 10 BZZ - await mintAndApprove(staker_1, stakeRegistry.address, doubled_stakeAmount_1); - await sr_staker_1.manageStake(nonce_1, doubled_stakeAmount_1, height_1); - await mineNBlocks(roundLength * 2); - const withdrawableStakeBefore1 = (await sr_staker_1.withdrawableStake()).toString(); - expect(withdrawableStakeBefore1).to.be.eq('0'); - - // Increase height and check withdrawable stake, should be still 0 - await sr_staker_1.manageStake(nonce_1, 0, height_1_n_1); - const withdrawableStakeAfter1 = (await sr_staker_1.withdrawableStake()).toString(); - expect(withdrawableStakeAfter1).to.be.eq('0'); - - // Decrease height and check withdrawable stake, should be 0 BZZ - await sr_staker_1.manageStake(nonce_1, 0, height_1); - await mineNBlocks(roundLength * 2); - const withdrawableStakeAfter2 = (await sr_staker_1.withdrawableStake()).toString(); - expect(withdrawableStakeAfter2).to.be.eq('0'); - }); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); }); - describe('change overlay hex', function () { - let sr_staker_1: Contract; - - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - sr_staker_1 = await ethers.getContract('StakeRegistry', staker_1); - - await mintAndApprove(staker_1, sr_staker_1.address, stakeAmount_1); - await sr_staker_1.manageStake(nonce_1, stakeAmount_1, height_1); - }); - - it('should change overlay hex', async function () { - const current_overlay = await sr_staker_1.overlayOfAddress(staker_1); - await sr_staker_1.manageStake(nonce_1_n_25, 0, 0); - const new_overlay = await sr_staker_1.overlayOfAddress(staker_1); - expect(new_overlay).to.not.eq(current_overlay); - }); - - it('should match new overlay hex', async function () { - await sr_staker_1.manageStake(nonce_1_n_25, zeroStake, height_1); - const new_overlay = await sr_staker_1.overlayOfAddress(staker_1); - expect(new_overlay).to.be.eq(overlay_1_n_25); - }); - - it('should match old overlay hex after double change', async function () { - await sr_staker_1.manageStake(nonce_1_n_25, zeroStake, height_1); - const new_overlay = await sr_staker_1.overlayOfAddress(staker_1); - expect(new_overlay).to.be.eq(overlay_1_n_25); - - await sr_staker_1.manageStake(nonce_1, zeroStake, height_1); - const old_overlay = await sr_staker_1.overlayOfAddress(staker_1); - expect(old_overlay).to.be.eq(overlay_1); - }); - }); + it('should not allow staking while paused and should allow it again after unpause', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); + + await stakeRegistryPauser.pause(); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( + errors.pause.currentlyPaused + ); - describe('commitment protection', function () { - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry', staker_0); - - // Set up initial stake with height 0 (lower height) - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await stakeRegistry.manageStake(nonce_0, stakeAmount_0, height_0); - }); - - it('should prevent decreasing commitment by increasing height', async function () { - // Try to manipulate the committed stake by increasing the height parameter - // This will decrease the committedStake value because of the larger divisor - await mintAndApprove(staker_0, stakeRegistry.address, updateStakeAmount_0); - - // This would decrease the committedStake due to larger height divisor - await expect(stakeRegistry.manageStake(nonce_0, updateStakeAmount_0, height_0_n_1)).to.be.revertedWith( - errors.commitment.decrease - ); - }); + await stakeRegistryPauser.unPause(); + await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; }); }); diff --git a/test/Stats.test.ts b/test/Stats.test.ts index 1ecfa7d0..4d467162 100644 --- a/test/Stats.test.ts +++ b/test/Stats.test.ts @@ -154,7 +154,7 @@ describe('Stats', async function () { this.timeout(120000); const allowed_variance = 0.035; const stakes = ['100000000000000000', '300000000000000000']; - const effectiveStakes = ['99999999999984000', '300000000000000000']; + const effectiveStakes = ['100000000000000000', '300000000000000000']; const nodes = [others[0], others[1]]; const dist = await nPlayerGames(nodes, stakes, effectiveStakes, trials); From 8aa960c61b374ba65d29387555f6671ac32aac03 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 13 Apr 2026 23:18:09 +0200 Subject: [PATCH 02/58] decouple main function into separate methods --- src/Staking.sol | 104 ++++++++++++++++++++++-------------- test/Redistribution.test.ts | 37 +++++++------ test/Staking.test.ts | 41 +++++++------- test/Stats.test.ts | 2 +- 4 files changed, 108 insertions(+), 76 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 92a8e228..c9605393 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -63,9 +63,17 @@ contract StakeRegistry is AccessControl, Pausable { uint64 public immutable WAIT_OVERLAY_CHANGE; uint64 public immutable WAIT_WITHDRAWAL; - event Deposit(address indexed owner, uint64 registeredFromRound, uint256 amount); + event DepositCreated( + address indexed owner, + uint64 registeredFromRound, + uint256 amount, + bytes32 overlay, + uint8 height + ); + event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); + event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); + event HeightIncreased(address indexed owner, uint64 registeredFromRound, uint8 height); event Withdrawal(address indexed owner, uint64 registeredFromRound, uint256 amount); - event ServiceCommitmentUpdate(address indexed owner, uint64 registeredFromRound, bytes32 overlay, uint8 height); event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); event StakeFrozen(address frozen, bytes32 overlay, uint256 time); @@ -76,6 +84,7 @@ contract StakeRegistry is AccessControl, Pausable { error OnlyPauser(); error BelowMinimumStake(); error NotStaked(); + error AlreadyStaked(); error HeightDecreaseNotAllowed(); error InvalidWithdrawalAmount(); error UpdateQueueFull(); @@ -95,55 +104,72 @@ contract StakeRegistry is AccessControl, Pausable { _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } - function manageStake(bytes32 _setNonce, uint256 _addAmount, uint8 _height) external whenNotPaused { + function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); + if (plannedStake.initialized && plannedStake.balance > 0) revert AlreadyStaked(); + if (_amount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); + bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); + _pullTokens(msg.sender, _amount); - if (!plannedStake.initialized || plannedStake.balance == 0) { - if (_addAmount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.CreateDeposit, + WAIT_BASE, + _setNonce, + _amount, + _height + ); - _pullTokens(msg.sender, _addAmount); + emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); + } - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.CreateDeposit, - WAIT_BASE, - _setNonce, - _addAmount, - _height - ); + function addTokens(uint256 _amount) external whenNotPaused { + if (!addressNotFrozen(msg.sender)) revert Frozen(); - emit Deposit(msg.sender, effectiveFromRound, _addAmount); - emit ServiceCommitmentUpdate(msg.sender, effectiveFromRound, newOverlay, _height); - return; - } + StakeState memory plannedStake = _previewStake(msg.sender, true); + if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); - if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); + _pullTokens(msg.sender, _amount); + uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); - if (_addAmount > 0) { - _pullTokens(msg.sender, _addAmount); - uint64 depositRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _addAmount, 0); - emit Deposit(msg.sender, depositRound, _addAmount); - } + emit TokensAdded(msg.sender, effectiveFromRound, _amount); + } - if (_height > plannedStake.height) { - uint64 heightRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); - emit ServiceCommitmentUpdate(msg.sender, heightRound, plannedStake.overlay, _height); - } + function changeOverlay(bytes32 _setNonce) external whenNotPaused { + if (!addressNotFrozen(msg.sender)) revert Frozen(); - if (newOverlay != plannedStake.overlay) { - uint64 overlayRound = _enqueueUpdate( - msg.sender, - UpdateKind.ChangeOverlay, - WAIT_OVERLAY_CHANGE, - _setNonce, - 0, - plannedStake.height - ); - emit ServiceCommitmentUpdate(msg.sender, overlayRound, newOverlay, plannedStake.height); - } + StakeState memory plannedStake = _previewStake(msg.sender, true); + if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + + bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); + if (newOverlay == plannedStake.overlay) return; + + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.ChangeOverlay, + WAIT_OVERLAY_CHANGE, + _setNonce, + 0, + 0 + ); + + emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); + } + + function increaseHeight(uint8 _height) external whenNotPaused { + if (!addressNotFrozen(msg.sender)) revert Frozen(); + + StakeState memory plannedStake = _previewStake(msg.sender, true); + if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); + if (_height == plannedStake.height) return; + if (plannedStake.balance < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); + + uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); + emit HeightIncreased(msg.sender, effectiveFromRound, _height); } function withdraw(uint256 _amount) external whenNotPaused { diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index 8e1a8a75..fc7ce941 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -84,7 +84,8 @@ let node_2: string; const overlay_2 = '0xa40db58e368ea6856a24c0264ebd73b049f3dc1c2347b1babc901d3e09842dec'; const stakeAmount_2 = '100000000000000000'; const effectiveStakeAmount_2 = '100000000000000000'; -const effectiveStakeAmount_2_n_2 = '100000000000000000'; +const topUpStakeAmount_2_n_2 = '300000000000000000'; +const effectiveStakeAmount_2_n_2 = '400000000000000000'; const nonce_2 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const hash_2 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const depth_2 = '0x06'; @@ -100,7 +101,8 @@ const hash_3 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b const depth_3 = '0x06'; const reveal_nonce_3 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const height_3_n_2 = 3; -const effectiveStakeAmount_3 = '100000000000000000'; +const topUpStakeAmount_3_n_2 = '700000000000000000'; +const effectiveStakeAmount_3 = '800000000000000000'; let node_4: string; const overlay_4 = '0xaedb2a8007316805b4d64b249ea39c5a1c4a9ce51dc8432724241f41ecb02efb'; @@ -252,7 +254,7 @@ describe('Redistribution', function () { it('should not create a commit with recently staked node', async function () { const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); await mintAndApprove(deployer, node_0, sr_node_0.address, stakeAmount_0); - await sr_node_0.manageStake(nonce_0, stakeAmount_0, height_0); + await sr_node_0.createDeposit(nonce_0, stakeAmount_0, height_0); expect(await redistribution.currentPhaseCommit()).to.be.true; @@ -265,7 +267,7 @@ describe('Redistribution', function () { it('should create a commit with staked node', async function () { const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); await mintAndApprove(deployer, node_0, sr_node_0.address, stakeAmount_0); - await sr_node_0.manageStake(nonce_0, stakeAmount_0, height_0); + await sr_node_0.createDeposit(nonce_0, stakeAmount_0, height_0); expect(await redistribution.currentPhaseCommit()).to.be.true; @@ -279,7 +281,7 @@ describe('Redistribution', function () { const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); await mintAndApprove(deployer, node_0, sr_node_0.address, stakeAmount_0); - await expect(sr_node_0.manageStake(nonce_0, stakeAmount_0, height_0_n_2)).to.be.revertedWith( + await expect(sr_node_0.createDeposit(nonce_0, stakeAmount_0, height_0_n_2)).to.be.revertedWith( errors.deposit.belowMinimum ); }); @@ -287,7 +289,7 @@ describe('Redistribution', function () { it('should create a commit with staked node and height 2', async function () { const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); await mintAndApprove(deployer, node_0, sr_node_0.address, stakeAmount_0_n_2); - await sr_node_0.manageStake(nonce_0, stakeAmount_0_n_2, height_0_n_2); + await sr_node_0.createDeposit(nonce_0, stakeAmount_0_n_2, height_0_n_2); expect(await redistribution.currentPhaseCommit()).to.be.true; @@ -346,32 +348,33 @@ describe('Redistribution', function () { const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); await mintAndApprove(deployer, node_0, sr_node_0.address, stakeAmount_0); - await sr_node_0.manageStake(nonce_0, stakeAmount_0, height_0); + await sr_node_0.createDeposit(nonce_0, stakeAmount_0, height_0); const sr_node_1 = await ethers.getContract('StakeRegistry', node_1); await mintAndApprove(deployer, node_1, sr_node_1.address, stakeAmount_1); - await sr_node_1.manageStake(nonce_1, stakeAmount_1, height_1); + await sr_node_1.createDeposit(nonce_1, stakeAmount_1, height_1); // 16 depth neighbourhood with node_5 const sr_node_1_n_25 = await ethers.getContract('StakeRegistry', node_1); await mintAndApprove(deployer, node_1, sr_node_1_n_25.address, stakeAmount_1); - await sr_node_1_n_25.manageStake(nonce_1_n_25, stakeAmount_1, height_1); + await sr_node_1_n_25.addTokens(stakeAmount_1); + await sr_node_1_n_25.changeOverlay(nonce_1_n_25); const sr_node_2 = await ethers.getContract('StakeRegistry', node_2); await mintAndApprove(deployer, node_2, sr_node_2.address, stakeAmount_2); - await sr_node_2.manageStake(nonce_2, stakeAmount_2, height_2); + await sr_node_2.createDeposit(nonce_2, stakeAmount_2, height_2); const sr_node_3 = await ethers.getContract('StakeRegistry', node_3); await mintAndApprove(deployer, node_3, sr_node_3.address, stakeAmount_3); - await sr_node_3.manageStake(nonce_3, stakeAmount_3, height_4); + await sr_node_3.createDeposit(nonce_3, stakeAmount_3, height_4); const sr_node_4 = await ethers.getContract('StakeRegistry', node_4); await mintAndApprove(deployer, node_4, sr_node_4.address, stakeAmount_3); - await sr_node_4.manageStake(nonce_4, stakeAmount_3, height_4); + await sr_node_4.createDeposit(nonce_4, stakeAmount_3, height_4); const sr_node_5 = await ethers.getContract('StakeRegistry', node_5); await mintAndApprove(deployer, node_5, sr_node_5.address, stakeAmount_5); - await sr_node_5.manageStake(nonce_5, stakeAmount_5, height_5); + await sr_node_5.createDeposit(nonce_5, stakeAmount_5, height_5); // We need to mine 2 rounds to make the staking possible // as this is the minimum time between staking and committing @@ -527,7 +530,9 @@ describe('Redistribution', function () { // Change height and check if node is playing const sr_node_3 = await ethers.getContract('StakeRegistry', node_3); - await sr_node_3.manageStake(nonce_3, 0, height_3_n_2); + await mintAndApprove(deployer, node_3, sr_node_3.address, topUpStakeAmount_3_n_2); + await sr_node_3.addTokens(topUpStakeAmount_3_n_2); + await sr_node_3.increaseHeight(height_3_n_2); await mineNBlocks(3 * phaseLength); await mineToNode(redistribution, 3); @@ -581,7 +586,9 @@ describe('Redistribution', function () { it('should create a commit with successful reveal if the overlay is within the reported depth with height 2', async function () { const r_node_2 = await ethers.getContract('Redistribution', node_2); const sr_node_2 = await ethers.getContract('StakeRegistry', node_2); - await sr_node_2.manageStake(nonce_2, 0, height_2_n_2); + await mintAndApprove(deployer, node_2, sr_node_2.address, topUpStakeAmount_2_n_2); + await sr_node_2.addTokens(topUpStakeAmount_2_n_2); + await sr_node_2.increaseHeight(height_2_n_2); await mineToNode(redistribution, 2); expect(await redistribution.currentPhaseCommit()).to.be.true; diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 38c39d04..448e41ea 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -48,6 +48,7 @@ const nonce_1 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555 const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; const stakeAmount_0 = '100000000000000000'; const doubleStakeAmount_0 = '200000000000000000'; +const topUpForHeight1 = '100000000000000000'; const stakeAmount_1 = '100000000000000000'; const updateStakeAmount_0 = '633633'; const updatedStakeAmount_0 = '100000000000633633'; @@ -84,7 +85,7 @@ async function advanceRounds(rounds = 2) { async function activateStake(contract: Contract, owner: string, nonce: string, amount: string, height: number) { await mintAndApprove(owner, contract.address, amount); - await contract.manageStake(nonce, amount, height); + await contract.createDeposit(nonce, amount, height); await advanceRounds(); await contract.applyUpdates(owner); } @@ -111,9 +112,9 @@ describe('Staking', function () { const currentRound = await srStaker0.currentRound(); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); - await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)) - .to.emit(srStaker0, 'Deposit') - .withArgs(staker_0, currentRound.add(2), stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)) + .to.emit(srStaker0, 'DepositCreated') + .withArgs(staker_0, currentRound.add(2), stakeAmount_0, overlay_0, height_0); await advanceRounds(); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); @@ -121,12 +122,11 @@ describe('Staking', function () { it('should keep a scheduled deposit inactive until the delay elapses', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); - const currentRound = await srStaker0.currentRound(); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); - await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)) - .to.emit(srStaker0, 'ServiceCommitmentUpdate') - .withArgs(staker_0, currentRound.add(2), overlay_0, height_0); + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)) + .to.emit(srStaker0, 'DepositCreated') + .withArgs(staker_0, (await srStaker0.currentRound()).add(2), stakeAmount_0, overlay_0, height_0); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); expect(await srStaker0.overlayOfAddress(staker_0)).to.be.eq(zeroBytes32); @@ -146,7 +146,7 @@ describe('Staking', function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); await mintAndApprove(staker_1, srStaker1.address, stakeAmount_1); - await expect(srStaker1.manageStake(nonce_1, stakeAmount_1, height_1_n_1)).to.be.revertedWith( + await expect(srStaker1.createDeposit(nonce_1, stakeAmount_1, height_1_n_1)).to.be.revertedWith( errors.deposit.belowMinimum ); }); @@ -155,15 +155,16 @@ describe('Staking', function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - await mintAndApprove(staker_0, srStaker0.address, updateStakeAmount_0); - await srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0_n_1); + await mintAndApprove(staker_0, srStaker0.address, topUpForHeight1); + await expect(srStaker0.addTokens(topUpForHeight1)).to.emit(srStaker0, 'TokensAdded'); + await expect(srStaker0.increaseHeight(height_0_n_1)).to.emit(srStaker0, 'HeightIncreased'); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0); await advanceRounds(); - expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(updatedStakeAmount_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); }); @@ -171,7 +172,7 @@ describe('Staking', function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); - await srStaker1.manageStake(nonce_1_n_25, 0, height_1); + await expect(srStaker1.changeOverlay(nonce_1_n_25)).to.emit(srStaker1, 'OverlayChanged'); expect(await srStaker1.overlayOfAddress(staker_1)).to.be.eq(overlay_1); await advanceRounds(); @@ -182,7 +183,7 @@ describe('Staking', function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0_n_1); - await expect(srStaker0.manageStake(nonce_0, 0, height_0)).to.be.revertedWith(errors.deposit.heightDecrease); + await expect(srStaker0.increaseHeight(height_0)).to.be.revertedWith(errors.deposit.heightDecrease); }); it('should keep effective stake equal to balance after oracle price changes', async function () { @@ -254,12 +255,10 @@ describe('Staking', function () { expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); await mintAndApprove(staker_0, srStaker0.address, updateStakeAmount_0); - await expect(srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0)).to.be.revertedWith( - errors.freeze.currentlyFrozen - ); + await expect(srStaker0.addTokens(updateStakeAmount_0)).to.be.revertedWith(errors.freeze.currentlyFrozen); await mineNBlocks(freezeTime + 1); - await expect(srStaker0.manageStake(nonce_0, updateStakeAmount_0, height_0)).to.not.be.reverted; + await expect(srStaker0.addTokens(updateStakeAmount_0)).to.not.be.reverted; }); it('should slash active stake balances', async function () { @@ -285,7 +284,7 @@ describe('Staking', function () { it('should not allow stake migration while unpaused and should include queued deposits when paused', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); - await srStaker0.manageStake(nonce_0, stakeAmount_0, height_0); + await srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0); await expect(srStaker0.migrateStake()).to.be.revertedWith(errors.pause.notCurrentlyPaused); @@ -303,11 +302,11 @@ describe('Staking', function () { await stakeRegistryPauser.pause(); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); - await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( errors.pause.currentlyPaused ); await stakeRegistryPauser.unPause(); - await expect(srStaker0.manageStake(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; }); }); diff --git a/test/Stats.test.ts b/test/Stats.test.ts index 4d467162..52fb1330 100644 --- a/test/Stats.test.ts +++ b/test/Stats.test.ts @@ -64,7 +64,7 @@ async function nPlayerGames(nodes: string[], stakes: string[], effectiveStakes: for (let i = 0; i < nodes.length; i++) { const sr_node = await ethers.getContract('StakeRegistry', nodes[i]); await mintAndApprove(deployer, nodes[i], sr_node.address, stakes[i]); - await sr_node.manageStake(nonce, stakes[i], 0); + await sr_node.createDeposit(nonce, stakes[i], 0); } const winDist: Outcome[] = []; From 1d4543cae5ea76bcbcc452de44ec3e9df5661dd8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 13 Apr 2026 23:42:13 +0200 Subject: [PATCH 03/58] fix: block queued withdrawals during freezes and active rounds Prevent queued stake withdrawals and exits from executing while a node is frozen or actively participating in the current redistribution round, and wire staking to the redistribution contract for the runtime check. --- deploy/local/007_deploy_roles_staking.ts | 1 + deploy/main/007_deploy_roles_staking.ts | 1 + deploy/tenderly/007_deploy_roles_staking.ts | 1 + deploy/test/007_deploy_roles_staking.ts | 1 + src/Redistribution.sol | 21 ++++++++ src/Staking.sol | 37 +++++++++++++- test/Staking.test.ts | 54 +++++++++++++++++++++ 7 files changed, 115 insertions(+), 1 deletion(-) diff --git a/deploy/local/007_deploy_roles_staking.ts b/deploy/local/007_deploy_roles_staking.ts index c8d595e6..86b89efa 100644 --- a/deploy/local/007_deploy_roles_staking.ts +++ b/deploy/local/007_deploy_roles_staking.ts @@ -10,6 +10,7 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); + await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); log('----------------------------------------------------'); }; diff --git a/deploy/main/007_deploy_roles_staking.ts b/deploy/main/007_deploy_roles_staking.ts index af3823d9..f0db52fb 100644 --- a/deploy/main/007_deploy_roles_staking.ts +++ b/deploy/main/007_deploy_roles_staking.ts @@ -12,6 +12,7 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisAddress = (await get('Redistribution')).address; await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); + await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); } else { log('DEPLOYER NEEDS TO HAVE ADMIN ROLE TO ASSIGN THE REDISTRIBUTION ROLE, PLEASE ASSIGN IT OR GRANT ROLE MANUALLY'); } diff --git a/deploy/tenderly/007_deploy_roles_staking.ts b/deploy/tenderly/007_deploy_roles_staking.ts index 03c61e1a..e07570fb 100644 --- a/deploy/tenderly/007_deploy_roles_staking.ts +++ b/deploy/tenderly/007_deploy_roles_staking.ts @@ -14,6 +14,7 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) if (await read('StakeRegistry', 'hasRole', adminRole, deployer)) { const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); + await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); } else { log( 'DEPLOYER NEEDS TO HAVE ADMIN ROLE TO ASSIGN THE REDISTRIBUTION ROLE, PLEASE ASSIGN IT AND/OR GRANT ROLE MANUALLY' diff --git a/deploy/test/007_deploy_roles_staking.ts b/deploy/test/007_deploy_roles_staking.ts index 909adaa6..bc4ef93e 100644 --- a/deploy/test/007_deploy_roles_staking.ts +++ b/deploy/test/007_deploy_roles_staking.ts @@ -10,6 +10,7 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); + await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); // Verify role assignment log('Verifying role assignment...'); diff --git a/src/Redistribution.sol b/src/Redistribution.sol index b6ea568b..64d70c49 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -836,6 +836,27 @@ contract Redistribution is AccessControl, Pausable { return inProximity(Stakes.overlayOfAddress(_owner), currentRoundAnchor(), _depthResponsibility); } + function isParticipatingInCurrentRound(address _owner) external view returns (bool) { + uint64 cr = currentRound(); + + if (currentCommitRound != cr) { + return false; + } + + uint256 commitsArrayLength = currentCommits.length; + for (uint256 i = 0; i < commitsArrayLength; ) { + if (currentCommits[i].owner == _owner) { + return true; + } + + unchecked { + ++i; + } + } + + return false; + } + // ----------------------------- Reveal ------------------------------ /** diff --git a/src/Staking.sol b/src/Staking.sol index c9605393..65ba8dbe 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -4,6 +4,10 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; +interface IRedistribution { + function isParticipatingInCurrentRound(address _owner) external view returns (bool); +} + /** * @title Staking contract for the Swarm storage incentives * @author The Swarm Authors @@ -62,6 +66,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 public immutable WAIT_BASE; uint64 public immutable WAIT_OVERLAY_CHANGE; uint64 public immutable WAIT_WITHDRAWAL; + address public redistributionContract; event DepositCreated( address indexed owner, @@ -88,6 +93,7 @@ contract StakeRegistry is AccessControl, Pausable { error HeightDecreaseNotAllowed(); error InvalidWithdrawalAmount(); error UpdateQueueFull(); + error RedistributionNotConfigured(); constructor( address _bzzToken, @@ -236,10 +242,14 @@ contract StakeRegistry is AccessControl, Pausable { function freezeDeposit(address _owner, uint256 _time) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + if (!_stakes[_owner].initialized && _queueLength(_owner) == 0) { + return; + } + + _stakes[_owner].frozenUntilBlock = block.number + _time; _applyReadyUpdates(_owner); if (_stakes[_owner].initialized) { - _stakes[_owner].frozenUntilBlock = block.number + _time; emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); } } @@ -272,6 +282,11 @@ contract StakeRegistry is AccessControl, Pausable { NetworkId = _NetworkId; } + function setRedistributionContract(address _redistributionContract) external { + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + redistributionContract = _redistributionContract; + } + function pause() public { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); _pause(); @@ -322,6 +337,9 @@ contract StakeRegistry is AccessControl, Pausable { uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { + if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) { + break; + } _applyStoredUpdate(_owner, queue[head]); delete queue[head]; unchecked { @@ -393,6 +411,20 @@ contract StakeRegistry is AccessControl, Pausable { } } + function _blocksQueuedWithdrawalExecution(address _owner, UpdateKind _kind) internal view returns (bool) { + if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { + return false; + } + + if (!addressNotFrozen(_owner)) { + return true; + } + + if (redistributionContract == address(0)) revert RedistributionNotConfigured(); + + return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); + } + function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; @@ -405,6 +437,9 @@ contract StakeRegistry is AccessControl, Pausable { if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; } + if (!includeFutureUpdates && _blocksQueuedWithdrawalExecution(_owner, scheduled.kind)) { + break; + } preview = _applyPreviewUpdate(_owner, preview, scheduled); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 448e41ea..6b5036ec 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -46,6 +46,7 @@ const overlay_1_n_25 = '0x676766bbae530fd0483e4734e800569c95929b707b9c50f8717dc9 const nonce_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const nonce_1 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; +const obfuscatedHash_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const stakeAmount_0 = '100000000000000000'; const doubleStakeAmount_0 = '200000000000000000'; const topUpForHeight1 = '100000000000000000'; @@ -215,6 +216,59 @@ describe('Staking', function () { expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); }); + it('should keep queued withdrawal pending while the node is frozen', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); + + await srStaker0.withdraw(withdrawAmount); + await advanceRounds(); + + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime); + + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + + await mineNBlocks(freezeTime + 1); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + }); + + it('should keep queued withdrawal pending while the node is active in the current round', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const redistribution = await ethers.getContract('Redistribution', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + await srStaker0.withdraw(withdrawAmount); + await advanceRounds(); + + const currentRound = await redistribution.currentRound(); + await redistribution.commit(obfuscatedHash_0, currentRound); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); + + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); + + await mineNBlocks(roundLength); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + }); + it('should schedule exits and clear the stake on applyUpdates', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); From e2f2542ceb2778c69c8521c5db2448675ce672ab Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 00:09:47 +0200 Subject: [PATCH 04/58] fix: make redistribution payout and linkage atomic Prevent claims from finalizing when postage payout fails, and initialize staking with the expected redistribution contract so deployment catches linkage mismatches early. --- deploy/local/003_deploy_staking.ts | 6 +++++ deploy/local/004_deploy_redistribution.ts | 11 +++++++-- deploy/local/007_deploy_roles_staking.ts | 1 - deploy/main/003_deploy_staking.ts | 6 +++++ deploy/main/004_deploy_redistribution.ts | 11 +++++++-- deploy/main/007_deploy_roles_staking.ts | 1 - deploy/main/010_deploy_verify.ts | 3 ++- deploy/tenderly/007_deploy_roles_staking.ts | 1 - deploy/test/003_deploy_staking.ts | 6 +++++ deploy/test/004_deploy_redistribution.ts | 11 +++++++-- deploy/test/007_deploy_roles_staking.ts | 1 - deploy/test/010_deploy_verify.ts | 3 ++- src/Redistribution.sol | 19 +--------------- src/Staking.sol | 23 ++++++++++++++++--- test/Redistribution.test.ts | 25 +++++++++++++++++++++ test/Staking.test.ts | 14 ++++++++++++ 16 files changed, 109 insertions(+), 33 deletions(-) diff --git a/deploy/local/003_deploy_staking.ts b/deploy/local/003_deploy_staking.ts index 42bc25be..a3b08f1c 100644 --- a/deploy/local/003_deploy_staking.ts +++ b/deploy/local/003_deploy_staking.ts @@ -1,4 +1,5 @@ import { DeployFunction } from 'hardhat-deploy/types'; +import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -8,9 +9,14 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); + const redistributionAddress = ethers.utils.getContractAddress({ + from: deployer, + nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, + }); const args = [ token.address, + redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/local/004_deploy_redistribution.ts b/deploy/local/004_deploy_redistribution.ts index af67d412..3779a90b 100644 --- a/deploy/local/004_deploy_redistribution.ts +++ b/deploy/local/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, log } = deployments; + const { deploy, get, read, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,13 +11,20 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - await deploy('Redistribution', { + const redistribution = await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 1, }); + const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); + if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { + throw new Error( + `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` + ); + } + log('----------------------------------------------------'); }; diff --git a/deploy/local/007_deploy_roles_staking.ts b/deploy/local/007_deploy_roles_staking.ts index 86b89efa..c8d595e6 100644 --- a/deploy/local/007_deploy_roles_staking.ts +++ b/deploy/local/007_deploy_roles_staking.ts @@ -10,7 +10,6 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); - await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); log('----------------------------------------------------'); }; diff --git a/deploy/main/003_deploy_staking.ts b/deploy/main/003_deploy_staking.ts index 7c65be2e..4b11d18d 100644 --- a/deploy/main/003_deploy_staking.ts +++ b/deploy/main/003_deploy_staking.ts @@ -1,4 +1,5 @@ import { DeployFunction } from 'hardhat-deploy/types'; +import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -7,9 +8,14 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const config = networkConfig[network.name] || {}; const swarmNetworkID = config.swarmNetworkId; const token = await get('Token'); + const redistributionAddress = ethers.utils.getContractAddress({ + from: deployer, + nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, + }); const args = [ token.address, + redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/main/004_deploy_redistribution.ts b/deploy/main/004_deploy_redistribution.ts index 9943e0c0..aeb164b6 100644 --- a/deploy/main/004_deploy_redistribution.ts +++ b/deploy/main/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, log } = deployments; + const { deploy, get, read, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,13 +11,20 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - await deploy('Redistribution', { + const redistribution = await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 6, }); + const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); + if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { + throw new Error( + `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` + ); + } + log('----------------------------------------------------'); }; diff --git a/deploy/main/007_deploy_roles_staking.ts b/deploy/main/007_deploy_roles_staking.ts index f0db52fb..af3823d9 100644 --- a/deploy/main/007_deploy_roles_staking.ts +++ b/deploy/main/007_deploy_roles_staking.ts @@ -12,7 +12,6 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisAddress = (await get('Redistribution')).address; await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); - await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); } else { log('DEPLOYER NEEDS TO HAVE ADMIN ROLE TO ASSIGN THE REDISTRIBUTION ROLE, PLEASE ASSIGN IT OR GRANT ROLE MANUALLY'); } diff --git a/deploy/main/010_deploy_verify.ts b/deploy/main/010_deploy_verify.ts index d6c6fd8f..f18c3c4c 100644 --- a/deploy/main/010_deploy_verify.ts +++ b/deploy/main/010_deploy_verify.ts @@ -28,8 +28,10 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); + const redistribution = await get('Redistribution'); const argStaking = [ token.address, + redistribution.address, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, @@ -41,7 +43,6 @@ const func: DeployFunction = async function ({ deployments, network }) { log('----------------------------------------------------'); // Verify redistribution - const redistribution = await get('Redistribution'); const argRedistribution = [staking.address, postageStamp.address, priceOracle.address]; log('Verifying...'); diff --git a/deploy/tenderly/007_deploy_roles_staking.ts b/deploy/tenderly/007_deploy_roles_staking.ts index e07570fb..03c61e1a 100644 --- a/deploy/tenderly/007_deploy_roles_staking.ts +++ b/deploy/tenderly/007_deploy_roles_staking.ts @@ -14,7 +14,6 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) if (await read('StakeRegistry', 'hasRole', adminRole, deployer)) { const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); - await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); } else { log( 'DEPLOYER NEEDS TO HAVE ADMIN ROLE TO ASSIGN THE REDISTRIBUTION ROLE, PLEASE ASSIGN IT AND/OR GRANT ROLE MANUALLY' diff --git a/deploy/test/003_deploy_staking.ts b/deploy/test/003_deploy_staking.ts index b82b2f20..3e3df157 100644 --- a/deploy/test/003_deploy_staking.ts +++ b/deploy/test/003_deploy_staking.ts @@ -1,4 +1,5 @@ import { DeployFunction } from 'hardhat-deploy/types'; +import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -7,9 +8,14 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const config = networkConfig[network.name] || {}; const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); + const redistributionAddress = ethers.utils.getContractAddress({ + from: deployer, + nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, + }); const args = [ token.address, + redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/test/004_deploy_redistribution.ts b/deploy/test/004_deploy_redistribution.ts index 9943e0c0..aeb164b6 100644 --- a/deploy/test/004_deploy_redistribution.ts +++ b/deploy/test/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, log } = deployments; + const { deploy, get, read, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,13 +11,20 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - await deploy('Redistribution', { + const redistribution = await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 6, }); + const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); + if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { + throw new Error( + `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` + ); + } + log('----------------------------------------------------'); }; diff --git a/deploy/test/007_deploy_roles_staking.ts b/deploy/test/007_deploy_roles_staking.ts index bc4ef93e..909adaa6 100644 --- a/deploy/test/007_deploy_roles_staking.ts +++ b/deploy/test/007_deploy_roles_staking.ts @@ -10,7 +10,6 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts }) const redisRole = await read('StakeRegistry', 'REDISTRIBUTOR_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', redisRole, redisAddress); - await execute('StakeRegistry', { from: deployer }, 'setRedistributionContract', redisAddress); // Verify role assignment log('Verifying role assignment...'); diff --git a/deploy/test/010_deploy_verify.ts b/deploy/test/010_deploy_verify.ts index 2433f17c..9d5f4964 100644 --- a/deploy/test/010_deploy_verify.ts +++ b/deploy/test/010_deploy_verify.ts @@ -35,8 +35,10 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); + const redistribution = await get('Redistribution'); const argStaking = [ token.address, + redistribution.address, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, @@ -48,7 +50,6 @@ const func: DeployFunction = async function ({ deployments, network }) { log('----------------------------------------------------'); // Verify redistribution - const redistribution = await get('Redistribution'); const argRedistribution = [staking.address, postageStamp.address, priceOracle.address]; log('Redistribution'); diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 64d70c49..0ba50463 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -12,12 +12,6 @@ interface IPriceOracle { } interface IStakeRegistry { - struct Stake { - bytes32 overlay; - uint256 stakeAmount; - uint256 lastUpdatedBlockNumber; - } - function freezeDeposit(address _owner, uint256 _time) external; function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256); @@ -200,11 +194,6 @@ contract Redistribution is AccessControl, Pausable { */ event PriceAdjustmentSkipped(uint16 redundancyCount); - /** - * @dev Withdraw not successful in claim - */ - event WithdrawFailed(address owner); - /** * @dev Logs that an overlay has revealed */ @@ -476,13 +465,7 @@ contract Redistribution is AccessControl, Pausable { estimateSize(entryProofLast.proofSegments[0]); - // Do the check if the withdraw was success - (bool success, ) = address(PostageContract).call( - abi.encodeWithSignature("withdraw(address)", winnerSelected.owner) - ); - if (!success) { - emit WithdrawFailed(winnerSelected.owner); - } + PostageContract.withdraw(winnerSelected.owner); emit WinnerSelected(winnerSelected); emit ChunkCount(PostageContract.validChunkCount()); diff --git a/src/Staking.sol b/src/Staking.sol index 65ba8dbe..2b2bae78 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -93,17 +93,20 @@ contract StakeRegistry is AccessControl, Pausable { error HeightDecreaseNotAllowed(); error InvalidWithdrawalAmount(); error UpdateQueueFull(); - error RedistributionNotConfigured(); + error InvalidRedistributionContract(); constructor( address _bzzToken, + address _redistributionContract, uint64 _NetworkId, uint64 _waitBase, uint64 _waitOverlayChange, uint64 _waitWithdrawal ) { + if (_redistributionContract == address(0)) revert InvalidRedistributionContract(); NetworkId = _NetworkId; bzzToken = _bzzToken; + redistributionContract = _redistributionContract; WAIT_BASE = _waitBase; WAIT_OVERLAY_CHANGE = _waitOverlayChange; WAIT_WITHDRAWAL = _waitWithdrawal; @@ -284,6 +287,10 @@ contract StakeRegistry is AccessControl, Pausable { function setRedistributionContract(address _redistributionContract) external { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + if ( + !hasRole(REDISTRIBUTOR_ROLE, _redistributionContract) || + !_supportsParticipationCheck(_redistributionContract) + ) revert InvalidRedistributionContract(); redistributionContract = _redistributionContract; } @@ -420,11 +427,21 @@ contract StakeRegistry is AccessControl, Pausable { return true; } - if (redistributionContract == address(0)) revert RedistributionNotConfigured(); - return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); } + function _supportsParticipationCheck(address _redistributionContract) internal view returns (bool) { + if (_redistributionContract.code.length == 0) { + return false; + } + + (bool success, ) = _redistributionContract.staticcall( + abi.encodeWithSelector(IRedistribution.isParticipatingInCurrentRound.selector, address(0)) + ); + + return success; + } + function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index fc7ce941..a96b8f55 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -187,6 +187,7 @@ const errors = { claim: { noReveals: 'NoReveals()', alreadyClaimed: 'AlreadyClaimed()', + postageWithdrawFailed: 'OnlyRedistributor()', randomCheckFailed: 'RandomElementCheckFailed()', outOfDepth: 'OutOfDepth()', reserveCheckFailed: 'ReserveCheckFailed()', @@ -1493,6 +1494,30 @@ describe('Redistribution', function () { await expect(r_node_1.claim(proof1, proof2, proofLast)).to.be.revertedWith(errors.claim.alreadyClaimed); }); + it('should revert claim when postage payout fails and allow retry', async function () { + await r_node_1.reveal(depth_5, hash_5, reveal_nonce_1); + await r_node_5.reveal(depth_5, hash_5, reveal_nonce_5); + + await mineNBlocks(phaseLength); + + const postageDeployer = await ethers.getContract('PostageStamp', deployer); + const redistribution = await ethers.getContract('Redistribution'); + const redistributorRole = await postageDeployer.REDISTRIBUTOR_ROLE(); + const claimRoundBefore = await redistribution.currentClaimRound(); + + await postageDeployer.revokeRole(redistributorRole, redistribution.address); + + await expect(r_node_5.claim(proof1, proof2, proofLast)).to.be.revertedWith( + errors.claim.postageWithdrawFailed + ); + expect(await redistribution.currentClaimRound()).to.be.eq(claimRoundBefore); + + await postageDeployer.grantRole(redistributorRole, redistribution.address); + + await expect(r_node_5.claim(proof1, proof2, proofLast)).to.not.be.reverted; + expect(await redistribution.currentClaimRound()).to.be.eq(await redistribution.currentRound()); + }); + it('if incorrect winner claims, correct winner is paid', async function () { await r_node_1.reveal(depth_5, hash_5, reveal_nonce_1); await r_node_5.reveal(depth_5, hash_5, reveal_nonce_5); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 6b5036ec..46d9fe1a 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -38,6 +38,9 @@ const errors = { notCurrentlyPaused: 'Pausable: not paused', onlyPauseCanUnPause: 'OnlyPauser()', }, + general: { + invalidRedistribution: 'InvalidRedistributionContract()', + }, }; const overlay_0 = '0xa602fa47b3e8ce39ffc2017ad9069ff95eb58c051b1cfa2b0d86bc44a5433733'; @@ -102,10 +105,21 @@ describe('Staking', function () { }); it('should deploy StakeRegistry with queue wait parameters', async function () { + const redistribution = await ethers.getContract('Redistribution'); expect(stakeRegistry.address).to.be.properAddress; expect(await stakeRegistry.WAIT_BASE()).to.be.eq(2); expect(await stakeRegistry.WAIT_OVERLAY_CHANGE()).to.be.eq(2); expect(await stakeRegistry.WAIT_WITHDRAWAL()).to.be.eq(2); + expect(await stakeRegistry.redistributionContract()).to.be.eq(redistribution.address); + }); + + it('should only allow relinking redistribution to a valid redistributor contract', async function () { + const redistribution = await ethers.getContract('Redistribution'); + + await expect(stakeRegistry.setRedistributionContract(token.address)).to.be.revertedWith( + errors.general.invalidRedistribution + ); + await expect(stakeRegistry.setRedistributionContract(redistribution.address)).to.not.be.reverted; }); it('should schedule a new deposit and activate it after the base delay', async function () { From 9f9d1ccc061267eb78b83a3bdf39be31e3a9c0d7 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 00:22:49 +0200 Subject: [PATCH 05/58] test: harden staking and redistribution coverage Add direct effective stake coverage and make the two-reveal winner assertion resilient to deterministic state changes, while cleaning related test typing and lint issues. --- src/Staking.sol | 5 ++- test/Redistribution.test.ts | 66 ++++++++++++++----------------------- test/Staking.test.ts | 11 ++++++- test/Stats.test.ts | 1 - 4 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 2b2bae78..d6aa616f 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -442,7 +442,10 @@ contract StakeRegistry is AccessControl, Pausable { return success; } - function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (StakeState memory preview) { + function _previewStake( + address _owner, + bool includeFutureUpdates + ) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; ScheduledUpdate[] storage queue = _updateQueues[_owner]; diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index a96b8f55..6b9318d8 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -64,8 +64,6 @@ const obfuscatedHash_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b555 const height_0 = 0; const height_0_n_2 = 2; -//fake -const overlay_f = '0xf4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f415'; const depth_f = '0x0000000000000000000000000000000000000000000000000000000000000007'; const reveal_nonce_f = '0xf4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f415'; @@ -120,23 +118,6 @@ const reveal_nonce_5 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b const { depth: depth_5, hash: hash_5 } = node5_proof1; const height_5 = 0; -let node_6: string; -const overlay_6 = '0x141680b0d9c7ab250672fd4603ac13e39e47de6e2c93d71bbdc66459a6c5e39f'; -const stakeAmount_6 = '100000000000000000'; - -const nonce_6 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const hash_6 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const depth_6 = '0x06'; -const reveal_nonce_6 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; - -let node_7: string; -const overlay_7 = '0x152d169abc6e6a0e0a2a7b78dcfea0bebe32942f05e9bb10ee2996203d5361ef'; -const stakeAmount_7 = '100000000000000000'; -const nonce_7 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const hash_7 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const depth_7 = '0x06'; -const reveal_nonce_7 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; - // start round number after startRoundFixture() const startRoundNumber = 3; // start round number after mintToNode(red, 0) -> without claim @@ -167,8 +148,6 @@ before(async function () { node_3 = namedAccounts.node_3; node_4 = namedAccounts.node_4; node_5 = namedAccounts.node_5; - node_6 = namedAccounts.node_6; - node_7 = namedAccounts.node_7; }); const errors = { @@ -246,7 +225,6 @@ describe('Redistribution', function () { expect(await redistribution.currentPhaseCommit()).to.be.true; const r_node_0 = await ethers.getContract('Redistribution', node_0); - const currentRound = await r_node_0.currentRound(); await expect(r_node_0['isParticipatingInUpcomingRound(address,uint8)'](node_0, depth_0)).to.be.revertedWith( errors.commit.notStaked ); @@ -1171,7 +1149,7 @@ describe('Redistribution', function () { const { proofParams } = await generatedSampling(true); // alter the identifier into random one - proofParams.proof1.socProof![0].identifier = randomBytes(32); + proofParams.proof1.socProof![0].identifier = Uint8Array.from(randomBytes(32)); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1183,7 +1161,7 @@ describe('Redistribution', function () { proofParams.proof1.socProof![0] = await getSocProofAttachment( proofParams.proof1.socProof![0].chunkAddr, - randomBytes(32), + Uint8Array.from(randomBytes(32)), depth ); @@ -1199,7 +1177,7 @@ describe('Redistribution', function () { const index = Buffer.from(proofParams.proof1.postageProof.index); index.writeUInt32BE(2 ** 30, 4); - proofParams.proof1.postageProof.index = index; + proofParams.proof1.postageProof.index = Uint8Array.from(index); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1233,7 +1211,7 @@ describe('Redistribution', function () { const chunkAddr = Buffer.from(proofParams.proof1.proveSegment); const { index, signature, timeStamp } = await constructPostageStamp(batchId, chunkAddr, wallet); - proofParams.proof1.postageProof.postageId = batchId; + proofParams.proof1.postageProof.postageId = Uint8Array.from(batchId); proofParams.proof1.postageProof.signature = signature; proofParams.proof1.postageProof.index = index; proofParams.proof1.postageProof.timeStamp = timeStamp; @@ -1248,7 +1226,7 @@ describe('Redistribution', function () { const index = Buffer.from(proofParams.proof1.postageProof.index); index.writeUInt32BE(0, 0); - proofParams.proof1.postageProof.index = index; + proofParams.proof1.postageProof.index = Uint8Array.from(index); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1260,7 +1238,7 @@ describe('Redistribution', function () { const index = Buffer.from(proofParams.proof1.postageProof.index); index.writeUInt32BE(1, 4); - proofParams.proof1.postageProof.index = index; + proofParams.proof1.postageProof.index = Uint8Array.from(index); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1272,7 +1250,7 @@ describe('Redistribution', function () { it('wrong proof segments for the reserve commitment', async function () { const { proofParams } = await generatedSampling(); - proofParams.proof1.proofSegments[0] = randomBytes(32); + proofParams.proof1.proofSegments[0] = Uint8Array.from(randomBytes(32)); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1282,7 +1260,7 @@ describe('Redistribution', function () { it('wrong proof segments for the original chunk', async function () { const { proofParams } = await generatedSampling(); - proofParams.proof1.proofSegments2[1] = randomBytes(32); + proofParams.proof1.proofSegments2[1] = Uint8Array.from(randomBytes(32)); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1292,7 +1270,7 @@ describe('Redistribution', function () { it('wrong proof segments for the transformed chunk', async function () { const { proofParams } = await generatedSampling(); - proofParams.proof1.proofSegments3[1] = randomBytes(32); + proofParams.proof1.proofSegments3[1] = Uint8Array.from(randomBytes(32)); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1302,7 +1280,7 @@ describe('Redistribution', function () { it('first inclusion proof segment of transformed and original do not match', async function () { const { proofParams } = await generatedSampling(); - proofParams.proof1.proofSegments2[0] = randomBytes(32); + proofParams.proof1.proofSegments2[0] = Uint8Array.from(randomBytes(32)); await expect( r_node_5.claim(proofParams.proof1, proofParams.proof2, proofParams.proofLast) @@ -1424,7 +1402,7 @@ describe('Redistribution', function () { expect(await sr.nodeEffectiveStake(node_1)).to.be.eq(0); }); - it('if both reveal, should select correct winner', async function () { + it('if both reveal, should select a valid winner and pay that node', async function () { const nodesInNeighbourhood = 2; await r_node_1.reveal(depth_5, hash_5, reveal_nonce_1); @@ -1432,10 +1410,16 @@ describe('Redistribution', function () { await mineNBlocks(phaseLength); - expect(await r_node_1.isWinner(overlay_1_n_25)).to.be.false; - expect(await r_node_5.isWinner(overlay_5)).to.be.true; + const node1Won = await r_node_1.isWinner(overlay_1_n_25); + const node5Won = await r_node_5.isWinner(overlay_5); + expect(node1Won).to.not.be.eq(node5Won); - const tx2 = await r_node_5.claim(proof1, proof2, proofLast); + const winnerOwner = node1Won ? node_1 : node_5; + const winnerOverlay = node1Won ? overlay_1_n_25 : overlay_5; + const winnerStake = node1Won ? stakeAmount_1_n_25 : effectiveStakeAmount_5; + const winnerContract = node1Won ? r_node_1 : r_node_5; + + const tx2 = await winnerContract.claim(proof1, proof2, proofLast); const receipt2 = await tx2.wait(); let WinnerSelectedEvent, TruthSelectedEvent, CountCommitsEvent, CountRevealsEvent; @@ -1458,16 +1442,16 @@ describe('Redistribution', function () { (receipt2.blockNumber - copyBatch.tx.blockNumber) * price1 * 2 ** copyBatch.postageDepth + (receipt2.blockNumber - stampCreatedBlock) * price1 * 2 ** batch.depth; // batch in the beforeHook - expect(await token.balanceOf(node_5)).to.be.eq(expectedPotPayout); + expect(await token.balanceOf(winnerOwner)).to.be.eq(expectedPotPayout); expect(CountCommitsEvent.args[0]).to.be.eq(2); expect(CountRevealsEvent.args[0]).to.be.eq(2); - expect(WinnerSelectedEvent.args[0].owner).to.be.eq(node_5); - expect(WinnerSelectedEvent.args[0].overlay).to.be.eq(overlay_5); - expect(WinnerSelectedEvent.args[0].stake).to.be.eq(effectiveStakeAmount_5); + expect(WinnerSelectedEvent.args[0].owner).to.be.eq(winnerOwner); + expect(WinnerSelectedEvent.args[0].overlay).to.be.eq(winnerOverlay); + expect(WinnerSelectedEvent.args[0].stake).to.be.eq(winnerStake); expect(WinnerSelectedEvent.args[0].stakeDensity).to.be.eq( - calculateStakeDensity(effectiveStakeAmount_5, Number(depth_5)) + calculateStakeDensity(winnerStake, Number(depth_5)) ); expect(WinnerSelectedEvent.args[0].hash).to.be.eq(hash_5); expect(WinnerSelectedEvent.args[0].depth).to.be.eq(parseInt(depth_5)); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 46d9fe1a..889c49b1 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -55,7 +55,6 @@ const doubleStakeAmount_0 = '200000000000000000'; const topUpForHeight1 = '100000000000000000'; const stakeAmount_1 = '100000000000000000'; const updateStakeAmount_0 = '633633'; -const updatedStakeAmount_0 = '100000000000633633'; const withdrawAmount = '100000000000000000'; const slashAmount = '50000000000000000'; const partialSlashBalance = '50000000000000000'; @@ -212,6 +211,16 @@ describe('Staking', function () { expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); }); + it('should return balance as effective stake for an unfrozen node', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + await mineNBlocks(1); + + const staked = await srStaker0.stakes(staker_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(staked.balance); + }); + it('should schedule withdrawals and transfer tokens on applyUpdates', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); diff --git a/test/Stats.test.ts b/test/Stats.test.ts index 52fb1330..b77d6d1b 100644 --- a/test/Stats.test.ts +++ b/test/Stats.test.ts @@ -98,7 +98,6 @@ async function nPlayerGames(nodes: string[], stakes: string[], effectiveStakes: for (let i = 0; i < nodes.length; i++) { const r_node = await ethers.getContract('Redistribution', nodes[i]); - const overlay = createOverlay(nodes[i], depth, nonce); await r_node.reveal(depth, sampleHashString, reveal_nonce); } From 035ebc8c572ba2560a38cf853ca6f035a60203cb Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 00:45:00 +0200 Subject: [PATCH 06/58] reconcile slashing --- src/Staking.sol | 49 ++++++++++++++++++++++++++++++++++++++++++++ test/Staking.test.ts | 27 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/Staking.sol b/src/Staking.sol index d6aa616f..353a1d9c 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -275,6 +275,8 @@ contract StakeRegistry is AccessControl, Pausable { } else { delete _stakes[_owner]; } + + _reconcileQueuedWithdrawals(_owner); } emit StakeSlashed(_owner, previousOverlay, _amount); @@ -562,6 +564,53 @@ contract StakeRegistry is AccessControl, Pausable { return _updateQueues[_owner].length - _queueHeads[_owner]; } + function _reconcileQueuedWithdrawals(address _owner) internal { + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + StakeState memory preview = _stakes[_owner]; + + for (uint256 i = head; i < queue.length; ) { + ScheduledUpdate storage scheduled = queue[i]; + + if (scheduled.kind == UpdateKind.CreateDeposit) { + preview.overlay = _deriveOverlay(_owner, scheduled.nonce); + preview.balance = scheduled.amount; + preview.height = scheduled.height; + preview.lastUpdatedBlockNumber = block.number; + preview.initialized = true; + } else if (scheduled.kind == UpdateKind.AddTokens) { + preview.balance += scheduled.amount; + preview.lastUpdatedBlockNumber = block.number; + preview.initialized = true; + } else if (scheduled.kind == UpdateKind.IncreaseHeight) { + if (preview.initialized && scheduled.height > preview.height) { + preview.height = scheduled.height; + preview.lastUpdatedBlockNumber = block.number; + } + } else if (scheduled.kind == UpdateKind.ChangeOverlay) { + if (preview.initialized) { + preview.overlay = _deriveOverlay(_owner, scheduled.nonce); + preview.lastUpdatedBlockNumber = block.number; + } + } else if (scheduled.kind == UpdateKind.WithdrawTokens) { + if (preview.initialized) { + if (scheduled.amount > preview.balance) { + scheduled.amount = preview.balance; + } + + preview.balance -= scheduled.amount; + preview.lastUpdatedBlockNumber = block.number; + } + } else if (scheduled.kind == UpdateKind.ExitStake) { + delete preview; + } + + unchecked { + ++i; + } + } + } + function _pullTokens(address _owner, uint256 _amount) internal { if (_amount == 0) revert InvalidWithdrawalAmount(); if (!ERC20(bzzToken).transferFrom(_owner, address(this), _amount)) revert TransferFailed(); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 889c49b1..135817d9 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -52,11 +52,14 @@ const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000 const obfuscatedHash_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const stakeAmount_0 = '100000000000000000'; const doubleStakeAmount_0 = '200000000000000000'; +const tripleStakeAmount_0 = '300000000000000000'; const topUpForHeight1 = '100000000000000000'; const stakeAmount_1 = '100000000000000000'; const updateStakeAmount_0 = '633633'; const withdrawAmount = '100000000000000000'; +const doubleWithdrawAmount = '200000000000000000'; const slashAmount = '50000000000000000'; +const doubleSlashAmount = '200000000000000000'; const partialSlashBalance = '50000000000000000'; const height_0 = 0; const height_0_n_1 = 1; @@ -358,6 +361,30 @@ describe('Staking', function () { expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); }); + it('should reduce queued withdrawals that exceed the post-slash stake', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, tripleStakeAmount_0, height_0); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); + + await srStaker0.withdraw(doubleWithdrawAmount); + + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.slashDeposit(staker_0, doubleSlashAmount); + + await advanceRounds(); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + + await srStaker0.applyUpdates(staker_0); + + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); + }); + it('should not allow stake migration while unpaused and should include queued deposits when paused', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); From bf76cb6242b340517abe330902b797be21578bc7 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 00:56:30 +0200 Subject: [PATCH 07/58] fix: align queued stake previews with round-based checks Reconcile queued withdrawals after slashing and preview stake state at a specific round so upcoming-round eligibility uses the same round context as the anchor. --- src/Redistribution.sol | 16 +++++-- src/Staking.sol | 90 +++++++++++++++++++++++++++++++++++++ test/Redistribution.test.ts | 15 +++++++ test/Staking.test.ts | 20 +++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 0ba50463..ad318ebb 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -18,9 +18,15 @@ interface IStakeRegistry { function overlayOfAddress(address _owner) external view returns (bytes32); + function overlayOfAddressAtRound(address _owner, uint64 _targetRound) external view returns (bytes32); + function heightOfAddress(address _owner) external view returns (uint8); + function heightOfAddressAtRound(address _owner, uint64 _targetRound) external view returns (uint8); + function nodeEffectiveStake(address _owner) external view returns (uint256); + + function nodeEffectiveStakeAtRound(address _owner, uint64 _targetRound) external view returns (uint256); } /** @@ -805,18 +811,20 @@ contract Redistribution is AccessControl, Pausable { * @param _depth The storage depth the applicant intends to report. */ function isParticipatingInUpcomingRound(address _owner, uint8 _depth) public view returns (bool) { - uint256 _stake = Stakes.nodeEffectiveStake(_owner); - uint8 _depthResponsibility = _depth - Stakes.heightOfAddress(_owner); - if (currentPhaseReveal()) { revert WrongPhase(); } + uint64 targetRound = currentPhaseClaim() ? currentRound() + 1 : currentRound(); + uint256 _stake = Stakes.nodeEffectiveStakeAtRound(_owner, targetRound); + if (_stake == 0) { revert NotStaked(); } - return inProximity(Stakes.overlayOfAddress(_owner), currentRoundAnchor(), _depthResponsibility); + uint8 _depthResponsibility = _depth - Stakes.heightOfAddressAtRound(_owner, targetRound); + + return inProximity(Stakes.overlayOfAddressAtRound(_owner, targetRound), currentRoundAnchor(), _depthResponsibility); } function isParticipatingInCurrentRound(address _owner) external view returns (bool) { diff --git a/src/Staking.sol b/src/Staking.sol index 353a1d9c..c95c46fe 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -113,6 +113,10 @@ contract StakeRegistry is AccessControl, Pausable { _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } + //////////////////////////////////////// + // STATE CHANGING // + //////////////////////////////////////// + function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -306,6 +310,10 @@ contract StakeRegistry is AccessControl, Pausable { _unpause(); } + //////////////////////////////////////// + // STATE READING // + //////////////////////////////////////// + function stakes(address _owner) public view returns (Stake memory) { return _toStakeView(_previewStake(_owner, false)); } @@ -331,6 +339,32 @@ contract StakeRegistry is AccessControl, Pausable { return preview.initialized ? preview.height : 0; } + /** + * @notice Returns the effective stake that would be active in the target round. + */ + function nodeEffectiveStakeAtRound(address _owner, uint64 _targetRound) public view returns (uint256) { + if (!_addressNotFrozenAtRound(_owner, _targetRound)) return 0; + + StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + return preview.initialized ? preview.balance : 0; + } + + /** + * @notice Returns the overlay that would be active in the target round. + */ + function overlayOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (bytes32) { + StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + return preview.initialized ? preview.overlay : bytes32(0); + } + + /** + * @notice Returns the height that would be active in the target round. + */ + function heightOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (uint8) { + StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + return preview.initialized ? preview.height : 0; + } + function currentRound() public view returns (uint64) { return uint64(block.number / ROUND_LENGTH); } @@ -432,6 +466,26 @@ contract StakeRegistry is AccessControl, Pausable { return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); } + function _blocksQueuedWithdrawalExecutionAtRound( + address _owner, + UpdateKind _kind, + uint64 _targetRound + ) internal view returns (bool) { + if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { + return false; + } + + if (!_addressNotFrozenAtRound(_owner, _targetRound)) { + return true; + } + + if (_targetRound <= currentRound()) { + return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); + } + + return false; + } + function _supportsParticipationCheck(address _redistributionContract) internal view returns (bool) { if (_redistributionContract.code.length == 0) { return false; @@ -471,6 +525,29 @@ contract StakeRegistry is AccessControl, Pausable { } } + function _previewStakeAtRound(address _owner, uint64 _targetRound) internal view returns (StakeState memory preview) { + preview = _stakes[_owner]; + + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + + for (uint256 i = head; i < queue.length; ) { + ScheduledUpdate storage scheduled = queue[i]; + if (scheduled.effectiveFromRound > _targetRound) { + break; + } + if (_blocksQueuedWithdrawalExecutionAtRound(_owner, scheduled.kind, _targetRound)) { + break; + } + + preview = _applyPreviewUpdate(_owner, preview, scheduled); + + unchecked { + ++i; + } + } + } + function _applyPreviewUpdate( address _owner, StakeState memory preview, @@ -564,6 +641,19 @@ contract StakeRegistry is AccessControl, Pausable { return _updateQueues[_owner].length - _queueHeads[_owner]; } + function _addressNotFrozenAtRound(address _owner, uint64 _targetRound) internal view returns (bool) { + StakeState storage stake = _stakes[_owner]; + if (!stake.initialized) { + return true; + } + + if (_targetRound <= currentRound()) { + return stake.frozenUntilBlock < block.number; + } + + return stake.frozenUntilBlock < uint256(_targetRound) * ROUND_LENGTH; + } + function _reconcileQueuedWithdrawals(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index 6b9318d8..c40af8b2 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -452,6 +452,21 @@ describe('Redistribution', function () { expect(await redistribution['isParticipatingInUpcomingRound(address,uint8)'](node_3, depth_3)).to.be.true; expect(await redistribution['isParticipatingInUpcomingRound(address,uint8)'](node_4, depth_4)).to.be.true; }); + + it('should use next-round stake state during claim-phase eligibility checks', async function () { + const sr_node_0 = await ethers.getContract('StakeRegistry', node_0); + const r_node_0 = await ethers.getContract('Redistribution', node_0); + + await sr_node_0.exit(); + await mineNBlocks(roundLength + phaseLength * 2); + + expect(await redistribution.currentRound()).to.be.eq(startRoundNumber + 1); + expect(await redistribution.currentPhaseClaim()).to.be.true; + + await expect(r_node_0['isParticipatingInUpcomingRound(address,uint8)'](node_0, depth_0)).to.be.revertedWith( + errors.commit.notStaked + ); + }); }); describe('commit phase with no reveals', async function () { diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 135817d9..8d3b2dad 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -203,6 +203,26 @@ describe('Staking', function () { await expect(srStaker0.increaseHeight(height_0)).to.be.revertedWith(errors.deposit.heightDecrease); }); + it('should preview queued stake state at a target round', async function () { + const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); + await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); + + const currentRound = await srStaker1.currentRound(); + + await mintAndApprove(staker_1, srStaker1.address, topUpForHeight1); + await srStaker1.addTokens(topUpForHeight1); + await srStaker1.changeOverlay(nonce_1_n_25); + await srStaker1.increaseHeight(height_1_n_1); + + expect(await srStaker1.nodeEffectiveStakeAtRound(staker_1, currentRound.add(1))).to.be.eq(stakeAmount_1); + expect(await srStaker1.overlayOfAddressAtRound(staker_1, currentRound.add(1))).to.be.eq(overlay_1); + expect(await srStaker1.heightOfAddressAtRound(staker_1, currentRound.add(1))).to.be.eq(height_1); + + expect(await srStaker1.nodeEffectiveStakeAtRound(staker_1, currentRound.add(2))).to.be.eq(doubleStakeAmount_0); + expect(await srStaker1.overlayOfAddressAtRound(staker_1, currentRound.add(2))).to.be.eq(overlay_1_n_25); + expect(await srStaker1.heightOfAddressAtRound(staker_1, currentRound.add(2))).to.be.eq(height_1_n_1); + }); + it('should keep effective stake equal to balance after oracle price changes', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); From 10197f9110597681a72a93ccc9f8acf7a3307d7c Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 00:58:45 +0200 Subject: [PATCH 08/58] add comments for staking functions --- src/Staking.sol | 142 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/src/Staking.sol b/src/Staking.sol index c95c46fe..01ae1142 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -17,10 +17,14 @@ interface IRedistribution { */ contract StakeRegistry is AccessControl, Pausable { + // ----------------------------- State variables ------------------------------ + uint256 private constant ROUND_LENGTH = 152; uint256 private constant MIN_STAKE = 100000000000000000; uint256 private constant UPDATE_QUEUE_MAX_LENGTH = 10; + // ----------------------------- Type declarations ------------------------------ + enum UpdateKind { CreateDeposit, AddTokens, @@ -68,6 +72,8 @@ contract StakeRegistry is AccessControl, Pausable { uint64 public immutable WAIT_WITHDRAWAL; address public redistributionContract; + // ----------------------------- Events ------------------------------ + event DepositCreated( address indexed owner, uint64 registeredFromRound, @@ -82,6 +88,8 @@ contract StakeRegistry is AccessControl, Pausable { event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); event StakeFrozen(address frozen, bytes32 overlay, uint256 time); + // ----------------------------- Errors ------------------------------ + error TransferFailed(); error Frozen(); error Unauthorized(); @@ -117,6 +125,12 @@ contract StakeRegistry is AccessControl, Pausable { // STATE CHANGING // //////////////////////////////////////// + /** + * @notice Schedules a new deposit to become active after the base delay. + * @param _setNonce The nonce used to derive the overlay. + * @param _amount The amount of BZZ to lock. + * @param _height The initial staking height. + */ function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -139,6 +153,10 @@ contract StakeRegistry is AccessControl, Pausable { emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); } + /** + * @notice Schedules an increase of the caller's stake balance. + * @param _amount The amount of BZZ to add to the stake. + */ function addTokens(uint256 _amount) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -151,6 +169,10 @@ contract StakeRegistry is AccessControl, Pausable { emit TokensAdded(msg.sender, effectiveFromRound, _amount); } + /** + * @notice Schedules an overlay change after the configured overlay delay. + * @param _setNonce The nonce used to derive the new overlay. + */ function changeOverlay(bytes32 _setNonce) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -172,6 +194,10 @@ contract StakeRegistry is AccessControl, Pausable { emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); } + /** + * @notice Schedules a height increase once the base delay elapses. + * @param _height The new staking height. + */ function increaseHeight(uint8 _height) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -185,6 +211,10 @@ contract StakeRegistry is AccessControl, Pausable { emit HeightIncreased(msg.sender, effectiveFromRound, _height); } + /** + * @notice Schedules a partial withdrawal after the withdrawal delay. + * @param _amount The amount of BZZ to withdraw from the stake. + */ function withdraw(uint256 _amount) external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); if (_amount == 0) revert InvalidWithdrawalAmount(); @@ -205,6 +235,9 @@ contract StakeRegistry is AccessControl, Pausable { emit Withdrawal(msg.sender, effectiveFromRound, _amount); } + /** + * @notice Schedules a full exit after the withdrawal delay. + */ function exit() external whenNotPaused { if (!addressNotFrozen(msg.sender)) revert Frozen(); @@ -215,10 +248,18 @@ contract StakeRegistry is AccessControl, Pausable { emit Withdrawal(msg.sender, effectiveFromRound, plannedStake.balance); } + /** + * @notice Applies all updates that are ready for the given owner. + * @param _owner The address whose queue should be processed. + */ function applyUpdates(address _owner) public { _applyReadyUpdates(_owner); } + /** + * @notice Withdraws active and queued stake while the contract is paused. + * @dev Used for migration flows where queued deposits and top ups must be returned. + */ function migrateStake() external whenPaused { _applyReadyUpdates(msg.sender); @@ -246,6 +287,11 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Freezes a stake and blocks queued withdrawals while the freeze lasts. + * @param _owner The staker to freeze. + * @param _time The freeze duration in blocks. + */ function freezeDeposit(address _owner, uint256 _time) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); @@ -261,6 +307,11 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Slashes the active stake and reconciles queued withdrawals if needed. + * @param _owner The staker to slash. + * @param _amount The amount to slash from the active stake. + */ function slashDeposit(address _owner, uint256 _amount) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); @@ -286,11 +337,19 @@ contract StakeRegistry is AccessControl, Pausable { emit StakeSlashed(_owner, previousOverlay, _amount); } + /** + * @notice Updates the Swarm network identifier used in overlay derivation. + * @param _NetworkId The new network id. + */ function changeNetworkId(uint64 _NetworkId) external { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); NetworkId = _NetworkId; } + /** + * @notice Relinks the redistribution contract after validating its interface and role. + * @param _redistributionContract The new redistribution contract address. + */ function setRedistributionContract(address _redistributionContract) external { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); if ( @@ -300,11 +359,17 @@ contract StakeRegistry is AccessControl, Pausable { redistributionContract = _redistributionContract; } + /** + * @notice Pauses staking mutations. + */ function pause() public { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); _pause(); } + /** + * @notice Unpauses staking mutations. + */ function unPause() public { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); _unpause(); @@ -314,10 +379,16 @@ contract StakeRegistry is AccessControl, Pausable { // STATE READING // //////////////////////////////////////// + /** + * @notice Returns the currently visible stake state for an owner. + */ function stakes(address _owner) public view returns (Stake memory) { return _toStakeView(_previewStake(_owner, false)); } + /** + * @notice Returns the currently effective stake balance for an owner. + */ function nodeEffectiveStake(address _owner) public view returns (uint256) { if (!addressNotFrozen(_owner)) return 0; @@ -325,15 +396,24 @@ contract StakeRegistry is AccessControl, Pausable { return preview.initialized ? preview.balance : 0; } + /** + * @notice Returns the last block where the active stake was updated. + */ function lastUpdatedBlockNumberOfAddress(address _owner) public view returns (uint256) { return _stakes[_owner].initialized ? _stakes[_owner].lastUpdatedBlockNumber : 0; } + /** + * @notice Returns the currently effective overlay for an owner. + */ function overlayOfAddress(address _owner) public view returns (bytes32) { StakeState memory preview = _previewStake(_owner, false); return preview.initialized ? preview.overlay : bytes32(0); } + /** + * @notice Returns the currently effective height for an owner. + */ function heightOfAddress(address _owner) public view returns (uint8) { StakeState memory preview = _previewStake(_owner, false); return preview.initialized ? preview.height : 0; @@ -365,15 +445,25 @@ contract StakeRegistry is AccessControl, Pausable { return preview.initialized ? preview.height : 0; } + /** + * @notice Returns the current staking round derived from block height. + */ function currentRound() public view returns (uint64) { return uint64(block.number / ROUND_LENGTH); } + /** + * @notice Returns true when the owner is not currently frozen. + */ function addressNotFrozen(address _owner) internal view returns (bool) { StakeState storage stake = _stakes[_owner]; return !stake.initialized || stake.frozenUntilBlock < block.number; } + /** + * @notice Applies all queued updates that are effective in the current round. + * @dev Withdrawals and exits are deferred while the node is frozen or active in the current round. + */ function _applyReadyUpdates(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; @@ -398,6 +488,9 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Applies a single queued update to storage. + */ function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { StakeState storage stake = _stakes[_owner]; @@ -454,6 +547,9 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Returns true when a queued withdrawal or exit must stay pending for the current round. + */ function _blocksQueuedWithdrawalExecution(address _owner, UpdateKind _kind) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; @@ -466,6 +562,9 @@ contract StakeRegistry is AccessControl, Pausable { return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); } + /** + * @notice Returns true when a queued withdrawal or exit would still be blocked in the target round. + */ function _blocksQueuedWithdrawalExecutionAtRound( address _owner, UpdateKind _kind, @@ -486,6 +585,9 @@ contract StakeRegistry is AccessControl, Pausable { return false; } + /** + * @notice Validates that the redistribution contract exposes the participation check. + */ function _supportsParticipationCheck(address _redistributionContract) internal view returns (bool) { if (_redistributionContract.code.length == 0) { return false; @@ -498,6 +600,9 @@ contract StakeRegistry is AccessControl, Pausable { return success; } + /** + * @notice Previews stake state using the current round, optionally including future queued updates. + */ function _previewStake( address _owner, bool includeFutureUpdates @@ -525,6 +630,9 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Previews stake state as it would look in a specific target round. + */ function _previewStakeAtRound(address _owner, uint64 _targetRound) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; @@ -548,6 +656,9 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Applies a single queued update to an in-memory preview state. + */ function _applyPreviewUpdate( address _owner, StakeState memory preview, @@ -604,6 +715,9 @@ contract StakeRegistry is AccessControl, Pausable { return preview; } + /** + * @notice Appends a new queued update and assigns the first valid effective round. + */ function _enqueueUpdate( address _owner, UpdateKind _kind, @@ -629,6 +743,9 @@ contract StakeRegistry is AccessControl, Pausable { ); } + /** + * @notice Returns the effective round of the last queued update. + */ function _lastScheduledRound(address _owner) internal view returns (uint64) { ScheduledUpdate[] storage queue = _updateQueues[_owner]; if (_queueHeads[_owner] == queue.length) { @@ -637,10 +754,16 @@ contract StakeRegistry is AccessControl, Pausable { return queue[queue.length - 1].effectiveFromRound; } + /** + * @notice Returns the number of pending queued updates. + */ function _queueLength(address _owner) internal view returns (uint256) { return _updateQueues[_owner].length - _queueHeads[_owner]; } + /** + * @notice Returns true when the owner would be unfrozen by the target round. + */ function _addressNotFrozenAtRound(address _owner, uint64 _targetRound) internal view returns (bool) { StakeState storage stake = _stakes[_owner]; if (!stake.initialized) { @@ -654,6 +777,10 @@ contract StakeRegistry is AccessControl, Pausable { return stake.frozenUntilBlock < uint256(_targetRound) * ROUND_LENGTH; } + /** + * @notice Shrinks queued withdrawals when slashing leaves less balance than they expect. + * @dev This preserves queue order while preventing later withdrawals from overpaying the owner. + */ function _reconcileQueuedWithdrawals(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; @@ -701,19 +828,31 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Pulls BZZ into the staking contract. + */ function _pullTokens(address _owner, uint256 _amount) internal { if (_amount == 0) revert InvalidWithdrawalAmount(); if (!ERC20(bzzToken).transferFrom(_owner, address(this), _amount)) revert TransferFailed(); } + /** + * @notice Returns the minimum stake required for a given height. + */ function _minimumStakeForHeight(uint8 _height) internal pure returns (uint256) { return MIN_STAKE * (2 ** _height); } + /** + * @notice Derives an overlay from owner, network id and nonce. + */ function _deriveOverlay(address _owner, bytes32 _setNonce) internal view returns (bytes32) { return keccak256(abi.encodePacked(_owner, reverse(NetworkId), _setNonce)); } + /** + * @notice Converts internal stake state into the public view struct. + */ function _toStakeView(StakeState memory _stake) internal pure returns (Stake memory) { if (!_stake.initialized) { return Stake({overlay: 0, balance: 0, lastUpdatedBlockNumber: 0, frozenUntilBlock: 0, height: 0}); @@ -729,6 +868,9 @@ contract StakeRegistry is AccessControl, Pausable { }); } + /** + * @notice Reverses byte order for network id encoding in overlay derivation. + */ function reverse(uint64 input) internal pure returns (uint64 v) { v = input; From f5c74b8c897d701f1cebe2d91f83f129d5ed23c9 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 01:10:16 +0200 Subject: [PATCH 09/58] fix: close exit queues and extend withdrawal delay Prevent new stake updates from being enqueued after an exit is scheduled, and align withdrawal waits on real networks with the intended 28-day round window while keeping local settings fast. --- helper-hardhat-config.ts | 11 +++++++---- src/Staking.sol | 6 ++++++ test/Staking.test.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/helper-hardhat-config.ts b/helper-hardhat-config.ts index 2e22695f..ac60e778 100644 --- a/helper-hardhat-config.ts +++ b/helper-hardhat-config.ts @@ -10,6 +10,9 @@ export interface networkConfigInfo { [key: string]: networkConfigItem; } +const ROUNDS_PER_DAY = 114; +const WITHDRAWAL_WAIT_ROUNDS = ROUNDS_PER_DAY * 28; + export const networkConfig: networkConfigInfo = { localhost: { swarmNetworkId: 0, @@ -38,7 +41,7 @@ export const networkConfig: networkConfigInfo = { multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', stakeWaitBase: 2, stakeWaitOverlayChange: 2, - stakeWaitWithdrawal: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, testnet: { blockConfirmations: 6, @@ -46,7 +49,7 @@ export const networkConfig: networkConfigInfo = { multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', stakeWaitBase: 2, stakeWaitOverlayChange: 2, - stakeWaitWithdrawal: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, tenderly: { blockConfirmations: 1, @@ -54,7 +57,7 @@ export const networkConfig: networkConfigInfo = { multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', stakeWaitBase: 2, stakeWaitOverlayChange: 2, - stakeWaitWithdrawal: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, mainnet: { blockConfirmations: 6, @@ -62,7 +65,7 @@ export const networkConfig: networkConfigInfo = { multisig: '0xD5C070FEb5EA883063c183eDFF10BA6836cf9816', stakeWaitBase: 2, stakeWaitOverlayChange: 2, - stakeWaitWithdrawal: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, }; diff --git a/src/Staking.sol b/src/Staking.sol index 01ae1142..91667594 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -62,6 +62,7 @@ contract StakeRegistry is AccessControl, Pausable { mapping(address => StakeState) private _stakes; mapping(address => ScheduledUpdate[]) private _updateQueues; mapping(address => uint256) private _queueHeads; + mapping(address => bool) private _queueClosed; bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); @@ -101,6 +102,7 @@ contract StakeRegistry is AccessControl, Pausable { error HeightDecreaseNotAllowed(); error InvalidWithdrawalAmount(); error UpdateQueueFull(); + error QueueClosed(); error InvalidRedistributionContract(); constructor( @@ -245,6 +247,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); + _queueClosed[msg.sender] = true; emit Withdrawal(msg.sender, effectiveFromRound, plannedStake.balance); } @@ -281,6 +284,7 @@ contract StakeRegistry is AccessControl, Pausable { delete _stakes[msg.sender]; delete _updateQueues[msg.sender]; delete _queueHeads[msg.sender]; + delete _queueClosed[msg.sender]; if (payout > 0) { if (!ERC20(bzzToken).transfer(msg.sender, payout)) revert TransferFailed(); @@ -483,6 +487,7 @@ contract StakeRegistry is AccessControl, Pausable { if (head == queue.length) { delete _updateQueues[_owner]; delete _queueHeads[_owner]; + delete _queueClosed[_owner]; } else { _queueHeads[_owner] = head; } @@ -726,6 +731,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 _amount, uint8 _height ) internal returns (uint64 effectiveFromRound) { + if (_queueClosed[_owner]) revert QueueClosed(); if (_queueLength(_owner) >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(); uint64 candidateRound = currentRound() + _minimumWait; diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 8d3b2dad..fd7ca54e 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -40,6 +40,7 @@ const errors = { }, general: { invalidRedistribution: 'InvalidRedistributionContract()', + queueClosed: 'QueueClosed()', }, }; @@ -333,6 +334,18 @@ describe('Staking', function () { expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); }); + it('should not allow new updates to be queued after exit is scheduled', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + await srStaker0.exit(); + + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_1, stakeAmount_0, height_0)).to.be.revertedWith( + errors.general.queueClosed + ); + }); + it('should not allow invalid withdrawals', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await expect(srStaker0.withdraw(0)).to.be.revertedWith(errors.withdraw.invalid); From 01bc6ffcda48211e6180d6b12ce82c627f41f3fb Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 14 Apr 2026 12:24:53 +0200 Subject: [PATCH 10/58] formatting --- src/Redistribution.sol | 7 ++++++- src/Staking.sol | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index ad318ebb..2d7787e6 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -824,7 +824,12 @@ contract Redistribution is AccessControl, Pausable { uint8 _depthResponsibility = _depth - Stakes.heightOfAddressAtRound(_owner, targetRound); - return inProximity(Stakes.overlayOfAddressAtRound(_owner, targetRound), currentRoundAnchor(), _depthResponsibility); + return + inProximity( + Stakes.overlayOfAddressAtRound(_owner, targetRound), + currentRoundAnchor(), + _depthResponsibility + ); } function isParticipatingInCurrentRound(address _owner) external view returns (bool) { diff --git a/src/Staking.sol b/src/Staking.sol index 91667594..108ab87b 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -638,7 +638,10 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Previews stake state as it would look in a specific target round. */ - function _previewStakeAtRound(address _owner, uint64 _targetRound) internal view returns (StakeState memory preview) { + function _previewStakeAtRound( + address _owner, + uint64 _targetRound + ) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; ScheduledUpdate[] storage queue = _updateQueues[_owner]; From d88be867a72078e96fa53e7b4f6f963ae4a0a6ae Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 15 Apr 2026 12:25:12 +0200 Subject: [PATCH 11/58] refactor: simplify stake initialization state Use overlay presence as the stake initialization check and remove the dead lastUpdatedBlockNumber field and related test assertions. --- src/Redistribution.sol | 2 - src/Staking.sol | 110 +++++++++++++++++------------------------ test/Staking.test.ts | 6 --- 3 files changed, 44 insertions(+), 74 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 2d7787e6..5a0b5757 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -14,8 +14,6 @@ interface IPriceOracle { interface IStakeRegistry { function freezeDeposit(address _owner, uint256 _time) external; - function lastUpdatedBlockNumberOfAddress(address _owner) external view returns (uint256); - function overlayOfAddress(address _owner) external view returns (bytes32); function overlayOfAddressAtRound(address _owner, uint64 _targetRound) external view returns (bytes32); diff --git a/src/Staking.sol b/src/Staking.sol index 108ab87b..a737e6d5 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -37,7 +37,6 @@ contract StakeRegistry is AccessControl, Pausable { struct Stake { bytes32 overlay; uint256 balance; - uint256 lastUpdatedBlockNumber; uint256 frozenUntilBlock; uint8 height; } @@ -45,10 +44,8 @@ contract StakeRegistry is AccessControl, Pausable { struct StakeState { bytes32 overlay; uint256 balance; - uint256 lastUpdatedBlockNumber; uint256 frozenUntilBlock; uint8 height; - bool initialized; } struct ScheduledUpdate { @@ -137,7 +134,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (plannedStake.initialized && plannedStake.balance > 0) revert AlreadyStaked(); + if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); if (_amount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); @@ -163,7 +160,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); _pullTokens(msg.sender, _amount); uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); @@ -179,7 +176,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) return; @@ -204,7 +201,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); if (_height == plannedStake.height) return; if (plannedStake.balance < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); @@ -222,7 +219,7 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount == 0) revert InvalidWithdrawalAmount(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_amount >= plannedStake.balance) revert BelowMinimumStake(); if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); @@ -244,7 +241,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(msg.sender)) revert Frozen(); StakeState memory plannedStake = _previewStake(msg.sender, true); - if (!plannedStake.initialized || plannedStake.balance == 0) revert NotStaked(); + if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); _queueClosed[msg.sender] = true; @@ -299,14 +296,14 @@ contract StakeRegistry is AccessControl, Pausable { function freezeDeposit(address _owner, uint256 _time) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - if (!_stakes[_owner].initialized && _queueLength(_owner) == 0) { + if (!_isInitialized(_owner) && _queueLength(_owner) == 0) { return; } _stakes[_owner].frozenUntilBlock = block.number + _time; _applyReadyUpdates(_owner); - if (_stakes[_owner].initialized) { + if (_isInitialized(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); } } @@ -324,13 +321,11 @@ contract StakeRegistry is AccessControl, Pausable { StakeState storage stake = _stakes[_owner]; bytes32 previousOverlay = stake.overlay; - if (stake.initialized) { + if (_isInitialized(_owner)) { if (stake.balance > _amount) { stake.balance -= _amount; - stake.lastUpdatedBlockNumber = block.number; } else if (_queueLength(_owner) > 0) { stake.balance = 0; - stake.lastUpdatedBlockNumber = block.number; } else { delete _stakes[_owner]; } @@ -397,14 +392,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!addressNotFrozen(_owner)) return 0; StakeState memory preview = _previewStake(_owner, false); - return preview.initialized ? preview.balance : 0; - } - - /** - * @notice Returns the last block where the active stake was updated. - */ - function lastUpdatedBlockNumberOfAddress(address _owner) public view returns (uint256) { - return _stakes[_owner].initialized ? _stakes[_owner].lastUpdatedBlockNumber : 0; + return _isInitialized(preview) ? preview.balance : 0; } /** @@ -412,7 +400,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function overlayOfAddress(address _owner) public view returns (bytes32) { StakeState memory preview = _previewStake(_owner, false); - return preview.initialized ? preview.overlay : bytes32(0); + return _isInitialized(preview) ? preview.overlay : bytes32(0); } /** @@ -420,7 +408,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function heightOfAddress(address _owner) public view returns (uint8) { StakeState memory preview = _previewStake(_owner, false); - return preview.initialized ? preview.height : 0; + return _isInitialized(preview) ? preview.height : 0; } /** @@ -430,7 +418,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!_addressNotFrozenAtRound(_owner, _targetRound)) return 0; StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); - return preview.initialized ? preview.balance : 0; + return _isInitialized(preview) ? preview.balance : 0; } /** @@ -438,7 +426,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function overlayOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (bytes32) { StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); - return preview.initialized ? preview.overlay : bytes32(0); + return _isInitialized(preview) ? preview.overlay : bytes32(0); } /** @@ -446,7 +434,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function heightOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (uint8) { StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); - return preview.initialized ? preview.height : 0; + return _isInitialized(preview) ? preview.height : 0; } /** @@ -460,8 +448,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when the owner is not currently frozen. */ function addressNotFrozen(address _owner) internal view returns (bool) { - StakeState storage stake = _stakes[_owner]; - return !stake.initialized || stake.frozenUntilBlock < block.number; + return !_isInitialized(_owner) || _stakes[_owner].frozenUntilBlock < block.number; } /** @@ -503,42 +490,35 @@ contract StakeRegistry is AccessControl, Pausable { stake.overlay = _deriveOverlay(_owner, scheduled.nonce); stake.balance = scheduled.amount; stake.height = scheduled.height; - stake.lastUpdatedBlockNumber = block.number; - stake.initialized = true; return; } if (scheduled.kind == UpdateKind.AddTokens) { stake.balance += scheduled.amount; - stake.lastUpdatedBlockNumber = block.number; - stake.initialized = true; return; } if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (stake.initialized && scheduled.height > stake.height) { + if (_isInitialized(_owner) && scheduled.height > stake.height) { stake.height = scheduled.height; - stake.lastUpdatedBlockNumber = block.number; } return; } if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (stake.initialized) { + if (_isInitialized(_owner)) { stake.overlay = _deriveOverlay(_owner, scheduled.nonce); - stake.lastUpdatedBlockNumber = block.number; } return; } if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (stake.initialized) { + if (_isInitialized(_owner)) { if (scheduled.amount >= stake.balance) { stake.balance = 0; } else { stake.balance -= scheduled.amount; } - stake.lastUpdatedBlockNumber = block.number; if (!ERC20(bzzToken).transfer(_owner, scheduled.amount)) revert TransferFailed(); } @@ -676,42 +656,35 @@ contract StakeRegistry is AccessControl, Pausable { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; preview.height = scheduled.height; - preview.lastUpdatedBlockNumber = block.number; - preview.initialized = true; return preview; } if (scheduled.kind == UpdateKind.AddTokens) { preview.balance += scheduled.amount; - preview.lastUpdatedBlockNumber = block.number; - preview.initialized = true; return preview; } if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (preview.initialized && scheduled.height > preview.height) { + if (_isInitialized(preview) && scheduled.height > preview.height) { preview.height = scheduled.height; - preview.lastUpdatedBlockNumber = block.number; } return preview; } if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (preview.initialized) { + if (_isInitialized(preview)) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); - preview.lastUpdatedBlockNumber = block.number; } return preview; } if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (preview.initialized) { + if (_isInitialized(preview)) { if (scheduled.amount >= preview.balance) { preview.balance = 0; } else { preview.balance -= scheduled.amount; } - preview.lastUpdatedBlockNumber = block.number; } return preview; } @@ -774,16 +747,15 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when the owner would be unfrozen by the target round. */ function _addressNotFrozenAtRound(address _owner, uint64 _targetRound) internal view returns (bool) { - StakeState storage stake = _stakes[_owner]; - if (!stake.initialized) { + if (!_isInitialized(_owner)) { return true; } if (_targetRound <= currentRound()) { - return stake.frozenUntilBlock < block.number; + return _stakes[_owner].frozenUntilBlock < block.number; } - return stake.frozenUntilBlock < uint256(_targetRound) * ROUND_LENGTH; + return _stakes[_owner].frozenUntilBlock < uint256(_targetRound) * ROUND_LENGTH; } /** @@ -802,30 +774,23 @@ contract StakeRegistry is AccessControl, Pausable { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; preview.height = scheduled.height; - preview.lastUpdatedBlockNumber = block.number; - preview.initialized = true; } else if (scheduled.kind == UpdateKind.AddTokens) { preview.balance += scheduled.amount; - preview.lastUpdatedBlockNumber = block.number; - preview.initialized = true; } else if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (preview.initialized && scheduled.height > preview.height) { + if (_isInitialized(preview) && scheduled.height > preview.height) { preview.height = scheduled.height; - preview.lastUpdatedBlockNumber = block.number; } } else if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (preview.initialized) { + if (_isInitialized(preview)) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); - preview.lastUpdatedBlockNumber = block.number; } } else if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (preview.initialized) { + if (_isInitialized(preview)) { if (scheduled.amount > preview.balance) { scheduled.amount = preview.balance; } preview.balance -= scheduled.amount; - preview.lastUpdatedBlockNumber = block.number; } } else if (scheduled.kind == UpdateKind.ExitStake) { delete preview; @@ -863,20 +828,33 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Converts internal stake state into the public view struct. */ function _toStakeView(StakeState memory _stake) internal pure returns (Stake memory) { - if (!_stake.initialized) { - return Stake({overlay: 0, balance: 0, lastUpdatedBlockNumber: 0, frozenUntilBlock: 0, height: 0}); + if (!_isInitialized(_stake)) { + return Stake({overlay: 0, balance: 0, frozenUntilBlock: 0, height: 0}); } return Stake({ overlay: _stake.overlay, balance: _stake.balance, - lastUpdatedBlockNumber: _stake.lastUpdatedBlockNumber, frozenUntilBlock: _stake.frozenUntilBlock, height: _stake.height }); } + /** + * @notice Returns true when the stored stake for an owner is initialized. + */ + function _isInitialized(address _owner) internal view returns (bool) { + return _stakes[_owner].overlay != bytes32(0); + } + + /** + * @notice Returns true when an in-memory stake state is initialized. + */ + function _isInitialized(StakeState memory _stake) internal pure returns (bool) { + return _stake.overlay != bytes32(0); + } + /** * @notice Reverses byte order for network id encoding in overlay derivation. */ diff --git a/test/Staking.test.ts b/test/Staking.test.ts index fd7ca54e..fa37e5ae 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -154,10 +154,6 @@ describe('Staking', function () { expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); expect(await srStaker0.overlayOfAddress(staker_0)).to.be.eq(overlay_0); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); - expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); - - await srStaker0.applyUpdates(staker_0); - expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.not.be.eq(0); }); it('should not allow first stake below minimum for the requested height', async function () { @@ -331,7 +327,6 @@ describe('Staking', function () { expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); expect(stakedAfter.overlay).to.be.eq(zeroBytes32); expect(stakedAfter.balance).to.be.eq(0); - expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); }); it('should not allow new updates to be queued after exit is scheduled', async function () { @@ -391,7 +386,6 @@ describe('Staking', function () { await stakeRegistryRedistributor.slashDeposit(staker_0, partialSlashBalance); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); - expect(await srStaker0.lastUpdatedBlockNumberOfAddress(staker_0)).to.be.eq(0); }); it('should reduce queued withdrawals that exceed the post-slash stake', async function () { From 9b5d2717e41deee20c0e092477a7b21bfb037b1b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 15 Apr 2026 12:52:26 +0200 Subject: [PATCH 12/58] fix: execute effective stake withdrawals without redistribution relinking Allow effective withdrawals and exits to execute without current-round participation blocking them, and remove the admin-controlled redistribution hook from staking and deployment wiring. --- deploy/local/003_deploy_staking.ts | 6 --- deploy/local/004_deploy_redistribution.ts | 11 +---- deploy/main/003_deploy_staking.ts | 6 --- deploy/main/004_deploy_redistribution.ts | 11 +---- deploy/test/003_deploy_staking.ts | 6 --- deploy/test/004_deploy_redistribution.ts | 11 +---- src/Redistribution.sol | 23 +-------- src/Staking.sol | 57 ++-------------------- test/Staking.test.ts | 59 +++++++++++++++-------- 9 files changed, 51 insertions(+), 139 deletions(-) diff --git a/deploy/local/003_deploy_staking.ts b/deploy/local/003_deploy_staking.ts index a3b08f1c..42bc25be 100644 --- a/deploy/local/003_deploy_staking.ts +++ b/deploy/local/003_deploy_staking.ts @@ -1,5 +1,4 @@ import { DeployFunction } from 'hardhat-deploy/types'; -import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -9,14 +8,9 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); - const redistributionAddress = ethers.utils.getContractAddress({ - from: deployer, - nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, - }); const args = [ token.address, - redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/local/004_deploy_redistribution.ts b/deploy/local/004_deploy_redistribution.ts index 3779a90b..af67d412 100644 --- a/deploy/local/004_deploy_redistribution.ts +++ b/deploy/local/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, read, log } = deployments; + const { deploy, get, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,20 +11,13 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - const redistribution = await deploy('Redistribution', { + await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 1, }); - const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); - if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { - throw new Error( - `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` - ); - } - log('----------------------------------------------------'); }; diff --git a/deploy/main/003_deploy_staking.ts b/deploy/main/003_deploy_staking.ts index 4b11d18d..7c65be2e 100644 --- a/deploy/main/003_deploy_staking.ts +++ b/deploy/main/003_deploy_staking.ts @@ -1,5 +1,4 @@ import { DeployFunction } from 'hardhat-deploy/types'; -import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -8,14 +7,9 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const config = networkConfig[network.name] || {}; const swarmNetworkID = config.swarmNetworkId; const token = await get('Token'); - const redistributionAddress = ethers.utils.getContractAddress({ - from: deployer, - nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, - }); const args = [ token.address, - redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/main/004_deploy_redistribution.ts b/deploy/main/004_deploy_redistribution.ts index aeb164b6..9943e0c0 100644 --- a/deploy/main/004_deploy_redistribution.ts +++ b/deploy/main/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, read, log } = deployments; + const { deploy, get, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,20 +11,13 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - const redistribution = await deploy('Redistribution', { + await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 6, }); - const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); - if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { - throw new Error( - `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` - ); - } - log('----------------------------------------------------'); }; diff --git a/deploy/test/003_deploy_staking.ts b/deploy/test/003_deploy_staking.ts index 3e3df157..b82b2f20 100644 --- a/deploy/test/003_deploy_staking.ts +++ b/deploy/test/003_deploy_staking.ts @@ -1,5 +1,4 @@ import { DeployFunction } from 'hardhat-deploy/types'; -import { ethers } from 'hardhat'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { @@ -8,14 +7,9 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne const config = networkConfig[network.name] || {}; const swarmNetworkID = config.swarmNetworkId; const token = await get('TestToken'); - const redistributionAddress = ethers.utils.getContractAddress({ - from: deployer, - nonce: (await ethers.provider.getTransactionCount(deployer)) + 1, - }); const args = [ token.address, - redistributionAddress, swarmNetworkID, config.stakeWaitBase || 2, config.stakeWaitOverlayChange || 2, diff --git a/deploy/test/004_deploy_redistribution.ts b/deploy/test/004_deploy_redistribution.ts index aeb164b6..9943e0c0 100644 --- a/deploy/test/004_deploy_redistribution.ts +++ b/deploy/test/004_deploy_redistribution.ts @@ -2,7 +2,7 @@ import { DeployFunction } from 'hardhat-deploy/types'; import { networkConfig } from '../../helper-hardhat-config'; const func: DeployFunction = async function ({ deployments, getNamedAccounts, network }) { - const { deploy, get, read, log } = deployments; + const { deploy, get, log } = deployments; const { deployer } = await getNamedAccounts(); const args = [ @@ -11,20 +11,13 @@ const func: DeployFunction = async function ({ deployments, getNamedAccounts, ne (await get('PriceOracle')).address, ]; - const redistribution = await deploy('Redistribution', { + await deploy('Redistribution', { from: deployer, args: args, log: true, waitConfirmations: networkConfig[network.name]?.blockConfirmations || 6, }); - const configuredRedistribution = await read('StakeRegistry', 'redistributionContract'); - if (configuredRedistribution.toLowerCase() !== redistribution.address.toLowerCase()) { - throw new Error( - `StakeRegistry redistribution mismatch: expected ${redistribution.address}, got ${configuredRedistribution}` - ); - } - log('----------------------------------------------------'); }; diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 5a0b5757..f7d64c66 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -805,6 +805,8 @@ contract Redistribution is AccessControl, Pausable { /** * @notice Determine if a the owner of a given overlay can participate in the upcoming round. + * @dev This method is part of the external interface used by Bee nodes to pre-check + * eligibility, so it must remain available even when it is not referenced by on-chain code. * @param _owner The address of the applicant from. * @param _depth The storage depth the applicant intends to report. */ @@ -830,27 +832,6 @@ contract Redistribution is AccessControl, Pausable { ); } - function isParticipatingInCurrentRound(address _owner) external view returns (bool) { - uint64 cr = currentRound(); - - if (currentCommitRound != cr) { - return false; - } - - uint256 commitsArrayLength = currentCommits.length; - for (uint256 i = 0; i < commitsArrayLength; ) { - if (currentCommits[i].owner == _owner) { - return true; - } - - unchecked { - ++i; - } - } - - return false; - } - // ----------------------------- Reveal ------------------------------ /** diff --git a/src/Staking.sol b/src/Staking.sol index a737e6d5..66480d27 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -4,10 +4,6 @@ import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; -interface IRedistribution { - function isParticipatingInCurrentRound(address _owner) external view returns (bool); -} - /** * @title Staking contract for the Swarm storage incentives * @author The Swarm Authors @@ -68,7 +64,6 @@ contract StakeRegistry is AccessControl, Pausable { uint64 public immutable WAIT_BASE; uint64 public immutable WAIT_OVERLAY_CHANGE; uint64 public immutable WAIT_WITHDRAWAL; - address public redistributionContract; // ----------------------------- Events ------------------------------ @@ -100,20 +95,16 @@ contract StakeRegistry is AccessControl, Pausable { error InvalidWithdrawalAmount(); error UpdateQueueFull(); error QueueClosed(); - error InvalidRedistributionContract(); constructor( address _bzzToken, - address _redistributionContract, uint64 _NetworkId, uint64 _waitBase, uint64 _waitOverlayChange, uint64 _waitWithdrawal ) { - if (_redistributionContract == address(0)) revert InvalidRedistributionContract(); NetworkId = _NetworkId; bzzToken = _bzzToken; - redistributionContract = _redistributionContract; WAIT_BASE = _waitBase; WAIT_OVERLAY_CHANGE = _waitOverlayChange; WAIT_WITHDRAWAL = _waitWithdrawal; @@ -345,19 +336,6 @@ contract StakeRegistry is AccessControl, Pausable { NetworkId = _NetworkId; } - /** - * @notice Relinks the redistribution contract after validating its interface and role. - * @param _redistributionContract The new redistribution contract address. - */ - function setRedistributionContract(address _redistributionContract) external { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); - if ( - !hasRole(REDISTRIBUTOR_ROLE, _redistributionContract) || - !_supportsParticipationCheck(_redistributionContract) - ) revert InvalidRedistributionContract(); - redistributionContract = _redistributionContract; - } - /** * @notice Pauses staking mutations. */ @@ -453,7 +431,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies all queued updates that are effective in the current round. - * @dev Withdrawals and exits are deferred while the node is frozen or active in the current round. + * @dev Withdrawals and exits are deferred only while the node is frozen. */ function _applyReadyUpdates(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; @@ -534,21 +512,19 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Returns true when a queued withdrawal or exit must stay pending for the current round. + * @dev Current-round participation does not block execution once the withdrawal or exit is effective. */ function _blocksQueuedWithdrawalExecution(address _owner, UpdateKind _kind) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } - if (!addressNotFrozen(_owner)) { - return true; - } - - return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); + return !addressNotFrozen(_owner); } /** * @notice Returns true when a queued withdrawal or exit would still be blocked in the target round. + * @dev Target-round previews only defer execution while the node remains frozen. */ function _blocksQueuedWithdrawalExecutionAtRound( address _owner, @@ -559,30 +535,7 @@ contract StakeRegistry is AccessControl, Pausable { return false; } - if (!_addressNotFrozenAtRound(_owner, _targetRound)) { - return true; - } - - if (_targetRound <= currentRound()) { - return IRedistribution(redistributionContract).isParticipatingInCurrentRound(_owner); - } - - return false; - } - - /** - * @notice Validates that the redistribution contract exposes the participation check. - */ - function _supportsParticipationCheck(address _redistributionContract) internal view returns (bool) { - if (_redistributionContract.code.length == 0) { - return false; - } - - (bool success, ) = _redistributionContract.staticcall( - abi.encodeWithSelector(IRedistribution.isParticipatingInCurrentRound.selector, address(0)) - ); - - return success; + return !_addressNotFrozenAtRound(_owner, _targetRound); } /** diff --git a/test/Staking.test.ts b/test/Staking.test.ts index fa37e5ae..251b0cd5 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -39,7 +39,6 @@ const errors = { onlyPauseCanUnPause: 'OnlyPauser()', }, general: { - invalidRedistribution: 'InvalidRedistributionContract()', queueClosed: 'QueueClosed()', }, }; @@ -90,6 +89,17 @@ async function advanceRounds(rounds = 2) { await mineNBlocks(roundLength * rounds); } +async function advanceToRoundCommitPhase(redistribution: Contract, targetRound: any) { + while (true) { + const currentRound = await redistribution.currentRound(); + const inCommitPhase = await redistribution.currentPhaseCommit(); + if (currentRound.eq(targetRound) && inCommitPhase) { + return; + } + await mineNBlocks(1); + } +} + async function activateStake(contract: Contract, owner: string, nonce: string, amount: string, height: number) { await mintAndApprove(owner, contract.address, amount); await contract.createDeposit(nonce, amount, height); @@ -108,21 +118,10 @@ describe('Staking', function () { }); it('should deploy StakeRegistry with queue wait parameters', async function () { - const redistribution = await ethers.getContract('Redistribution'); expect(stakeRegistry.address).to.be.properAddress; expect(await stakeRegistry.WAIT_BASE()).to.be.eq(2); expect(await stakeRegistry.WAIT_OVERLAY_CHANGE()).to.be.eq(2); expect(await stakeRegistry.WAIT_WITHDRAWAL()).to.be.eq(2); - expect(await stakeRegistry.redistributionContract()).to.be.eq(redistribution.address); - }); - - it('should only allow relinking redistribution to a valid redistributor contract', async function () { - const redistribution = await ethers.getContract('Redistribution'); - - await expect(stakeRegistry.setRedistributionContract(token.address)).to.be.revertedWith( - errors.general.invalidRedistribution - ); - await expect(stakeRegistry.setRedistributionContract(redistribution.address)).to.not.be.reverted; }); it('should schedule a new deposit and activate it after the base delay', async function () { @@ -286,30 +285,48 @@ describe('Staking', function () { expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); }); - it('should keep queued withdrawal pending while the node is active in the current round', async function () { + it('should execute queued withdrawal while the node is active in the current round', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); const redistribution = await ethers.getContract('Redistribution', staker_0); await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); - await srStaker0.withdraw(withdrawAmount); - await advanceRounds(); + const withdrawalReceipt = await (await srStaker0.withdraw(withdrawAmount)).wait(); + const withdrawalEvent = withdrawalReceipt.events?.find((event: any) => event.event === 'Withdrawal'); + const effectiveRound = withdrawalEvent?.args?.effectiveFromRound ?? withdrawalEvent?.args?.[1]; + await advanceToRoundCommitPhase(redistribution, effectiveRound); const currentRound = await redistribution.currentRound(); await redistribution.commit(obfuscatedHash_0, currentRound); - expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); - await srStaker0.applyUpdates(staker_0); - expect(await token.balanceOf(staker_0)).to.be.eq(0); - expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); await mineNBlocks(roundLength); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + }); + + it('should execute queued exit as soon as it becomes effective in the current round', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const redistribution = await ethers.getContract('Redistribution', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + const exitReceipt = await (await srStaker0.exit()).wait(); + const exitEvent = exitReceipt.events?.find((event: any) => event.event === 'Withdrawal'); + const effectiveRound = exitEvent?.args?.effectiveFromRound ?? exitEvent?.args?.[1]; + await advanceToRoundCommitPhase(redistribution, effectiveRound); + + const currentRound = await redistribution.currentRound(); + await expect(redistribution.commit(obfuscatedHash_0, currentRound)).to.be.revertedWith(errors.withdraw.notStaked); await srStaker0.applyUpdates(staker_0); - expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + + const stakedAfter = await srStaker0.stakes(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect(stakedAfter.overlay).to.be.eq(zeroBytes32); + expect(stakedAfter.balance).to.be.eq(0); }); it('should schedule exits and clear the stake on applyUpdates', async function () { From ba86d9534383498ae340919776fb0de0bb64720a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 16 Apr 2026 09:06:11 +0200 Subject: [PATCH 13/58] loosen checks per SWIP format --- src/Staking.sol | 11 --------- test/Staking.test.ts | 55 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 66480d27..689f00e0 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -122,8 +122,6 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The initial staking height. */ function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); - StakeState memory plannedStake = _previewStake(msg.sender, true); if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); if (_amount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); @@ -148,8 +146,6 @@ contract StakeRegistry is AccessControl, Pausable { * @param _amount The amount of BZZ to add to the stake. */ function addTokens(uint256 _amount) external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); - StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -164,8 +160,6 @@ contract StakeRegistry is AccessControl, Pausable { * @param _setNonce The nonce used to derive the new overlay. */ function changeOverlay(bytes32 _setNonce) external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); - StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -189,8 +183,6 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The new staking height. */ function increaseHeight(uint8 _height) external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); - StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); @@ -206,7 +198,6 @@ contract StakeRegistry is AccessControl, Pausable { * @param _amount The amount of BZZ to withdraw from the stake. */ function withdraw(uint256 _amount) external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); if (_amount == 0) revert InvalidWithdrawalAmount(); StakeState memory plannedStake = _previewStake(msg.sender, true); @@ -229,8 +220,6 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Schedules a full exit after the withdrawal delay. */ function exit() external whenNotPaused { - if (!addressNotFrozen(msg.sender)) revert Frozen(); - StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 251b0cd5..114ddb4f 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -30,7 +30,6 @@ const errors = { }, freeze: { noRole: 'OnlyRedistributor()', - currentlyFrozen: 'Frozen()', }, pause: { noRole: 'OnlyPauser()', @@ -364,8 +363,9 @@ describe('Staking', function () { await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); }); - it('should freeze active stake and block mutations until the freeze expires', async function () { + it('should allow non-transfer updates to be queued and applied while the node is frozen', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); @@ -373,17 +373,56 @@ describe('Staking', function () { await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); - await expect(stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime)) + await expect(stakeRegistryRedistributor.freezeDeposit(staker_0, longFreezeTime)) .to.emit(srStaker0, 'StakeFrozen') - .withArgs(staker_0, overlay_0, freezeTime); + .withArgs(staker_0, overlay_0, longFreezeTime); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); - await mintAndApprove(staker_0, srStaker0.address, updateStakeAmount_0); - await expect(srStaker0.addTokens(updateStakeAmount_0)).to.be.revertedWith(errors.freeze.currentlyFrozen); + await mintAndApprove(staker_0, srStaker0.address, topUpForHeight1); + await expect(srStaker0.addTokens(topUpForHeight1)).to.emit(srStaker0, 'TokensAdded'); + await expect(srStaker0.changeOverlay(nonce_1_n_25)).to.emit(srStaker0, 'OverlayChanged'); + await expect(srStaker0.increaseHeight(height_0_n_1)).to.emit(srStaker0, 'HeightIncreased'); - await mineNBlocks(freezeTime + 1); - await expect(srStaker0.addTokens(updateStakeAmount_0)).to.not.be.reverted; + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); + expect(await srStaker0.overlayOfAddress(staker_0)).to.not.be.eq(overlay_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + + await mineNBlocks(longFreezeTime + 1); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); + expect(await srStaker0.overlayOfAddress(staker_0)).to.not.be.eq(overlay_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + }); + + it('should allow withdrawals to be queued while frozen and execute them after the freeze expires', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); + + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.freezeDeposit(staker_0, longFreezeTime); + + await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'Withdrawal'); + await advanceRounds(); + + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + + await mineNBlocks(longFreezeTime + 1); + + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); }); it('should slash active stake balances', async function () { From c73b97847c9844f24fd3044b331523a5457d8be4 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 17 Apr 2026 13:36:44 +0200 Subject: [PATCH 14/58] refactor: rename queued stake previews to lookahead Clarify that queued stake preview getters are forward-looking rather than historical by switching the staking and redistribution APIs from target-round naming to explicit round lookahead semantics. --- src/Redistribution.sol | 14 ++++++------ src/Staking.sol | 49 +++++++++++++++++++++--------------------- test/Staking.test.ts | 16 ++++++-------- 3 files changed, 39 insertions(+), 40 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index f7d64c66..d2062965 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -16,15 +16,15 @@ interface IStakeRegistry { function overlayOfAddress(address _owner) external view returns (bytes32); - function overlayOfAddressAtRound(address _owner, uint64 _targetRound) external view returns (bytes32); + function overlayOfAddressLookahead(address _owner, uint64 _lookahead) external view returns (bytes32); function heightOfAddress(address _owner) external view returns (uint8); - function heightOfAddressAtRound(address _owner, uint64 _targetRound) external view returns (uint8); + function heightOfAddressLookahead(address _owner, uint64 _lookahead) external view returns (uint8); function nodeEffectiveStake(address _owner) external view returns (uint256); - function nodeEffectiveStakeAtRound(address _owner, uint64 _targetRound) external view returns (uint256); + function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) external view returns (uint256); } /** @@ -815,18 +815,18 @@ contract Redistribution is AccessControl, Pausable { revert WrongPhase(); } - uint64 targetRound = currentPhaseClaim() ? currentRound() + 1 : currentRound(); - uint256 _stake = Stakes.nodeEffectiveStakeAtRound(_owner, targetRound); + uint64 lookahead = currentPhaseClaim() ? 1 : 0; + uint256 _stake = Stakes.nodeEffectiveStakeLookahead(_owner, lookahead); if (_stake == 0) { revert NotStaked(); } - uint8 _depthResponsibility = _depth - Stakes.heightOfAddressAtRound(_owner, targetRound); + uint8 _depthResponsibility = _depth - Stakes.heightOfAddressLookahead(_owner, lookahead); return inProximity( - Stakes.overlayOfAddressAtRound(_owner, targetRound), + Stakes.overlayOfAddressLookahead(_owner, lookahead), currentRoundAnchor(), _depthResponsibility ); diff --git a/src/Staking.sol b/src/Staking.sol index 689f00e0..7bdfae25 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -379,28 +379,28 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns the effective stake that would be active in the target round. + * @notice Returns the effective stake that would be active after the given round lookahead. */ - function nodeEffectiveStakeAtRound(address _owner, uint64 _targetRound) public view returns (uint256) { - if (!_addressNotFrozenAtRound(_owner, _targetRound)) return 0; + function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) public view returns (uint256) { + if (!_addressNotFrozenLookahead(_owner, _lookahead)) return 0; - StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.balance : 0; } /** - * @notice Returns the overlay that would be active in the target round. + * @notice Returns the overlay that would be active after the given round lookahead. */ - function overlayOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (bytes32) { - StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + function overlayOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (bytes32) { + StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.overlay : bytes32(0); } /** - * @notice Returns the height that would be active in the target round. + * @notice Returns the height that would be active after the given round lookahead. */ - function heightOfAddressAtRound(address _owner, uint64 _targetRound) public view returns (uint8) { - StakeState memory preview = _previewStakeAtRound(_owner, _targetRound); + function heightOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (uint8) { + StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.height : 0; } @@ -512,19 +512,19 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns true when a queued withdrawal or exit would still be blocked in the target round. - * @dev Target-round previews only defer execution while the node remains frozen. + * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. + * @dev Lookahead previews only defer execution while the node remains frozen. */ - function _blocksQueuedWithdrawalExecutionAtRound( + function _blocksQueuedWithdrawalExecutionLookahead( address _owner, UpdateKind _kind, - uint64 _targetRound + uint64 _lookahead ) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } - return !_addressNotFrozenAtRound(_owner, _targetRound); + return !_addressNotFrozenLookahead(_owner, _lookahead); } /** @@ -558,23 +558,24 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Previews stake state as it would look in a specific target round. + * @notice Previews stake state as it would look after the given round lookahead. */ - function _previewStakeAtRound( + function _previewStakeLookahead( address _owner, - uint64 _targetRound + uint64 _lookahead ) internal view returns (StakeState memory preview) { preview = _stakes[_owner]; ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; + uint64 targetRound = currentRound() + _lookahead; for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; - if (scheduled.effectiveFromRound > _targetRound) { + if (scheduled.effectiveFromRound > targetRound) { break; } - if (_blocksQueuedWithdrawalExecutionAtRound(_owner, scheduled.kind, _targetRound)) { + if (_blocksQueuedWithdrawalExecutionLookahead(_owner, scheduled.kind, _lookahead)) { break; } @@ -686,18 +687,18 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns true when the owner would be unfrozen by the target round. + * @notice Returns true when the owner would be unfrozen after the given round lookahead. */ - function _addressNotFrozenAtRound(address _owner, uint64 _targetRound) internal view returns (bool) { + function _addressNotFrozenLookahead(address _owner, uint64 _lookahead) internal view returns (bool) { if (!_isInitialized(_owner)) { return true; } - if (_targetRound <= currentRound()) { + if (_lookahead == 0) { return _stakes[_owner].frozenUntilBlock < block.number; } - return _stakes[_owner].frozenUntilBlock < uint256(_targetRound) * ROUND_LENGTH; + return _stakes[_owner].frozenUntilBlock < uint256(currentRound() + _lookahead) * ROUND_LENGTH; } /** diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 114ddb4f..d527aacb 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -198,24 +198,22 @@ describe('Staking', function () { await expect(srStaker0.increaseHeight(height_0)).to.be.revertedWith(errors.deposit.heightDecrease); }); - it('should preview queued stake state at a target round', async function () { + it('should preview queued stake state with lookahead', async function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); - const currentRound = await srStaker1.currentRound(); - await mintAndApprove(staker_1, srStaker1.address, topUpForHeight1); await srStaker1.addTokens(topUpForHeight1); await srStaker1.changeOverlay(nonce_1_n_25); await srStaker1.increaseHeight(height_1_n_1); - expect(await srStaker1.nodeEffectiveStakeAtRound(staker_1, currentRound.add(1))).to.be.eq(stakeAmount_1); - expect(await srStaker1.overlayOfAddressAtRound(staker_1, currentRound.add(1))).to.be.eq(overlay_1); - expect(await srStaker1.heightOfAddressAtRound(staker_1, currentRound.add(1))).to.be.eq(height_1); + expect(await srStaker1.nodeEffectiveStakeLookahead(staker_1, 1)).to.be.eq(stakeAmount_1); + expect(await srStaker1.overlayOfAddressLookahead(staker_1, 1)).to.be.eq(overlay_1); + expect(await srStaker1.heightOfAddressLookahead(staker_1, 1)).to.be.eq(height_1); - expect(await srStaker1.nodeEffectiveStakeAtRound(staker_1, currentRound.add(2))).to.be.eq(doubleStakeAmount_0); - expect(await srStaker1.overlayOfAddressAtRound(staker_1, currentRound.add(2))).to.be.eq(overlay_1_n_25); - expect(await srStaker1.heightOfAddressAtRound(staker_1, currentRound.add(2))).to.be.eq(height_1_n_1); + expect(await srStaker1.nodeEffectiveStakeLookahead(staker_1, 2)).to.be.eq(doubleStakeAmount_0); + expect(await srStaker1.overlayOfAddressLookahead(staker_1, 2)).to.be.eq(overlay_1_n_25); + expect(await srStaker1.heightOfAddressLookahead(staker_1, 2)).to.be.eq(height_1_n_1); }); it('should keep effective stake equal to balance after oracle price changes', async function () { From dff2544ec636ae8d70ed0788387b078cd7f678c3 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sat, 18 Apr 2026 19:08:26 +0200 Subject: [PATCH 15/58] fix(staking): harden error paths and visibility - Revert FrozenWithdrawal on frozen withdrawal/exit in applyUpdates instead of silently skipping - Check _queueClosed before _previewStake so terminating queues revert QueueClosed instead of NotStaked - Enforce WAIT_OVERLAY_CHANGE >= WAIT_BASE and WAIT_WITHDRAWAL >= WAIT_BASE in constructor - Make UPDATE_QUEUE_MAX_LENGTH public --- src/Staking.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 7bdfae25..37805547 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -17,7 +17,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 private constant ROUND_LENGTH = 152; uint256 private constant MIN_STAKE = 100000000000000000; - uint256 private constant UPDATE_QUEUE_MAX_LENGTH = 10; + uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; // ----------------------------- Type declarations ------------------------------ @@ -95,6 +95,8 @@ contract StakeRegistry is AccessControl, Pausable { error InvalidWithdrawalAmount(); error UpdateQueueFull(); error QueueClosed(); + error FrozenWithdrawal(); + error InvalidWaitConfiguration(); constructor( address _bzzToken, @@ -103,6 +105,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 _waitOverlayChange, uint64 _waitWithdrawal ) { + if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) revert InvalidWaitConfiguration(); NetworkId = _NetworkId; bzzToken = _bzzToken; WAIT_BASE = _waitBase; @@ -146,6 +149,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _amount The amount of BZZ to add to the stake. */ function addTokens(uint256 _amount) external whenNotPaused { + if (_queueClosed[msg.sender]) revert QueueClosed(); StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -160,6 +164,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _setNonce The nonce used to derive the new overlay. */ function changeOverlay(bytes32 _setNonce) external whenNotPaused { + if (_queueClosed[msg.sender]) revert QueueClosed(); StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -183,6 +188,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The new staking height. */ function increaseHeight(uint8 _height) external whenNotPaused { + if (_queueClosed[msg.sender]) revert QueueClosed(); StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); @@ -199,6 +205,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function withdraw(uint256 _amount) external whenNotPaused { if (_amount == 0) revert InvalidWithdrawalAmount(); + if (_queueClosed[msg.sender]) revert QueueClosed(); StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -220,6 +227,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Schedules a full exit after the withdrawal delay. */ function exit() external whenNotPaused { + if (_queueClosed[msg.sender]) revert QueueClosed(); StakeState memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -429,7 +437,7 @@ contract StakeRegistry is AccessControl, Pausable { while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) { - break; + revert FrozenWithdrawal(); } _applyStoredUpdate(_owner, queue[head]); delete queue[head]; From 8bba0ee360419ea59c189f0ac3d25f3a0ce62c27 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sat, 18 Apr 2026 19:20:49 +0200 Subject: [PATCH 16/58] refactor(staking): remove dead code and fix privileged caller reverts - Remove dead `Frozen` error (unused after FrozenWithdrawal was added) - Merge identical `StakeState` into `Stake`, remove `_toStakeView` - Add `_revertOnFrozen` param to `_applyReadyUpdates` so privileged callers (freezeDeposit, slashDeposit, migrateStake) break instead of reverting when a frozen withdrawal is encountered - Move `_queueClosed` check from `_enqueueUpdate` to all six public callers including `createDeposit` which previously lacked it - Update tests to expect FrozenWithdrawal revert on applyUpdates while frozen --- src/Staking.sol | 163 +++++++++++++++---------------------------- test/Staking.test.ts | 5 +- 2 files changed, 58 insertions(+), 110 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 37805547..ff2b1715 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -37,13 +37,6 @@ contract StakeRegistry is AccessControl, Pausable { uint8 height; } - struct StakeState { - bytes32 overlay; - uint256 balance; - uint256 frozenUntilBlock; - uint8 height; - } - struct ScheduledUpdate { UpdateKind kind; uint64 effectiveFromRound; @@ -52,7 +45,7 @@ contract StakeRegistry is AccessControl, Pausable { uint8 height; } - mapping(address => StakeState) private _stakes; + mapping(address => Stake) private _stakes; mapping(address => ScheduledUpdate[]) private _updateQueues; mapping(address => uint256) private _queueHeads; mapping(address => bool) private _queueClosed; @@ -68,11 +61,7 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Events ------------------------------ event DepositCreated( - address indexed owner, - uint64 registeredFromRound, - uint256 amount, - bytes32 overlay, - uint8 height + address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height ); event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); @@ -84,7 +73,6 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Errors ------------------------------ error TransferFailed(); - error Frozen(); error Unauthorized(); error OnlyRedistributor(); error OnlyPauser(); @@ -105,7 +93,9 @@ contract StakeRegistry is AccessControl, Pausable { uint64 _waitOverlayChange, uint64 _waitWithdrawal ) { - if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) revert InvalidWaitConfiguration(); + if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) { + revert InvalidWaitConfiguration(); + } NetworkId = _NetworkId; bzzToken = _bzzToken; WAIT_BASE = _waitBase; @@ -125,21 +115,16 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The initial staking height. */ function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { - StakeState memory plannedStake = _previewStake(msg.sender, true); + if (_queueClosed[msg.sender]) revert QueueClosed(); + Stake memory plannedStake = _previewStake(msg.sender, true); if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); if (_amount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.CreateDeposit, - WAIT_BASE, - _setNonce, - _amount, - _height - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.CreateDeposit, WAIT_BASE, _setNonce, _amount, _height); emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); } @@ -150,7 +135,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function addTokens(uint256 _amount) external whenNotPaused { if (_queueClosed[msg.sender]) revert QueueClosed(); - StakeState memory plannedStake = _previewStake(msg.sender, true); + Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); _pullTokens(msg.sender, _amount); @@ -165,20 +150,14 @@ contract StakeRegistry is AccessControl, Pausable { */ function changeOverlay(bytes32 _setNonce) external whenNotPaused { if (_queueClosed[msg.sender]) revert QueueClosed(); - StakeState memory plannedStake = _previewStake(msg.sender, true); + Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) return; - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.ChangeOverlay, - WAIT_OVERLAY_CHANGE, - _setNonce, - 0, - 0 - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); } @@ -189,7 +168,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function increaseHeight(uint8 _height) external whenNotPaused { if (_queueClosed[msg.sender]) revert QueueClosed(); - StakeState memory plannedStake = _previewStake(msg.sender, true); + Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); if (_height == plannedStake.height) return; @@ -207,19 +186,13 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount == 0) revert InvalidWithdrawalAmount(); if (_queueClosed[msg.sender]) revert QueueClosed(); - StakeState memory plannedStake = _previewStake(msg.sender, true); + Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_amount >= plannedStake.balance) revert BelowMinimumStake(); if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.WithdrawTokens, - WAIT_WITHDRAWAL, - 0, - _amount, - 0 - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); emit Withdrawal(msg.sender, effectiveFromRound, _amount); } @@ -228,7 +201,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function exit() external whenNotPaused { if (_queueClosed[msg.sender]) revert QueueClosed(); - StakeState memory plannedStake = _previewStake(msg.sender, true); + Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); @@ -241,7 +214,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _owner The address whose queue should be processed. */ function applyUpdates(address _owner) public { - _applyReadyUpdates(_owner); + _applyReadyUpdates(_owner, true); } /** @@ -249,13 +222,13 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Used for migration flows where queued deposits and top ups must be returned. */ function migrateStake() external whenPaused { - _applyReadyUpdates(msg.sender); + _applyReadyUpdates(msg.sender, false); uint256 payout = _stakes[msg.sender].balance; ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; uint256 head = _queueHeads[msg.sender]; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { payout += scheduled.amount; @@ -289,7 +262,7 @@ contract StakeRegistry is AccessControl, Pausable { } _stakes[_owner].frozenUntilBlock = block.number + _time; - _applyReadyUpdates(_owner); + _applyReadyUpdates(_owner, false); if (_isInitialized(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); @@ -304,9 +277,9 @@ contract StakeRegistry is AccessControl, Pausable { function slashDeposit(address _owner, uint256 _amount) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - _applyReadyUpdates(_owner); + _applyReadyUpdates(_owner, false); - StakeState storage stake = _stakes[_owner]; + Stake storage stake = _stakes[_owner]; bytes32 previousOverlay = stake.overlay; if (_isInitialized(_owner)) { @@ -357,7 +330,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the currently visible stake state for an owner. */ function stakes(address _owner) public view returns (Stake memory) { - return _toStakeView(_previewStake(_owner, false)); + return _previewStake(_owner, false); } /** @@ -366,7 +339,7 @@ contract StakeRegistry is AccessControl, Pausable { function nodeEffectiveStake(address _owner) public view returns (uint256) { if (!addressNotFrozen(_owner)) return 0; - StakeState memory preview = _previewStake(_owner, false); + Stake memory preview = _previewStake(_owner, false); return _isInitialized(preview) ? preview.balance : 0; } @@ -374,7 +347,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the currently effective overlay for an owner. */ function overlayOfAddress(address _owner) public view returns (bytes32) { - StakeState memory preview = _previewStake(_owner, false); + Stake memory preview = _previewStake(_owner, false); return _isInitialized(preview) ? preview.overlay : bytes32(0); } @@ -382,7 +355,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the currently effective height for an owner. */ function heightOfAddress(address _owner) public view returns (uint8) { - StakeState memory preview = _previewStake(_owner, false); + Stake memory preview = _previewStake(_owner, false); return _isInitialized(preview) ? preview.height : 0; } @@ -392,7 +365,7 @@ contract StakeRegistry is AccessControl, Pausable { function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) public view returns (uint256) { if (!_addressNotFrozenLookahead(_owner, _lookahead)) return 0; - StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.balance : 0; } @@ -400,7 +373,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the overlay that would be active after the given round lookahead. */ function overlayOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (bytes32) { - StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.overlay : bytes32(0); } @@ -408,7 +381,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the height that would be active after the given round lookahead. */ function heightOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (uint8) { - StakeState memory preview = _previewStakeLookahead(_owner, _lookahead); + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); return _isInitialized(preview) ? preview.height : 0; } @@ -428,16 +401,18 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies all queued updates that are effective in the current round. - * @dev Withdrawals and exits are deferred only while the node is frozen. + * @dev When _revertOnFrozen is true, reverts if a frozen withdrawal/exit is encountered. + * When false, stops processing at the frozen entry (used by privileged callers). */ - function _applyReadyUpdates(address _owner) internal { + function _applyReadyUpdates(address _owner, bool _revertOnFrozen) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) { - revert FrozenWithdrawal(); + if (_revertOnFrozen) revert FrozenWithdrawal(); + break; } _applyStoredUpdate(_owner, queue[head]); delete queue[head]; @@ -459,7 +434,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Applies a single queued update to storage. */ function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { - StakeState storage stake = _stakes[_owner]; + Stake storage stake = _stakes[_owner]; if (scheduled.kind == UpdateKind.CreateDeposit) { stake.overlay = _deriveOverlay(_owner, scheduled.nonce); @@ -523,11 +498,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. * @dev Lookahead previews only defer execution while the node remains frozen. */ - function _blocksQueuedWithdrawalExecutionLookahead( - address _owner, - UpdateKind _kind, - uint64 _lookahead - ) internal view returns (bool) { + function _blocksQueuedWithdrawalExecutionLookahead(address _owner, UpdateKind _kind, uint64 _lookahead) + internal + view + returns (bool) + { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } @@ -538,17 +513,14 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Previews stake state using the current round, optionally including future queued updates. */ - function _previewStake( - address _owner, - bool includeFutureUpdates - ) internal view returns (StakeState memory preview) { + function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (Stake memory preview) { preview = _stakes[_owner]; ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; @@ -568,17 +540,14 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Previews stake state as it would look after the given round lookahead. */ - function _previewStakeLookahead( - address _owner, - uint64 _lookahead - ) internal view returns (StakeState memory preview) { + function _previewStakeLookahead(address _owner, uint64 _lookahead) internal view returns (Stake memory preview) { preview = _stakes[_owner]; ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; uint64 targetRound = currentRound() + _lookahead; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.effectiveFromRound > targetRound) { break; @@ -598,11 +567,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to an in-memory preview state. */ - function _applyPreviewUpdate( - address _owner, - StakeState memory preview, - ScheduledUpdate storage scheduled - ) internal view returns (StakeState memory) { + function _applyPreviewUpdate(address _owner, Stake memory preview, ScheduledUpdate storage scheduled) + internal + view + returns (Stake memory) + { if (scheduled.kind == UpdateKind.CreateDeposit) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; @@ -658,7 +627,6 @@ contract StakeRegistry is AccessControl, Pausable { uint256 _amount, uint8 _height ) internal returns (uint64 effectiveFromRound) { - if (_queueClosed[_owner]) revert QueueClosed(); if (_queueLength(_owner) >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(); uint64 candidateRound = currentRound() + _minimumWait; @@ -667,11 +635,7 @@ contract StakeRegistry is AccessControl, Pausable { _updateQueues[_owner].push( ScheduledUpdate({ - kind: _kind, - effectiveFromRound: effectiveFromRound, - nonce: _nonce, - amount: _amount, - height: _height + kind: _kind, effectiveFromRound: effectiveFromRound, nonce: _nonce, amount: _amount, height: _height }) ); } @@ -716,9 +680,9 @@ contract StakeRegistry is AccessControl, Pausable { function _reconcileQueuedWithdrawals(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; - StakeState memory preview = _stakes[_owner]; + Stake memory preview = _stakes[_owner]; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit) { @@ -775,23 +739,6 @@ contract StakeRegistry is AccessControl, Pausable { return keccak256(abi.encodePacked(_owner, reverse(NetworkId), _setNonce)); } - /** - * @notice Converts internal stake state into the public view struct. - */ - function _toStakeView(StakeState memory _stake) internal pure returns (Stake memory) { - if (!_isInitialized(_stake)) { - return Stake({overlay: 0, balance: 0, frozenUntilBlock: 0, height: 0}); - } - - return - Stake({ - overlay: _stake.overlay, - balance: _stake.balance, - frozenUntilBlock: _stake.frozenUntilBlock, - height: _stake.height - }); - } - /** * @notice Returns true when the stored stake for an owner is initialized. */ @@ -802,7 +749,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Returns true when an in-memory stake state is initialized. */ - function _isInitialized(StakeState memory _stake) internal pure returns (bool) { + function _isInitialized(Stake memory _stake) internal pure returns (bool) { return _stake.overlay != bytes32(0); } diff --git a/test/Staking.test.ts b/test/Staking.test.ts index d527aacb..9adfc990 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -39,6 +39,7 @@ const errors = { }, general: { queueClosed: 'QueueClosed()', + frozenWithdrawal: 'FrozenWithdrawal()', }, }; @@ -269,7 +270,7 @@ describe('Staking', function () { const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); await stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime); - await srStaker0.applyUpdates(staker_0); + await expect(srStaker0.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); expect(await token.balanceOf(staker_0)).to.be.eq(0); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); @@ -412,7 +413,7 @@ describe('Staking', function () { await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'Withdrawal'); await advanceRounds(); - await srStaker0.applyUpdates(staker_0); + await expect(srStaker0.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); expect(await token.balanceOf(staker_0)).to.be.eq(0); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); From 3ece9d121981b52f7812c707bf0dd5865ab2d1ac Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sat, 18 Apr 2026 20:32:36 +0200 Subject: [PATCH 17/58] ci: trigger CI run From 73816e9eac1ec5dd37d4ca6a8da13a8167a94728 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Sat, 18 Apr 2026 20:33:46 +0200 Subject: [PATCH 18/58] style: format Staking.sol with prettier --- src/Staking.sol | 70 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index ff2b1715..05d0defe 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -61,7 +61,11 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Events ------------------------------ event DepositCreated( - address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height + address indexed owner, + uint64 registeredFromRound, + uint256 amount, + bytes32 overlay, + uint8 height ); event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); @@ -123,8 +127,14 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); - uint64 effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.CreateDeposit, WAIT_BASE, _setNonce, _amount, _height); + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.CreateDeposit, + WAIT_BASE, + _setNonce, + _amount, + _height + ); emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); } @@ -156,8 +166,14 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) return; - uint64 effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.ChangeOverlay, + WAIT_OVERLAY_CHANGE, + _setNonce, + 0, + 0 + ); emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); } @@ -191,8 +207,14 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount >= plannedStake.balance) revert BelowMinimumStake(); if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); - uint64 effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); + uint64 effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.WithdrawTokens, + WAIT_WITHDRAWAL, + 0, + _amount, + 0 + ); emit Withdrawal(msg.sender, effectiveFromRound, _amount); } @@ -228,7 +250,7 @@ contract StakeRegistry is AccessControl, Pausable { ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; uint256 head = _queueHeads[msg.sender]; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { payout += scheduled.amount; @@ -498,11 +520,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. * @dev Lookahead previews only defer execution while the node remains frozen. */ - function _blocksQueuedWithdrawalExecutionLookahead(address _owner, UpdateKind _kind, uint64 _lookahead) - internal - view - returns (bool) - { + function _blocksQueuedWithdrawalExecutionLookahead( + address _owner, + UpdateKind _kind, + uint64 _lookahead + ) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } @@ -520,7 +542,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; @@ -547,7 +569,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 targetRound = currentRound() + _lookahead; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.effectiveFromRound > targetRound) { break; @@ -567,11 +589,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to an in-memory preview state. */ - function _applyPreviewUpdate(address _owner, Stake memory preview, ScheduledUpdate storage scheduled) - internal - view - returns (Stake memory) - { + function _applyPreviewUpdate( + address _owner, + Stake memory preview, + ScheduledUpdate storage scheduled + ) internal view returns (Stake memory) { if (scheduled.kind == UpdateKind.CreateDeposit) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; @@ -635,7 +657,11 @@ contract StakeRegistry is AccessControl, Pausable { _updateQueues[_owner].push( ScheduledUpdate({ - kind: _kind, effectiveFromRound: effectiveFromRound, nonce: _nonce, amount: _amount, height: _height + kind: _kind, + effectiveFromRound: effectiveFromRound, + nonce: _nonce, + amount: _amount, + height: _height }) ); } @@ -682,7 +708,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; Stake memory preview = _stakes[_owner]; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit) { From 36e003e41e82b52348f08d188ffd871e40636e2b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 20 Apr 2026 14:08:24 +0200 Subject: [PATCH 19/58] refactor(staking): remove _revertOnFrozen parameter Move FrozenWithdrawal revert into applyUpdates as a post-call check instead of threading a bool through _applyReadyUpdates. The internal function now always breaks on frozen entries. --- src/Staking.sol | 94 ++++++++++++++++++------------------------------- 1 file changed, 35 insertions(+), 59 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 05d0defe..603ab35d 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -61,11 +61,7 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Events ------------------------------ event DepositCreated( - address indexed owner, - uint64 registeredFromRound, - uint256 amount, - bytes32 overlay, - uint8 height + address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height ); event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); @@ -127,14 +123,8 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.CreateDeposit, - WAIT_BASE, - _setNonce, - _amount, - _height - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.CreateDeposit, WAIT_BASE, _setNonce, _amount, _height); emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); } @@ -166,14 +156,8 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) return; - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.ChangeOverlay, - WAIT_OVERLAY_CHANGE, - _setNonce, - 0, - 0 - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); } @@ -207,14 +191,8 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount >= plannedStake.balance) revert BelowMinimumStake(); if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); - uint64 effectiveFromRound = _enqueueUpdate( - msg.sender, - UpdateKind.WithdrawTokens, - WAIT_WITHDRAWAL, - 0, - _amount, - 0 - ); + uint64 effectiveFromRound = + _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); emit Withdrawal(msg.sender, effectiveFromRound, _amount); } @@ -236,7 +214,13 @@ contract StakeRegistry is AccessControl, Pausable { * @param _owner The address whose queue should be processed. */ function applyUpdates(address _owner) public { - _applyReadyUpdates(_owner, true); + _applyReadyUpdates(_owner); + if ( + _queueLength(_owner) > 0 + && _blocksQueuedWithdrawalExecution(_owner, _updateQueues[_owner][_queueHeads[_owner]].kind) + ) { + revert FrozenWithdrawal(); + } } /** @@ -244,13 +228,13 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Used for migration flows where queued deposits and top ups must be returned. */ function migrateStake() external whenPaused { - _applyReadyUpdates(msg.sender, false); + _applyReadyUpdates(msg.sender); uint256 payout = _stakes[msg.sender].balance; ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; uint256 head = _queueHeads[msg.sender]; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { payout += scheduled.amount; @@ -284,7 +268,7 @@ contract StakeRegistry is AccessControl, Pausable { } _stakes[_owner].frozenUntilBlock = block.number + _time; - _applyReadyUpdates(_owner, false); + _applyReadyUpdates(_owner); if (_isInitialized(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); @@ -299,7 +283,7 @@ contract StakeRegistry is AccessControl, Pausable { function slashDeposit(address _owner, uint256 _amount) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - _applyReadyUpdates(_owner, false); + _applyReadyUpdates(_owner); Stake storage stake = _stakes[_owner]; bytes32 previousOverlay = stake.overlay; @@ -423,19 +407,15 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies all queued updates that are effective in the current round. - * @dev When _revertOnFrozen is true, reverts if a frozen withdrawal/exit is encountered. - * When false, stops processing at the frozen entry (used by privileged callers). + * @dev Stops at the first frozen withdrawal/exit without reverting. */ - function _applyReadyUpdates(address _owner, bool _revertOnFrozen) internal { + function _applyReadyUpdates(address _owner) internal { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { - if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) { - if (_revertOnFrozen) revert FrozenWithdrawal(); - break; - } + if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) break; _applyStoredUpdate(_owner, queue[head]); delete queue[head]; unchecked { @@ -520,11 +500,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. * @dev Lookahead previews only defer execution while the node remains frozen. */ - function _blocksQueuedWithdrawalExecutionLookahead( - address _owner, - UpdateKind _kind, - uint64 _lookahead - ) internal view returns (bool) { + function _blocksQueuedWithdrawalExecutionLookahead(address _owner, UpdateKind _kind, uint64 _lookahead) + internal + view + returns (bool) + { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } @@ -542,7 +522,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; @@ -569,7 +549,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 targetRound = currentRound() + _lookahead; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.effectiveFromRound > targetRound) { break; @@ -589,11 +569,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to an in-memory preview state. */ - function _applyPreviewUpdate( - address _owner, - Stake memory preview, - ScheduledUpdate storage scheduled - ) internal view returns (Stake memory) { + function _applyPreviewUpdate(address _owner, Stake memory preview, ScheduledUpdate storage scheduled) + internal + view + returns (Stake memory) + { if (scheduled.kind == UpdateKind.CreateDeposit) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; @@ -657,11 +637,7 @@ contract StakeRegistry is AccessControl, Pausable { _updateQueues[_owner].push( ScheduledUpdate({ - kind: _kind, - effectiveFromRound: effectiveFromRound, - nonce: _nonce, - amount: _amount, - height: _height + kind: _kind, effectiveFromRound: effectiveFromRound, nonce: _nonce, amount: _amount, height: _height }) ); } @@ -708,7 +684,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; Stake memory preview = _stakes[_owner]; - for (uint256 i = head; i < queue.length; ) { + for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit) { From 78407968e9e8b634c98c39558008c0a7e60d4d91 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 21 Apr 2026 17:13:54 +0200 Subject: [PATCH 20/58] fix(staking): apply ready updates before freezing Swap freeze and apply order in freezeDeposit so mature withdrawals settle while the node is still unfrozen, then the freeze takes effect for future rounds. --- src/Staking.sol | 2 +- test/Staking.test.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 603ab35d..2a77b48b 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -267,8 +267,8 @@ contract StakeRegistry is AccessControl, Pausable { return; } - _stakes[_owner].frozenUntilBlock = block.number + _time; _applyReadyUpdates(_owner); + _stakes[_owner].frozenUntilBlock = block.number + _time; if (_isInitialized(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 9adfc990..3dace293 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -256,7 +256,7 @@ describe('Staking', function () { expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); }); - it('should keep queued withdrawal pending while the node is frozen', async function () { + it('should apply mature withdrawal during freeze and block future ones', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); @@ -270,17 +270,13 @@ describe('Staking', function () { const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); await stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime); - await expect(srStaker0.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); - expect(await token.balanceOf(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); await mineNBlocks(freezeTime + 1); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(0); - - await srStaker0.applyUpdates(staker_0); - expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); }); it('should execute queued withdrawal while the node is active in the current round', async function () { From c0c012c554815fe6100d3ed3e967f85fe83d4671 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 13:18:01 +0200 Subject: [PATCH 21/58] feat(staking): return effectiveFromRound from enqueue calls --- src/Staking.sol | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 2a77b48b..a0685b55 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -113,8 +113,13 @@ contract StakeRegistry is AccessControl, Pausable { * @param _setNonce The nonce used to derive the overlay. * @param _amount The amount of BZZ to lock. * @param _height The initial staking height. + * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external whenNotPaused { + function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) + external + whenNotPaused + returns (uint64 effectiveFromRound) + { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); @@ -123,7 +128,7 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); - uint64 effectiveFromRound = + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.CreateDeposit, WAIT_BASE, _setNonce, _amount, _height); emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); @@ -132,14 +137,15 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules an increase of the caller's stake balance. * @param _amount The amount of BZZ to add to the stake. + * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function addTokens(uint256 _amount) external whenNotPaused { + function addTokens(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); _pullTokens(msg.sender, _amount); - uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); emit TokensAdded(msg.sender, effectiveFromRound, _amount); } @@ -147,16 +153,17 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules an overlay change after the configured overlay delay. * @param _setNonce The nonce used to derive the new overlay. + * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. */ - function changeOverlay(bytes32 _setNonce) external whenNotPaused { + function changeOverlay(bytes32 _setNonce) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); - if (newOverlay == plannedStake.overlay) return; + if (newOverlay == plannedStake.overlay) return 0; - uint64 effectiveFromRound = + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); @@ -165,24 +172,26 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules a height increase once the base delay elapses. * @param _height The new staking height. + * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. */ - function increaseHeight(uint8 _height) external whenNotPaused { + function increaseHeight(uint8 _height) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); - if (_height == plannedStake.height) return; + if (_height == plannedStake.height) return 0; if (plannedStake.balance < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); - uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); emit HeightIncreased(msg.sender, effectiveFromRound, _height); } /** * @notice Schedules a partial withdrawal after the withdrawal delay. * @param _amount The amount of BZZ to withdraw from the stake. + * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function withdraw(uint256 _amount) external whenNotPaused { + function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_amount == 0) revert InvalidWithdrawalAmount(); if (_queueClosed[msg.sender]) revert QueueClosed(); @@ -191,20 +200,21 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount >= plannedStake.balance) revert BelowMinimumStake(); if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); - uint64 effectiveFromRound = + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); emit Withdrawal(msg.sender, effectiveFromRound, _amount); } /** * @notice Schedules a full exit after the withdrawal delay. + * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function exit() external whenNotPaused { + function exit() external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); - uint64 effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); _queueClosed[msg.sender] = true; emit Withdrawal(msg.sender, effectiveFromRound, plannedStake.balance); } From 285659da62cd3b65da78d3158bfb08747e1e4bcb Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 15:36:13 +0200 Subject: [PATCH 22/58] fix(staking): applyUpdates freeze guard and withdrawal fixes - Revert FrozenWithdrawal only when queue head is due and blocked - Transfer min(scheduled amount, balance) on withdrawal apply - Pause/unpause revert Unauthorized; zero deposit uses InvalidAmount - InvalidWithdrawalAmount(WithdrawalAmountIssue) for withdraw rejects BREAKING CHANGE: pause/unpause ABI error is Unauthorized not OnlyPauser; InvalidWithdrawalAmount now takes uint8 reason. --- src/Staking.sol | 40 +++++++++++++++++++++++++--------------- test/Staking.test.ts | 11 +++++++---- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index a0685b55..db46ac7d 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -30,6 +30,14 @@ contract StakeRegistry is AccessControl, Pausable { ExitStake } + /// @dev Why `withdraw` was rejected before anything was queued. + enum WithdrawalAmountIssue { + /// Amount is zero; withdraw only accepts positive partial pulls (see `exit()` for full unwind). + Zero, + /// Amount is greater than or equal to current stake; use `exit()` to schedule a full withdrawal after delay. + FullBalanceRequiresExit + } + struct Stake { bytes32 overlay; uint256 balance; @@ -75,12 +83,13 @@ contract StakeRegistry is AccessControl, Pausable { error TransferFailed(); error Unauthorized(); error OnlyRedistributor(); - error OnlyPauser(); error BelowMinimumStake(); error NotStaked(); error AlreadyStaked(); error HeightDecreaseNotAllowed(); - error InvalidWithdrawalAmount(); + error InvalidAmount(); + /// @notice See `WithdrawalAmountIssue` for meaning of `reason`. + error InvalidWithdrawalAmount(WithdrawalAmountIssue reason); error UpdateQueueFull(); error QueueClosed(); error FrozenWithdrawal(); @@ -192,12 +201,14 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_amount == 0) revert InvalidWithdrawalAmount(); + if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); - if (_amount >= plannedStake.balance) revert BelowMinimumStake(); + if (_amount >= plannedStake.balance) { + revert InvalidWithdrawalAmount(WithdrawalAmountIssue.FullBalanceRequiresExit); + } if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); effectiveFromRound = @@ -225,9 +236,11 @@ contract StakeRegistry is AccessControl, Pausable { */ function applyUpdates(address _owner) public { _applyReadyUpdates(_owner); + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; if ( - _queueLength(_owner) > 0 - && _blocksQueuedWithdrawalExecution(_owner, _updateQueues[_owner][_queueHeads[_owner]].kind) + head < queue.length && queue[head].effectiveFromRound <= currentRound() + && _blocksQueuedWithdrawalExecution(_owner, queue[head].kind) ) { revert FrozenWithdrawal(); } @@ -326,7 +339,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Pauses staking mutations. */ function pause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); _pause(); } @@ -334,7 +347,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Unpauses staking mutations. */ function unPause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); + if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); _unpause(); } @@ -476,13 +489,10 @@ contract StakeRegistry is AccessControl, Pausable { if (scheduled.kind == UpdateKind.WithdrawTokens) { if (_isInitialized(_owner)) { - if (scheduled.amount >= stake.balance) { - stake.balance = 0; - } else { - stake.balance -= scheduled.amount; - } + uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; + stake.balance -= paid; - if (!ERC20(bzzToken).transfer(_owner, scheduled.amount)) revert TransferFailed(); + if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); } return; } @@ -733,7 +743,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Pulls BZZ into the staking contract. */ function _pullTokens(address _owner, uint256 _amount) internal { - if (_amount == 0) revert InvalidWithdrawalAmount(); + if (_amount == 0) revert InvalidAmount(); if (!ERC20(bzzToken).transferFrom(_owner, address(this), _amount)) revert TransferFailed(); } diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 3dace293..7970350d 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -22,7 +22,7 @@ const errors = { heightDecrease: 'HeightDecreaseNotAllowed()', }, withdraw: { - invalid: 'InvalidWithdrawalAmount()', + invalidWithdrawalAmount: 'InvalidWithdrawalAmount', notStaked: 'NotStaked()', }, slash: { @@ -32,10 +32,10 @@ const errors = { noRole: 'OnlyRedistributor()', }, pause: { - noRole: 'OnlyPauser()', + noRole: 'Unauthorized()', currentlyPaused: 'Pausable: paused', notCurrentlyPaused: 'Pausable: not paused', - onlyPauseCanUnPause: 'OnlyPauser()', + onlyPauseCanUnPause: 'Unauthorized()', }, general: { queueClosed: 'QueueClosed()', @@ -354,8 +354,11 @@ describe('Staking', function () { it('should not allow invalid withdrawals', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); - await expect(srStaker0.withdraw(0)).to.be.revertedWith(errors.withdraw.invalid); + await expect(srStaker0.withdraw(0)).to.be.revertedWith(errors.withdraw.invalidWithdrawalAmount); await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); + + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + await expect(srStaker0.withdraw(stakeAmount_0)).to.be.revertedWith(errors.withdraw.invalidWithdrawalAmount); }); it('should allow non-transfer updates to be queued and applied while the node is frozen', async function () { From 27aa102d00bfcaf83da4cc5da1a128c8763fddd8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 15:37:22 +0200 Subject: [PATCH 23/58] docs(staking): NatSpec errors and diagnostic revert args - Document all custom errors with @notice - BelowMinimumStake(have,need), InvalidWaitConfiguration(...), UpdateQueueFull(count,limit) BREAKING CHANGE: StakeRegistry error signatures changed. --- src/Staking.sol | 35 ++++++++++++++++++++++++++--------- test/Redistribution.test.ts | 2 +- test/Staking.test.ts | 2 +- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index db46ac7d..f0a5474c 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -80,20 +80,32 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Errors ------------------------------ + /// @notice ERC20 `transfer` / `transferFrom` returned false for `bzzToken`. error TransferFailed(); + /// @notice Caller is not `DEFAULT_ADMIN_ROLE` (e.g. pause, unpause, `changeNetworkId`). error Unauthorized(); + /// @notice Caller lacks `REDISTRIBUTOR_ROLE` (`freezeDeposit`, `slashDeposit`). error OnlyRedistributor(); - error BelowMinimumStake(); + /// @notice Stake amount `have` is below protocol minimum `need` for the operation (deposit, height, or post-withdraw remainder). + error BelowMinimumStake(uint256 have, uint256 need); + /// @notice No active stake (or preview balance zero) for this action. error NotStaked(); + /// @notice Address already has stake or a pending deposit that establishes one. error AlreadyStaked(); + /// @notice `increaseHeight` cannot lower staking height. error HeightDecreaseNotAllowed(); + /// @notice Pulled token amount must be non-zero (`createDeposit`, `addTokens`). error InvalidAmount(); - /// @notice See `WithdrawalAmountIssue` for meaning of `reason`. + /// @notice `withdraw` rejected before enqueueing; see `WithdrawalAmountIssue`. error InvalidWithdrawalAmount(WithdrawalAmountIssue reason); - error UpdateQueueFull(); + /// @notice Update queue has `queuedCount` pending items; cannot exceed `limit`. + error UpdateQueueFull(uint256 queuedCount, uint256 limit); + /// @notice An exit is scheduled; no further mutations allowed until processed or migrated. error QueueClosed(); + /// @notice Cannot finish applying updates while the head item is a due withdrawal/exit and the stake is frozen. error FrozenWithdrawal(); - error InvalidWaitConfiguration(); + /// @notice Overlay or withdrawal wait rounds must be at least `waitBase` (`waitOverlayChange` / `waitWithdrawal` were below). + error InvalidWaitConfiguration(uint64 waitBase, uint64 waitOverlayChange, uint64 waitWithdrawal); constructor( address _bzzToken, @@ -103,7 +115,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 _waitWithdrawal ) { if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) { - revert InvalidWaitConfiguration(); + revert InvalidWaitConfiguration(_waitBase, _waitOverlayChange, _waitWithdrawal); } NetworkId = _NetworkId; bzzToken = _bzzToken; @@ -132,7 +144,8 @@ contract StakeRegistry is AccessControl, Pausable { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); - if (_amount < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); + uint256 minStake = _minimumStakeForHeight(_height); + if (_amount < minStake) revert BelowMinimumStake(_amount, minStake); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); @@ -189,7 +202,8 @@ contract StakeRegistry is AccessControl, Pausable { if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); if (_height == plannedStake.height) return 0; - if (plannedStake.balance < _minimumStakeForHeight(_height)) revert BelowMinimumStake(); + uint256 minForHeight = _minimumStakeForHeight(_height); + if (plannedStake.balance < minForHeight) revert BelowMinimumStake(plannedStake.balance, minForHeight); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.IncreaseHeight, WAIT_BASE, 0, 0, _height); emit HeightIncreased(msg.sender, effectiveFromRound, _height); @@ -209,7 +223,9 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount >= plannedStake.balance) { revert InvalidWithdrawalAmount(WithdrawalAmountIssue.FullBalanceRequiresExit); } - if (plannedStake.balance - _amount < _minimumStakeForHeight(plannedStake.height)) revert BelowMinimumStake(); + uint256 minAfterWithdraw = _minimumStakeForHeight(plannedStake.height); + uint256 balanceAfter = plannedStake.balance - _amount; + if (balanceAfter < minAfterWithdraw) revert BelowMinimumStake(balanceAfter, minAfterWithdraw); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); @@ -649,7 +665,8 @@ contract StakeRegistry is AccessControl, Pausable { uint256 _amount, uint8 _height ) internal returns (uint64 effectiveFromRound) { - if (_queueLength(_owner) >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(); + uint256 queued = _queueLength(_owner); + if (queued >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(queued, UPDATE_QUEUE_MAX_LENGTH); uint64 candidateRound = currentRound() + _minimumWait; uint64 lastRound = _lastScheduledRound(_owner); diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index da9061b2..6b66aef3 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -185,7 +185,7 @@ const errors = { noBalance: 'ERC20: insufficient allowance', noZeroAddress: 'owner cannot be the zero address', onlyOwner: 'Unauthorized()', - belowMinimum: 'BelowMinimumStake()', + belowMinimum: 'BelowMinimumStake', }, general: { onlyPauser: 'OnlyPauser()', diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 7970350d..3fcbb68c 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -18,7 +18,7 @@ const freezeTime = 3; const errors = { deposit: { noBalance: 'ERC20: insufficient allowance', - belowMinimum: 'BelowMinimumStake()', + belowMinimum: 'BelowMinimumStake', heightDecrease: 'HeightDecreaseNotAllowed()', }, withdraw: { From e989f4cae0e07d6242710a863430fbbccc67b953 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 15:45:31 +0200 Subject: [PATCH 24/58] =?UTF-8?q?refactor(staking):=20review=20items?= =?UTF-8?q?=E2=80=94networkId,=20reconcile,=20coverage=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Expose ROUND_LENGTH; rename NetworkId to networkId - Internal _addressNotFrozen; lookahead delegates when lookahead is zero - Reconcile queue via _applyPreviewUpdate plus WithdrawTokens storage cap - Clear last-scheduled-round when queue empty; reconcile only after slash paths that keep stake - Emit StakeMigrated before payout transfer from migrateStake Tests derive round length from contract; trim redundant constants; add cases for invalid waits, queue full, frozen apply with mismatched overlay delay, migrate event. BREAKING CHANGE: networkId() replaces NetworkId(); ROUND_LENGTH on ABI. --- src/Staking.sol | 66 ++++++++++-------------- test/Staking.test.ts | 119 +++++++++++++++++++++++++++++++++---------- 2 files changed, 117 insertions(+), 68 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index f0a5474c..c4c8edf3 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -15,7 +15,7 @@ import "@openzeppelin/contracts/security/Pausable.sol"; contract StakeRegistry is AccessControl, Pausable { // ----------------------------- State variables ------------------------------ - uint256 private constant ROUND_LENGTH = 152; + uint256 public constant ROUND_LENGTH = 152; uint256 private constant MIN_STAKE = 100000000000000000; uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; @@ -60,7 +60,7 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); - uint64 public NetworkId; + uint64 public networkId; address public immutable bzzToken; uint64 public immutable WAIT_BASE; uint64 public immutable WAIT_OVERLAY_CHANGE; @@ -77,6 +77,7 @@ contract StakeRegistry is AccessControl, Pausable { event Withdrawal(address indexed owner, uint64 registeredFromRound, uint256 amount); event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); event StakeFrozen(address frozen, bytes32 overlay, uint256 time); + event StakeMigrated(address indexed owner, uint256 totalReturned); // ----------------------------- Errors ------------------------------ @@ -109,7 +110,7 @@ contract StakeRegistry is AccessControl, Pausable { constructor( address _bzzToken, - uint64 _NetworkId, + uint64 _networkId, uint64 _waitBase, uint64 _waitOverlayChange, uint64 _waitWithdrawal @@ -117,7 +118,7 @@ contract StakeRegistry is AccessControl, Pausable { if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) { revert InvalidWaitConfiguration(_waitBase, _waitOverlayChange, _waitWithdrawal); } - NetworkId = _NetworkId; + networkId = _networkId; bzzToken = _bzzToken; WAIT_BASE = _waitBase; WAIT_OVERLAY_CHANGE = _waitOverlayChange; @@ -289,6 +290,8 @@ contract StakeRegistry is AccessControl, Pausable { delete _queueHeads[msg.sender]; delete _queueClosed[msg.sender]; + emit StakeMigrated(msg.sender, payout); + if (payout > 0) { if (!ERC20(bzzToken).transfer(msg.sender, payout)) revert TransferFailed(); } @@ -330,13 +333,14 @@ contract StakeRegistry is AccessControl, Pausable { if (_isInitialized(_owner)) { if (stake.balance > _amount) { stake.balance -= _amount; + _reconcileQueuedWithdrawals(_owner); } else if (_queueLength(_owner) > 0) { stake.balance = 0; + _reconcileQueuedWithdrawals(_owner); } else { + // Deletes stake storage including freeze deadline; node may re-stake without residual freeze. delete _stakes[_owner]; } - - _reconcileQueuedWithdrawals(_owner); } emit StakeSlashed(_owner, previousOverlay, _amount); @@ -344,11 +348,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Updates the Swarm network identifier used in overlay derivation. - * @param _NetworkId The new network id. + * @param _networkId The new network id. */ - function changeNetworkId(uint64 _NetworkId) external { + function changeNetworkId(uint64 _networkId) external { if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); - NetworkId = _NetworkId; + networkId = _networkId; } /** @@ -382,7 +386,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the currently effective stake balance for an owner. */ function nodeEffectiveStake(address _owner) public view returns (uint256) { - if (!addressNotFrozen(_owner)) return 0; + if (!_addressNotFrozen(_owner)) return 0; Stake memory preview = _previewStake(_owner, false); return _isInitialized(preview) ? preview.balance : 0; @@ -438,9 +442,9 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns true when the owner is not currently frozen. + * @dev True when the stake is absent or `frozenUntilBlock` is strictly before `block.number`. */ - function addressNotFrozen(address _owner) internal view returns (bool) { + function _addressNotFrozen(address _owner) internal view returns (bool) { return !_isInitialized(_owner) || _stakes[_owner].frozenUntilBlock < block.number; } @@ -529,7 +533,7 @@ contract StakeRegistry is AccessControl, Pausable { return false; } - return !addressNotFrozen(_owner); + return !_addressNotFrozen(_owner); } /** @@ -684,7 +688,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function _lastScheduledRound(address _owner) internal view returns (uint64) { ScheduledUpdate[] storage queue = _updateQueues[_owner]; - if (_queueHeads[_owner] == queue.length) { + if (_queueLength(_owner) == 0) { return 0; } return queue[queue.length - 1].effectiveFromRound; @@ -706,10 +710,10 @@ contract StakeRegistry is AccessControl, Pausable { } if (_lookahead == 0) { - return _stakes[_owner].frozenUntilBlock < block.number; + return _addressNotFrozen(_owner); } - return _stakes[_owner].frozenUntilBlock < uint256(currentRound() + _lookahead) * ROUND_LENGTH; + return _stakes[_owner].frozenUntilBlock < (uint256(currentRound()) + uint256(_lookahead)) * ROUND_LENGTH; } /** @@ -724,32 +728,14 @@ contract StakeRegistry is AccessControl, Pausable { for (uint256 i = head; i < queue.length;) { ScheduledUpdate storage scheduled = queue[i]; - if (scheduled.kind == UpdateKind.CreateDeposit) { - preview.overlay = _deriveOverlay(_owner, scheduled.nonce); - preview.balance = scheduled.amount; - preview.height = scheduled.height; - } else if (scheduled.kind == UpdateKind.AddTokens) { - preview.balance += scheduled.amount; - } else if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (_isInitialized(preview) && scheduled.height > preview.height) { - preview.height = scheduled.height; - } - } else if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (_isInitialized(preview)) { - preview.overlay = _deriveOverlay(_owner, scheduled.nonce); - } - } else if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (_isInitialized(preview)) { - if (scheduled.amount > preview.balance) { - scheduled.amount = preview.balance; - } - - preview.balance -= scheduled.amount; + if (scheduled.kind == UpdateKind.WithdrawTokens && _isInitialized(preview)) { + if (scheduled.amount > preview.balance) { + scheduled.amount = preview.balance; } - } else if (scheduled.kind == UpdateKind.ExitStake) { - delete preview; } + preview = _applyPreviewUpdate(_owner, preview, scheduled); + unchecked { ++i; } @@ -775,7 +761,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Derives an overlay from owner, network id and nonce. */ function _deriveOverlay(address _owner, bytes32 _setNonce) internal view returns (bytes32) { - return keccak256(abi.encodePacked(_owner, reverse(NetworkId), _setNonce)); + return keccak256(abi.encodePacked(_owner, reverse(networkId), _setNonce)); } /** diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 3fcbb68c..dbdb1fba 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -11,7 +11,8 @@ let pauser: string; let staker_0: string; let staker_1: string; -const roundLength = 152; +/** Blocks per staking round; overwritten from `StakeRegistry.ROUND_LENGTH()` after fixture load. */ +let roundLength = 152; const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000'; const freezeTime = 3; @@ -40,6 +41,8 @@ const errors = { general: { queueClosed: 'QueueClosed()', frozenWithdrawal: 'FrozenWithdrawal()', + queueFull: 'UpdateQueueFull', + invalidWaitConfig: 'InvalidWaitConfiguration', }, }; @@ -47,24 +50,18 @@ const overlay_0 = '0xa602fa47b3e8ce39ffc2017ad9069ff95eb58c051b1cfa2b0d86bc44a54 const overlay_1 = '0xa6f955c72d7053f96b91b5470491a0c732b0175af56dcfb7a604b82b16719406'; const overlay_1_n_25 = '0x676766bbae530fd0483e4734e800569c95929b707b9c50f8717dc99f9f91e915'; const nonce_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; -const nonce_1 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; -const obfuscatedHash_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; +const obfuscatedHash_0 = nonce_0; const stakeAmount_0 = '100000000000000000'; const doubleStakeAmount_0 = '200000000000000000'; const tripleStakeAmount_0 = '300000000000000000'; -const topUpForHeight1 = '100000000000000000'; -const stakeAmount_1 = '100000000000000000'; -const updateStakeAmount_0 = '633633'; -const withdrawAmount = '100000000000000000'; -const doubleWithdrawAmount = '200000000000000000'; +const withdrawAmount = stakeAmount_0; +const doubleWithdrawAmount = doubleStakeAmount_0; const slashAmount = '50000000000000000'; -const doubleSlashAmount = '200000000000000000'; -const partialSlashBalance = '50000000000000000'; +const doubleSlashAmount = doubleStakeAmount_0; +const partialSlashBalance = slashAmount; const height_0 = 0; const height_0_n_1 = 1; -const height_1 = 0; -const height_1_n_1 = 1; before(async function () { const namedAccounts = await getNamedAccounts(); @@ -107,11 +104,21 @@ async function activateStake(contract: Contract, owner: string, nonce: string, a await contract.applyUpdates(owner); } +async function getSignerFor(address: string) { + const signers = await ethers.getSigners(); + const signer = signers.find((s) => s.address.toLowerCase() === address.toLowerCase()); + if (!signer) { + throw new Error(`No unlocked signer for ${address}`); + } + return signer; +} + describe('Staking', function () { beforeEach(async function () { await deployments.fixture(); token = await ethers.getContract('TestToken', deployer); stakeRegistry = await ethers.getContract('StakeRegistry'); + roundLength = (await stakeRegistry.ROUND_LENGTH()).toNumber(); const pauserRole = await read('StakeRegistry', 'DEFAULT_ADMIN_ROLE'); await execute('StakeRegistry', { from: deployer }, 'grantRole', pauserRole, pauser); @@ -119,6 +126,7 @@ describe('Staking', function () { it('should deploy StakeRegistry with queue wait parameters', async function () { expect(stakeRegistry.address).to.be.properAddress; + expect(await stakeRegistry.ROUND_LENGTH()).to.be.eq(roundLength); expect(await stakeRegistry.WAIT_BASE()).to.be.eq(2); expect(await stakeRegistry.WAIT_OVERLAY_CHANGE()).to.be.eq(2); expect(await stakeRegistry.WAIT_WITHDRAWAL()).to.be.eq(2); @@ -157,9 +165,9 @@ describe('Staking', function () { it('should not allow first stake below minimum for the requested height', async function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); - await mintAndApprove(staker_1, srStaker1.address, stakeAmount_1); + await mintAndApprove(staker_1, srStaker1.address, stakeAmount_0); - await expect(srStaker1.createDeposit(nonce_1, stakeAmount_1, height_1_n_1)).to.be.revertedWith( + await expect(srStaker1.createDeposit(nonce_0, stakeAmount_0, height_0_n_1)).to.be.revertedWith( errors.deposit.belowMinimum ); }); @@ -168,8 +176,8 @@ describe('Staking', function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - await mintAndApprove(staker_0, srStaker0.address, topUpForHeight1); - await expect(srStaker0.addTokens(topUpForHeight1)).to.emit(srStaker0, 'TokensAdded'); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.addTokens(stakeAmount_0)).to.emit(srStaker0, 'TokensAdded'); await expect(srStaker0.increaseHeight(height_0_n_1)).to.emit(srStaker0, 'HeightIncreased'); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); @@ -183,7 +191,7 @@ describe('Staking', function () { it('should schedule overlay changes and expose them after the overlay delay', async function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); - await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); + await activateStake(srStaker1, staker_1, nonce_0, stakeAmount_0, height_0); await expect(srStaker1.changeOverlay(nonce_1_n_25)).to.emit(srStaker1, 'OverlayChanged'); expect(await srStaker1.overlayOfAddress(staker_1)).to.be.eq(overlay_1); @@ -201,20 +209,20 @@ describe('Staking', function () { it('should preview queued stake state with lookahead', async function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); - await activateStake(srStaker1, staker_1, nonce_1, stakeAmount_1, height_1); + await activateStake(srStaker1, staker_1, nonce_0, stakeAmount_0, height_0); - await mintAndApprove(staker_1, srStaker1.address, topUpForHeight1); - await srStaker1.addTokens(topUpForHeight1); + await mintAndApprove(staker_1, srStaker1.address, stakeAmount_0); + await srStaker1.addTokens(stakeAmount_0); await srStaker1.changeOverlay(nonce_1_n_25); - await srStaker1.increaseHeight(height_1_n_1); + await srStaker1.increaseHeight(height_0_n_1); - expect(await srStaker1.nodeEffectiveStakeLookahead(staker_1, 1)).to.be.eq(stakeAmount_1); + expect(await srStaker1.nodeEffectiveStakeLookahead(staker_1, 1)).to.be.eq(stakeAmount_0); expect(await srStaker1.overlayOfAddressLookahead(staker_1, 1)).to.be.eq(overlay_1); - expect(await srStaker1.heightOfAddressLookahead(staker_1, 1)).to.be.eq(height_1); + expect(await srStaker1.heightOfAddressLookahead(staker_1, 1)).to.be.eq(height_0); expect(await srStaker1.nodeEffectiveStakeLookahead(staker_1, 2)).to.be.eq(doubleStakeAmount_0); expect(await srStaker1.overlayOfAddressLookahead(staker_1, 2)).to.be.eq(overlay_1_n_25); - expect(await srStaker1.heightOfAddressLookahead(staker_1, 2)).to.be.eq(height_1_n_1); + expect(await srStaker1.heightOfAddressLookahead(staker_1, 2)).to.be.eq(height_0_n_1); }); it('should keep effective stake equal to balance after oracle price changes', async function () { @@ -347,7 +355,7 @@ describe('Staking', function () { await srStaker0.exit(); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); - await expect(srStaker0.createDeposit(nonce_1, stakeAmount_0, height_0)).to.be.revertedWith( + await expect(srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0)).to.be.revertedWith( errors.general.queueClosed ); }); @@ -377,8 +385,8 @@ describe('Staking', function () { expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); - await mintAndApprove(staker_0, srStaker0.address, topUpForHeight1); - await expect(srStaker0.addTokens(topUpForHeight1)).to.emit(srStaker0, 'TokensAdded'); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.addTokens(stakeAmount_0)).to.emit(srStaker0, 'TokensAdded'); await expect(srStaker0.changeOverlay(nonce_1_n_25)).to.emit(srStaker0, 'OverlayChanged'); await expect(srStaker0.increaseHeight(height_0_n_1)).to.emit(srStaker0, 'HeightIncreased'); @@ -475,7 +483,9 @@ describe('Staking', function () { const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); await stakeRegistryPauser.pause(); - await srStaker0.migrateStake(); + await expect(srStaker0.migrateStake()) + .to.emit(srStaker0, 'StakeMigrated') + .withArgs(staker_0, stakeAmount_0); expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); @@ -494,4 +504,57 @@ describe('Staking', function () { await stakeRegistryPauser.unPause(); await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; }); + + it('should reject staking constructor when waits are below base', async function () { + const tokenDeploy = await ethers.getContract('TestToken', deployer); + const netId = await stakeRegistry.networkId(); + const Factory = await ethers.getContractFactory('StakeRegistry'); + await expect(Factory.deploy(tokenDeploy.address, netId, 5, 4, 8)).to.be.revertedWith( + errors.general.invalidWaitConfig + ); + }); + + it('should reject enqueue when the update queue is full', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); + for (let i = 0; i < 10; i++) { + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.addTokens(stakeAmount_0)).to.emit(srStaker0, 'TokensAdded'); + } + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.addTokens(stakeAmount_0)).to.be.revertedWith(errors.general.queueFull); + }); + + it('should apply earlier-enqueued top-up while frozen before overlay change is due', async function () { + const Factory = await ethers.getContractFactory('StakeRegistry'); + const srAlt = await Factory.deploy( + token.address, + await stakeRegistry.networkId(), + 2, + 10, + 2 + ); + await srAlt.deployed(); + await srAlt.grantRole(await srAlt.REDISTRIBUTOR_ROLE(), redistributor); + + const srStaker = srAlt.connect(await getSignerFor(staker_0)); + await mintAndApprove(staker_0, srAlt.address, doubleStakeAmount_0); + await srStaker.createDeposit(nonce_0, stakeAmount_0, height_0); + await advanceRounds(); + await srAlt.applyUpdates(staker_0); + + await mintAndApprove(staker_0, srAlt.address, stakeAmount_0); + await srStaker.addTokens(stakeAmount_0); + await srStaker.changeOverlay(nonce_1_n_25); + + const srRedis = srAlt.connect(await getSignerFor(redistributor)); + await srRedis.freezeDeposit(staker_0, roundLength * 25); + + await advanceRounds(2); + + await srAlt.applyUpdates(staker_0); + + expect((await srAlt.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); + expect(await srAlt.overlayOfAddress(staker_0)).to.be.eq(overlay_0); + }); }); From e11a04f6f5c2ed8cc1c033f4a823af7a7d5882b0 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 15:49:22 +0200 Subject: [PATCH 25/58] test(staking): cover enqueue returns, no-ops, queue limits --- test/Staking.test.ts | 187 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 4 deletions(-) diff --git a/test/Staking.test.ts b/test/Staking.test.ts index dbdb1fba..a50b88a3 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -1,6 +1,6 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts } from 'hardhat'; -import { Contract } from 'ethers'; +import { BigNumber, Contract } from 'ethers'; import { mineNBlocks } from './util/tools'; const { read, execute } = deployments; @@ -23,7 +23,8 @@ const errors = { heightDecrease: 'HeightDecreaseNotAllowed()', }, withdraw: { - invalidWithdrawalAmount: 'InvalidWithdrawalAmount', + invalidWithdrawalAmountZero: 'InvalidWithdrawalAmount(0)', + invalidWithdrawalAmountFullBalance: 'InvalidWithdrawalAmount(1)', notStaked: 'NotStaked()', }, slash: { @@ -113,6 +114,22 @@ async function getSignerFor(address: string) { return signer; } +/** Effective staking round from enqueue events (ABI may expose `registeredFromRound`). */ +function effectiveRoundFromEvent(ev: any): BigNumber { + return ev.args?.effectiveFromRound ?? ev.args?.registeredFromRound ?? ev.args?.[1]; +} + +/** Custom errors with arguments often fail Chai `revertedWith` exact matching in waffle; match substring instead. */ +async function expectRevertReasonSubstring(txPromise: Promise, substring: string) { + try { + await txPromise; + expect.fail(`expected revert containing ${substring}`); + } catch (e: any) { + const combined = `${e.message ?? ''}${e.error?.message ?? ''}`; + expect(combined).to.include(substring); + } +} + describe('Staking', function () { beforeEach(async function () { await deployments.fixture(); @@ -362,11 +379,14 @@ describe('Staking', function () { it('should not allow invalid withdrawals', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); - await expect(srStaker0.withdraw(0)).to.be.revertedWith(errors.withdraw.invalidWithdrawalAmount); + await expectRevertReasonSubstring(srStaker0.withdraw(0), errors.withdraw.invalidWithdrawalAmountZero); await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - await expect(srStaker0.withdraw(stakeAmount_0)).to.be.revertedWith(errors.withdraw.invalidWithdrawalAmount); + await expectRevertReasonSubstring( + srStaker0.withdraw(stakeAmount_0), + errors.withdraw.invalidWithdrawalAmountFullBalance + ); }); it('should allow non-transfer updates to be queued and applied while the node is frozen', async function () { @@ -505,6 +525,165 @@ describe('Staking', function () { await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; }); + describe('enqueue API surface', function () { + it('should match createDeposit callStatic return to DepositCreated round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + const fromCall = await sr.callStatic.createDeposit(nonce_0, stakeAmount_0, height_0); + const tx = await sr.createDeposit(nonce_0, stakeAmount_0, height_0); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'DepositCreated'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should match addTokens callStatic return to TokensAdded round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, stakeAmount_0, height_0); + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + const fromCall = await sr.callStatic.addTokens(stakeAmount_0); + const tx = await sr.addTokens(stakeAmount_0); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'TokensAdded'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should match changeOverlay callStatic return to OverlayChanged round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_1); + await activateStake(sr, staker_1, nonce_0, stakeAmount_0, height_0); + const fromCall = await sr.callStatic.changeOverlay(nonce_1_n_25); + const tx = await sr.changeOverlay(nonce_1_n_25); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'OverlayChanged'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should match increaseHeight callStatic return to HeightIncreased round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); + const fromCall = await sr.callStatic.increaseHeight(height_0_n_1); + const tx = await sr.increaseHeight(height_0_n_1); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'HeightIncreased'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should match withdraw callStatic return to Withdrawal round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); + const fromCall = await sr.callStatic.withdraw(withdrawAmount); + const tx = await sr.withdraw(withdrawAmount); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'Withdrawal'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should match exit callStatic return to Withdrawal round', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_1); + await activateStake(sr, staker_1, nonce_0, stakeAmount_0, height_0); + const fromCall = await sr.callStatic.exit(); + const tx = await sr.exit(); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'Withdrawal'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + it('should return 0 and emit nothing when overlay is unchanged', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, stakeAmount_0, height_0); + expect(await sr.callStatic.changeOverlay(nonce_0)).to.eq(0); + const tx = await sr.changeOverlay(nonce_0); + const receipt = await tx.wait(); + expect(receipt.events?.some((e: any) => e.event === 'OverlayChanged')).to.eq(false); + }); + + it('should return 0 and emit nothing when height is unchanged', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0_n_1); + expect(await sr.callStatic.increaseHeight(height_0_n_1)).to.eq(0); + const tx = await sr.increaseHeight(height_0_n_1); + const receipt = await tx.wait(); + expect(receipt.events?.some((e: any) => e.event === 'HeightIncreased')).to.eq(false); + }); + + it('should assign non-decreasing effective rounds when stacking addTokens without mining', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, stakeAmount_0, height_0); + const rounds: BigNumber[] = []; + for (let i = 0; i < 5; i++) { + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + const tx = await sr.addTokens(stakeAmount_0); + const receipt = await tx.wait(); + const ev = receipt.events!.find((e: any) => e.event === 'TokensAdded'); + rounds.push(effectiveRoundFromEvent(ev)); + } + for (let i = 1; i < rounds.length; i++) { + expect(rounds[i].gte(rounds[i - 1])).to.eq(true); + } + }); + + it('should reject withdraw that would leave remainder below minimum for current height', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); + const almostAll = BigNumber.from(doubleStakeAmount_0).sub(1); + await expectRevertReasonSubstring(sr.withdraw(almostAll), errors.deposit.belowMinimum); + }); + + it('should revert applyUpdates atomically when a due withdrawal is blocked by freeze (withdraw then top-up)', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + const srDeployer = await ethers.getContract('StakeRegistry', deployer); + await srDeployer.grantRole(await srDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + await srRedis.freezeDeposit(staker_0, longFreezeTime); + + await sr.withdraw(withdrawAmount); + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + await sr.addTokens(stakeAmount_0); + await advanceRounds(); + + const balanceBefore = (await sr.stakes(staker_0)).balance; + await expect(sr.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); + expect((await sr.stakes(staker_0)).balance).to.eq(balanceBefore); + }); + + it('should revert applyUpdates atomically when due withdrawal blocks after earlier queued top-up', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; + await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + const srDeployer = await ethers.getContract('StakeRegistry', deployer); + await srDeployer.grantRole(await srDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + await srRedis.freezeDeposit(staker_0, longFreezeTime); + + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + await sr.addTokens(stakeAmount_0); + await sr.withdraw(withdrawAmount); + await advanceRounds(); + + const balanceBefore = (await sr.stakes(staker_0)).balance; + await expect(sr.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); + expect((await sr.stakes(staker_0)).balance).to.eq(balanceBefore); + }); + + it('should migrate active stake plus queued addTokens payout when paused', async function () { + const sr = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(sr, staker_0, nonce_0, stakeAmount_0, height_0); + await mintAndApprove(staker_0, sr.address, stakeAmount_0); + await sr.addTokens(stakeAmount_0); + + const srPauser = await ethers.getContract('StakeRegistry', pauser); + await srPauser.pause(); + + const payout = BigNumber.from(stakeAmount_0).add(stakeAmount_0); + await expect(sr.migrateStake()).to.emit(sr, 'StakeMigrated').withArgs(staker_0, payout); + expect(await token.balanceOf(staker_0)).to.eq(doubleStakeAmount_0); + expect((await sr.stakes(staker_0)).balance).to.eq(0); + }); + }); + it('should reject staking constructor when waits are below base', async function () { const tokenDeploy = await ethers.getContract('TestToken', deployer); const netId = await stakeRegistry.networkId(); From 89d93d5b531388f991242dae44fb3487f7a21d94 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 6 May 2026 16:00:56 +0200 Subject: [PATCH 26/58] chore(lint): format Staking.sol and clean tests --- src/Staking.sol | 71 +++++++++++++++++++++++---------------- test/PostageStamp.test.ts | 6 ---- test/PriceOracle.test.ts | 6 ---- test/Staking.test.ts | 57 ++++++++++++++++--------------- 4 files changed, 71 insertions(+), 69 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index c4c8edf3..f8da10cc 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -69,7 +69,11 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- Events ------------------------------ event DepositCreated( - address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height + address indexed owner, + uint64 registeredFromRound, + uint256 amount, + bytes32 overlay, + uint8 height ); event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); @@ -137,11 +141,11 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The initial staking height. * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) - external - whenNotPaused - returns (uint64 effectiveFromRound) - { + function createDeposit( + bytes32 _setNonce, + uint256 _amount, + uint8 _height + ) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); @@ -151,8 +155,14 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); _pullTokens(msg.sender, _amount); - effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.CreateDeposit, WAIT_BASE, _setNonce, _amount, _height); + effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.CreateDeposit, + WAIT_BASE, + _setNonce, + _amount, + _height + ); emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); } @@ -186,8 +196,7 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) return 0; - effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); } @@ -228,8 +237,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 balanceAfter = plannedStake.balance - _amount; if (balanceAfter < minAfterWithdraw) revert BelowMinimumStake(balanceAfter, minAfterWithdraw); - effectiveFromRound = - _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); emit Withdrawal(msg.sender, effectiveFromRound, _amount); } @@ -256,8 +264,9 @@ contract StakeRegistry is AccessControl, Pausable { ScheduledUpdate[] storage queue = _updateQueues[_owner]; uint256 head = _queueHeads[_owner]; if ( - head < queue.length && queue[head].effectiveFromRound <= currentRound() - && _blocksQueuedWithdrawalExecution(_owner, queue[head].kind) + head < queue.length && + queue[head].effectiveFromRound <= currentRound() && + _blocksQueuedWithdrawalExecution(_owner, queue[head].kind) ) { revert FrozenWithdrawal(); } @@ -274,7 +283,7 @@ contract StakeRegistry is AccessControl, Pausable { ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; uint256 head = _queueHeads[msg.sender]; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { payout += scheduled.amount; @@ -540,11 +549,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. * @dev Lookahead previews only defer execution while the node remains frozen. */ - function _blocksQueuedWithdrawalExecutionLookahead(address _owner, UpdateKind _kind, uint64 _lookahead) - internal - view - returns (bool) - { + function _blocksQueuedWithdrawalExecutionLookahead( + address _owner, + UpdateKind _kind, + uint64 _lookahead + ) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } @@ -562,7 +571,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; @@ -589,7 +598,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; uint64 targetRound = currentRound() + _lookahead; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.effectiveFromRound > targetRound) { break; @@ -609,11 +618,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to an in-memory preview state. */ - function _applyPreviewUpdate(address _owner, Stake memory preview, ScheduledUpdate storage scheduled) - internal - view - returns (Stake memory) - { + function _applyPreviewUpdate( + address _owner, + Stake memory preview, + ScheduledUpdate storage scheduled + ) internal view returns (Stake memory) { if (scheduled.kind == UpdateKind.CreateDeposit) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); preview.balance = scheduled.amount; @@ -678,7 +687,11 @@ contract StakeRegistry is AccessControl, Pausable { _updateQueues[_owner].push( ScheduledUpdate({ - kind: _kind, effectiveFromRound: effectiveFromRound, nonce: _nonce, amount: _amount, height: _height + kind: _kind, + effectiveFromRound: effectiveFromRound, + nonce: _nonce, + amount: _amount, + height: _height }) ); } @@ -725,7 +738,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 head = _queueHeads[_owner]; Stake memory preview = _stakes[_owner]; - for (uint256 i = head; i < queue.length;) { + for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; if (scheduled.kind == UpdateKind.WithdrawTokens && _isInitialized(preview)) { diff --git a/test/PostageStamp.test.ts b/test/PostageStamp.test.ts index 36b9026b..8ef35f24 100644 --- a/test/PostageStamp.test.ts +++ b/test/PostageStamp.test.ts @@ -50,12 +50,9 @@ const errors = { }; describe('PostageStamp', function () { - let minimumPrice: number; describe('when deploying contract', function () { beforeEach(async function () { await deployments.fixture(); - const priceOracle = await ethers.getContract('PriceOracle'); - minimumPrice = await priceOracle.minimumPrice(); }); it('should have minimum bucket depth set to 16', async function () { @@ -233,9 +230,6 @@ describe('PostageStamp', function () { const batch0 = computeBatchId(stamper, nonce0); expect(batch0).equal(await postageStampStamper.firstBatchId()); - const blocksElapsed = (await getBlockNumber()) - setPrice0Block; - const expectedNormalisedBalance1 = initialPaymentPerChunk1 + blocksElapsed * price0; - const nonce1 = '0x0000000000000000000000000000000000000000000000000000000000001235'; await postageStampStamper.createBatch( stamper, diff --git a/test/PriceOracle.test.ts b/test/PriceOracle.test.ts index 739cca1b..985b4abc 100644 --- a/test/PriceOracle.test.ts +++ b/test/PriceOracle.test.ts @@ -29,13 +29,9 @@ const errors = { }; describe('PriceOracle', function () { - let minimumPrice: number; - describe('when deploying contract', function () { beforeEach(async function () { await deployments.fixture(); - const priceOracle = await ethers.getContract('PriceOracle'); - minimumPrice = await priceOracle.minimumPrice(); }); it('should deploy PriceOracle', async function () { @@ -179,7 +175,6 @@ describe('PriceOracle', function () { describe('automatic update', function () { let minPriceString: string; let priceOracle: Contract, postageStamp: Contract; - let priceBaseString: string; let priceBase: number; beforeEach(async function () { @@ -196,7 +191,6 @@ describe('PriceOracle', function () { // Set price base priceBase = await priceOracle.priceBase(); - priceBaseString = priceBase.toString(); }); it('if redundany factor is 0', async function () { diff --git a/test/Staking.test.ts b/test/Staking.test.ts index a50b88a3..159c4e81 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -1,6 +1,6 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts } from 'hardhat'; -import { BigNumber, Contract } from 'ethers'; +import { BigNumber, Contract, ContractTransaction, Event } from 'ethers'; import { mineNBlocks } from './util/tools'; const { read, execute } = deployments; @@ -87,7 +87,7 @@ async function advanceRounds(rounds = 2) { await mineNBlocks(roundLength * rounds); } -async function advanceToRoundCommitPhase(redistribution: Contract, targetRound: any) { +async function advanceToRoundCommitPhase(redistribution: Contract, targetRound: BigNumber) { while (true) { const currentRound = await redistribution.currentRound(); const inCommitPhase = await redistribution.currentPhaseCommit(); @@ -115,17 +115,26 @@ async function getSignerFor(address: string) { } /** Effective staking round from enqueue events (ABI may expose `registeredFromRound`). */ -function effectiveRoundFromEvent(ev: any): BigNumber { - return ev.args?.effectiveFromRound ?? ev.args?.registeredFromRound ?? ev.args?.[1]; +function effectiveRoundFromEvent(ev: Event | undefined): BigNumber { + if (!ev?.args) { + throw new Error('expected event with args'); + } + const args = ev.args as readonly unknown[] & { + effectiveFromRound?: BigNumber; + registeredFromRound?: BigNumber; + }; + const fromNamed = args.effectiveFromRound ?? args.registeredFromRound; + return fromNamed !== undefined ? fromNamed : (args[1] as BigNumber); } /** Custom errors with arguments often fail Chai `revertedWith` exact matching in waffle; match substring instead. */ -async function expectRevertReasonSubstring(txPromise: Promise, substring: string) { +async function expectRevertReasonSubstring(txPromise: Promise, substring: string) { try { await txPromise; expect.fail(`expected revert containing ${substring}`); - } catch (e: any) { - const combined = `${e.message ?? ''}${e.error?.message ?? ''}`; + } catch (e: unknown) { + const err = e as { message?: string; error?: { message?: string } }; + const combined = `${err.message ?? ''}${err.error?.message ?? ''}`; expect(combined).to.include(substring); } } @@ -310,7 +319,7 @@ describe('Staking', function () { await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); const withdrawalReceipt = await (await srStaker0.withdraw(withdrawAmount)).wait(); - const withdrawalEvent = withdrawalReceipt.events?.find((event: any) => event.event === 'Withdrawal'); + const withdrawalEvent = withdrawalReceipt.events?.find((event: Event) => event.event === 'Withdrawal'); const effectiveRound = withdrawalEvent?.args?.effectiveFromRound ?? withdrawalEvent?.args?.[1]; await advanceToRoundCommitPhase(redistribution, effectiveRound); @@ -333,7 +342,7 @@ describe('Staking', function () { await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); const exitReceipt = await (await srStaker0.exit()).wait(); - const exitEvent = exitReceipt.events?.find((event: any) => event.event === 'Withdrawal'); + const exitEvent = exitReceipt.events?.find((event: Event) => event.event === 'Withdrawal'); const effectiveRound = exitEvent?.args?.effectiveFromRound ?? exitEvent?.args?.[1]; await advanceToRoundCommitPhase(redistribution, effectiveRound); @@ -503,9 +512,7 @@ describe('Staking', function () { const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); await stakeRegistryPauser.pause(); - await expect(srStaker0.migrateStake()) - .to.emit(srStaker0, 'StakeMigrated') - .withArgs(staker_0, stakeAmount_0); + await expect(srStaker0.migrateStake()).to.emit(srStaker0, 'StakeMigrated').withArgs(staker_0, stakeAmount_0); expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); @@ -532,7 +539,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.createDeposit(nonce_0, stakeAmount_0, height_0); const tx = await sr.createDeposit(nonce_0, stakeAmount_0, height_0); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'DepositCreated'); + const ev = receipt.events!.find((e: Event) => e.event === 'DepositCreated'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -543,7 +550,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.addTokens(stakeAmount_0); const tx = await sr.addTokens(stakeAmount_0); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'TokensAdded'); + const ev = receipt.events!.find((e: Event) => e.event === 'TokensAdded'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -553,7 +560,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.changeOverlay(nonce_1_n_25); const tx = await sr.changeOverlay(nonce_1_n_25); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'OverlayChanged'); + const ev = receipt.events!.find((e: Event) => e.event === 'OverlayChanged'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -563,7 +570,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.increaseHeight(height_0_n_1); const tx = await sr.increaseHeight(height_0_n_1); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'HeightIncreased'); + const ev = receipt.events!.find((e: Event) => e.event === 'HeightIncreased'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -573,7 +580,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.withdraw(withdrawAmount); const tx = await sr.withdraw(withdrawAmount); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'Withdrawal'); + const ev = receipt.events!.find((e: Event) => e.event === 'Withdrawal'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -583,7 +590,7 @@ describe('Staking', function () { const fromCall = await sr.callStatic.exit(); const tx = await sr.exit(); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'Withdrawal'); + const ev = receipt.events!.find((e: Event) => e.event === 'Withdrawal'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); @@ -593,7 +600,7 @@ describe('Staking', function () { expect(await sr.callStatic.changeOverlay(nonce_0)).to.eq(0); const tx = await sr.changeOverlay(nonce_0); const receipt = await tx.wait(); - expect(receipt.events?.some((e: any) => e.event === 'OverlayChanged')).to.eq(false); + expect(receipt.events?.some((e: Event) => e.event === 'OverlayChanged')).to.eq(false); }); it('should return 0 and emit nothing when height is unchanged', async function () { @@ -602,7 +609,7 @@ describe('Staking', function () { expect(await sr.callStatic.increaseHeight(height_0_n_1)).to.eq(0); const tx = await sr.increaseHeight(height_0_n_1); const receipt = await tx.wait(); - expect(receipt.events?.some((e: any) => e.event === 'HeightIncreased')).to.eq(false); + expect(receipt.events?.some((e: Event) => e.event === 'HeightIncreased')).to.eq(false); }); it('should assign non-decreasing effective rounds when stacking addTokens without mining', async function () { @@ -613,7 +620,7 @@ describe('Staking', function () { await mintAndApprove(staker_0, sr.address, stakeAmount_0); const tx = await sr.addTokens(stakeAmount_0); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: any) => e.event === 'TokensAdded'); + const ev = receipt.events!.find((e: Event) => e.event === 'TokensAdded'); rounds.push(effectiveRoundFromEvent(ev)); } for (let i = 1; i < rounds.length; i++) { @@ -706,13 +713,7 @@ describe('Staking', function () { it('should apply earlier-enqueued top-up while frozen before overlay change is due', async function () { const Factory = await ethers.getContractFactory('StakeRegistry'); - const srAlt = await Factory.deploy( - token.address, - await stakeRegistry.networkId(), - 2, - 10, - 2 - ); + const srAlt = await Factory.deploy(token.address, await stakeRegistry.networkId(), 2, 10, 2); await srAlt.deployed(); await srAlt.grantRole(await srAlt.REDISTRIBUTOR_ROLE(), redistributor); From 97c0d7e8cef5eecd38fdeab488eee9cfad991094 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 7 May 2026 09:33:43 +0200 Subject: [PATCH 27/58] refactor(staking): fold full withdraw into BelowMinimumStake --- src/Staking.sol | 13 +++++++------ test/Staking.test.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index f8da10cc..16b56f39 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -32,10 +32,10 @@ contract StakeRegistry is AccessControl, Pausable { /// @dev Why `withdraw` was rejected before anything was queued. enum WithdrawalAmountIssue { - /// Amount is zero; withdraw only accepts positive partial pulls (see `exit()` for full unwind). + /// Amount is zero; `withdraw` only accepts positive pulls (see `exit()` for full unwind). Zero, - /// Amount is greater than or equal to current stake; use `exit()` to schedule a full withdrawal after delay. - FullBalanceRequiresExit + /// Amount is greater than the previewed stake balance. + ExceedsBalance } struct Stake { @@ -101,7 +101,7 @@ contract StakeRegistry is AccessControl, Pausable { error HeightDecreaseNotAllowed(); /// @notice Pulled token amount must be non-zero (`createDeposit`, `addTokens`). error InvalidAmount(); - /// @notice `withdraw` rejected before enqueueing; see `WithdrawalAmountIssue`. + /// @notice `withdraw` rejected before enqueueing; see `WithdrawalAmountIssue`. For a remainder below minimum (including withdrawing the entire balance here), see `BelowMinimumStake`; use `exit()` for a scheduled full unwind. error InvalidWithdrawalAmount(WithdrawalAmountIssue reason); /// @notice Update queue has `queuedCount` pending items; cannot exceed `limit`. error UpdateQueueFull(uint256 queuedCount, uint256 limit); @@ -222,6 +222,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules a partial withdrawal after the withdrawal delay. * @param _amount The amount of BZZ to withdraw from the stake. + * @dev A full unwind must use `exit()`, not `withdraw(balance)`. Overdrawing reverts with `ExceedsBalance`; leaving a remainder below the height minimum reverts with `BelowMinimumStake`. * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { @@ -230,8 +231,8 @@ contract StakeRegistry is AccessControl, Pausable { Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); - if (_amount >= plannedStake.balance) { - revert InvalidWithdrawalAmount(WithdrawalAmountIssue.FullBalanceRequiresExit); + if (_amount > plannedStake.balance) { + revert InvalidWithdrawalAmount(WithdrawalAmountIssue.ExceedsBalance); } uint256 minAfterWithdraw = _minimumStakeForHeight(plannedStake.height); uint256 balanceAfter = plannedStake.balance - _amount; diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 159c4e81..82d98542 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -24,7 +24,7 @@ const errors = { }, withdraw: { invalidWithdrawalAmountZero: 'InvalidWithdrawalAmount(0)', - invalidWithdrawalAmountFullBalance: 'InvalidWithdrawalAmount(1)', + invalidWithdrawalAmountExceedsBalance: 'InvalidWithdrawalAmount(1)', notStaked: 'NotStaked()', }, slash: { @@ -392,9 +392,12 @@ describe('Staking', function () { await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + await expectRevertReasonSubstring(srStaker0.withdraw(stakeAmount_0), errors.deposit.belowMinimum); + + const overdraw = BigNumber.from(stakeAmount_0).add(1).toString(); await expectRevertReasonSubstring( - srStaker0.withdraw(stakeAmount_0), - errors.withdraw.invalidWithdrawalAmountFullBalance + srStaker0.withdraw(overdraw), + errors.withdraw.invalidWithdrawalAmountExceedsBalance ); }); From 9d0a05460a95b660b1440cd0c33d430edfc5b08c Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 7 May 2026 15:19:02 +0200 Subject: [PATCH 28/58] test(staking): add FIFO queue and mixed-delay cases --- test/Staking.test.ts | 110 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 82d98542..2592c906 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -740,4 +740,114 @@ describe('Staking', function () { expect((await srAlt.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); expect(await srAlt.overlayOfAddress(staker_0)).to.be.eq(overlay_0); }); + + describe('queue FIFO and mixed delays', function () { + /** WAIT_BASE / WAIT_OVERLAY_CHANGE / WAIT_WITHDRAWAL — overlay and withdrawal waits > base so effective rounds spread out. */ + async function deployStakeRegistryAlt( + waitBase: number, + waitOverlay: number, + waitWithdrawal: number + ): Promise { + const Factory = await ethers.getContractFactory('StakeRegistry'); + const srAlt = await Factory.deploy( + token.address, + await stakeRegistry.networkId(), + waitBase, + waitOverlay, + waitWithdrawal + ); + await srAlt.deployed(); + await srAlt.grantRole(await srAlt.REDISTRIBUTOR_ROLE(), redistributor); + return srAlt; + } + + it('applies addTokens, withdraw, and changeOverlay in effective-round order when waits differ', async function () { + const waitBase = 2; + const waitWithdrawal = 6; + const waitOverlay = 10; + const srAlt = await deployStakeRegistryAlt(waitBase, waitOverlay, waitWithdrawal); + const srStaker = srAlt.connect(await getSignerFor(staker_1)); + + expect(await srAlt.WAIT_BASE()).to.eq(waitBase); + expect(await srAlt.WAIT_OVERLAY_CHANGE()).to.eq(waitOverlay); + expect(await srAlt.WAIT_WITHDRAWAL()).to.eq(waitWithdrawal); + + await mintAndApprove(staker_1, srAlt.address, doubleStakeAmount_0); + await srStaker.createDeposit(nonce_0, doubleStakeAmount_0, height_0); + await advanceRounds(waitBase); + await srAlt.applyUpdates(staker_1); + expect((await srAlt.stakes(staker_1)).balance).to.be.eq(doubleStakeAmount_0); + expect(await token.balanceOf(staker_1)).to.be.eq(0); + + await mintAndApprove(staker_1, srAlt.address, stakeAmount_0); + await srStaker.addTokens(stakeAmount_0); + await srStaker.withdraw(withdrawAmount); + await srStaker.changeOverlay(nonce_1_n_25); + + // First maturity: top-up only (round + waitBase). + await advanceRounds(waitBase); + await srAlt.applyUpdates(staker_1); + expect((await srAlt.stakes(staker_1)).balance).to.be.eq(BigNumber.from(doubleStakeAmount_0).add(stakeAmount_0)); + expect(await srAlt.overlayOfAddress(staker_1)).to.be.eq(overlay_1); + expect(await token.balanceOf(staker_1)).to.be.eq(0); + + // Second: queued withdrawal (stacked after top-up; maturity round + waitWithdrawal). + await advanceRounds(waitWithdrawal - waitBase); + await srAlt.applyUpdates(staker_1); + expect((await srAlt.stakes(staker_1)).balance).to.be.eq(doubleStakeAmount_0); + expect(await token.balanceOf(staker_1)).to.be.eq(BigNumber.from(withdrawAmount)); + expect(await srAlt.overlayOfAddress(staker_1)).to.be.eq(overlay_1); + + // Third: overlay (candidate round + waitOverlay vs last scheduled round). + await advanceRounds(waitOverlay - waitWithdrawal); + await srAlt.applyUpdates(staker_1); + expect(await srAlt.overlayOfAddress(staker_1)).to.be.eq(overlay_1_n_25); + expect((await srAlt.stakes(staker_1)).balance).to.be.eq(doubleStakeAmount_0); + }); + + it('applies addTokens then withdraw then addTokens in queue order when shares the same effective round', async function () { + const waitBase = 2; + const waitWithdrawal = 6; + const waitOverlay = 10; + const srAlt = await deployStakeRegistryAlt(waitBase, waitOverlay, waitWithdrawal); + const srStaker = srAlt.connect(await getSignerFor(staker_0)); + + await mintAndApprove(staker_0, srAlt.address, doubleStakeAmount_0); + await srStaker.createDeposit(nonce_0, doubleStakeAmount_0, height_0); + await advanceRounds(waitBase); + await srAlt.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); + + await mintAndApprove(staker_0, srAlt.address, doubleStakeAmount_0); + await srStaker.addTokens(stakeAmount_0); + await srStaker.withdraw(withdrawAmount); + await srStaker.addTokens(stakeAmount_0); + + await advanceRounds(waitBase); + await srAlt.applyUpdates(staker_0); + expect((await srAlt.stakes(staker_0)).balance).to.be.eq(BigNumber.from(doubleStakeAmount_0).add(stakeAmount_0)); + + await advanceRounds(waitWithdrawal - waitBase); + await srAlt.applyUpdates(staker_0); + expect((await srAlt.stakes(staker_0)).balance).to.be.eq(BigNumber.from(doubleStakeAmount_0).add(stakeAmount_0)); + expect(await token.balanceOf(staker_0)).to.be.eq(BigNumber.from(withdrawAmount)); + }); + + it('applies addTokens, withdraw, and increaseHeight in strict FIFO in one round when fixture waits are uniform', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); + + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await srStaker0.addTokens(stakeAmount_0); + await srStaker0.withdraw(withdrawAmount); + await srStaker0.increaseHeight(height_0_n_1); + + await advanceRounds(2); + await srStaker0.applyUpdates(staker_0); + + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + expect(await token.balanceOf(staker_0)).to.be.eq(BigNumber.from(withdrawAmount)); + }); + }); }); From 9db52e26546867a6ae6a0e9fb190b20fd5297c14 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 7 May 2026 15:21:31 +0200 Subject: [PATCH 29/58] test(staking): cover redeposit after exit completes --- test/Staking.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 2592c906..5c968c2c 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -386,6 +386,26 @@ describe('Staking', function () { ); }); + it('should allow redeposit after exit is fully applied', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + await srStaker0.exit(); + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + expect((await srStaker0.stakes(staker_0)).overlay).to.be.eq(zeroBytes32); + + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0)).to.emit(srStaker0, 'DepositCreated'); + + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + const redeposit = await srStaker0.stakes(staker_0); + expect(redeposit.overlay).to.not.be.eq(zeroBytes32); + expect(redeposit.overlay).to.not.be.eq(overlay_0); + }); + it('should not allow invalid withdrawals', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await expectRevertReasonSubstring(srStaker0.withdraw(0), errors.withdraw.invalidWithdrawalAmountZero); From b3562c44ac77635e16d36b623ec0e4aa2f3dccf8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 18:49:28 +0200 Subject: [PATCH 30/58] feat(staking): persist protocol freeze per account Redistributor penalties must not be skipped by unstaking, migrating, or stake deletion. Move the deadline to `freezeUntilBlock` (monotonic on `freezeDeposit`), keep applying due queue updates before extending the freeze so mature withdrawals still settle in the same transaction. --- src/Staking.sol | 35 ++++++++++++++++++++----------- test/Staking.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 16b56f39..864011b0 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -10,6 +10,8 @@ import "@openzeppelin/contracts/security/Pausable.sol"; * @dev Allows users to stake tokens in order to be eligible for the Redistribution Schelling co-ordination game. * Stakes are frozen or slashed by the Redistribution contract in response to violations of the * protocol. + * @dev Freeze penalties are stored per account (`freezeUntilBlock`); they are not cleared by exit, + * migration, or stake deletion. A new deposit after exit still cannot participate until the freeze ends. */ contract StakeRegistry is AccessControl, Pausable { @@ -41,7 +43,6 @@ contract StakeRegistry is AccessControl, Pausable { struct Stake { bytes32 overlay; uint256 balance; - uint256 frozenUntilBlock; uint8 height; } @@ -54,6 +55,8 @@ contract StakeRegistry is AccessControl, Pausable { } mapping(address => Stake) private _stakes; + /// @notice End block of the protocol freeze for this account (exclusive: unfrozen when `block.number` > this value). Persists across exit and migration. + mapping(address => uint256) public freezeUntilBlock; mapping(address => ScheduledUpdate[]) private _updateQueues; mapping(address => uint256) private _queueHeads; mapping(address => bool) private _queueClosed; @@ -308,19 +311,32 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Freezes a stake and blocks queued withdrawals while the freeze lasts. + * @notice Extends the account freeze and blocks queued withdrawals while the freeze lasts. * @param _owner The staker to freeze. - * @param _time The freeze duration in blocks. + * @param _time The freeze duration in blocks from `block.number`. + * @dev If an existing freeze ends later than `block.number + _time`, it is kept (monotonic). The + * deadline is stored per account and survives exit, `migrateStake`, and stake deletion. */ function freezeDeposit(address _owner, uint256 _time) external { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + uint256 until = block.number + _time; + + // No stake and no queue: only record account-level penalty. if (!_isInitialized(_owner) && _queueLength(_owner) == 0) { + if (freezeUntilBlock[_owner] < until) { + freezeUntilBlock[_owner] = until; + } return; } + // Apply updates that were already due under the *previous* freeze window first, so a mature + // withdrawal in the same transaction is not blocked by the new penalty start. _applyReadyUpdates(_owner); - _stakes[_owner].frozenUntilBlock = block.number + _time; + + if (freezeUntilBlock[_owner] < until) { + freezeUntilBlock[_owner] = until; + } if (_isInitialized(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); @@ -348,7 +364,6 @@ contract StakeRegistry is AccessControl, Pausable { stake.balance = 0; _reconcileQueuedWithdrawals(_owner); } else { - // Deletes stake storage including freeze deadline; node may re-stake without residual freeze. delete _stakes[_owner]; } } @@ -452,10 +467,10 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @dev True when the stake is absent or `frozenUntilBlock` is strictly before `block.number`. + * @dev True when `freezeUntilBlock[_owner] < block.number` (current block is past the penalty window). */ function _addressNotFrozen(address _owner) internal view returns (bool) { - return !_isInitialized(_owner) || _stakes[_owner].frozenUntilBlock < block.number; + return freezeUntilBlock[_owner] < block.number; } /** @@ -719,15 +734,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns true when the owner would be unfrozen after the given round lookahead. */ function _addressNotFrozenLookahead(address _owner, uint64 _lookahead) internal view returns (bool) { - if (!_isInitialized(_owner)) { - return true; - } - if (_lookahead == 0) { return _addressNotFrozen(_owner); } - return _stakes[_owner].frozenUntilBlock < (uint256(currentRound()) + uint256(_lookahead)) * ROUND_LENGTH; + return freezeUntilBlock[_owner] < (uint256(currentRound()) + uint256(_lookahead)) * ROUND_LENGTH; } /** diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 5c968c2c..70fa4ae4 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -406,6 +406,56 @@ describe('Staking', function () { expect(redeposit.overlay).to.not.be.eq(overlay_0); }); + it('should keep account freeze across full exit and block effective stake until it expires', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + await srStaker0.exit(); + await advanceRounds(); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + // Applies the mature exit first, then starts the penalty window (same tx as existing tests expect). + await srRedis.freezeDeposit(staker_0, longFreezeTime); + + expect((await srStaker0.stakes(staker_0)).overlay).to.be.eq(zeroBytes32); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect(await srStaker0.freezeUntilBlock(staker_0)).to.be.gt(0); + + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0); + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + + await mineNBlocks(longFreezeTime + 1); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + }); + + it('should not shorten an existing freeze when a shorter freeze is applied', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + + const longFreeze = roundLength * 10; + const shortFreeze = 5; + await srRedis.freezeDeposit(staker_0, longFreeze); + const untilAfterLong = await srStaker0.freezeUntilBlock(staker_0); + + await mineNBlocks(3); + await srRedis.freezeDeposit(staker_0, shortFreeze); + const untilAfterShort = await srStaker0.freezeUntilBlock(staker_0); + + expect(untilAfterShort).to.be.eq(untilAfterLong); + }); + it('should not allow invalid withdrawals', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await expectRevertReasonSubstring(srStaker0.withdraw(0), errors.withdraw.invalidWithdrawalAmountZero); From 56e4c04f6fd729b22853451abc0a7524b1db8835 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 18:53:42 +0200 Subject: [PATCH 31/58] remove change of network by admin - security --- src/Staking.sol | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 864011b0..2612ecf8 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -371,15 +371,6 @@ contract StakeRegistry is AccessControl, Pausable { emit StakeSlashed(_owner, previousOverlay, _amount); } - /** - * @notice Updates the Swarm network identifier used in overlay derivation. - * @param _networkId The new network id. - */ - function changeNetworkId(uint64 _networkId) external { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); - networkId = _networkId; - } - /** * @notice Pauses staking mutations. */ From ce7e917cb23334cce4d2370ff702aa0eb1770cce Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 23:34:07 +0200 Subject: [PATCH 32/58] fix various staking issues --- src/Staking.sol | 77 +++++++++++++++++++++++++++++++------------- test/Staking.test.ts | 57 ++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 2612ecf8..013182c9 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -20,6 +20,8 @@ contract StakeRegistry is AccessControl, Pausable { uint256 public constant ROUND_LENGTH = 152; uint256 private constant MIN_STAKE = 100000000000000000; uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; + /// @notice Maximum staking height; prevents `2**height` overflow in `MIN_STAKE * (2 ** height)`. + uint8 public constant MAX_STAKING_HEIGHT = 128; // ----------------------------- Type declarations ------------------------------ @@ -81,17 +83,18 @@ contract StakeRegistry is AccessControl, Pausable { event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); event HeightIncreased(address indexed owner, uint64 registeredFromRound, uint8 height); - event Withdrawal(address indexed owner, uint64 registeredFromRound, uint256 amount); - event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); - event StakeFrozen(address frozen, bytes32 overlay, uint256 time); + /// @notice A partial or full withdrawal was scheduled; tokens move only when the item is applied. + event WithdrawalQueued(address indexed owner, uint64 effectiveFromRound, uint256 amount); + /// @notice BZZ was transferred to `owner` when a queued withdrawal or exit was applied (`executedInRound` is the round at execution). + event Withdrawal(address indexed owner, uint64 executedInRound, uint256 amount); + event StakeSlashed(address indexed owner, bytes32 overlay, uint256 amount); + event StakeFrozen(address indexed frozen, bytes32 overlay, uint256 time); event StakeMigrated(address indexed owner, uint256 totalReturned); // ----------------------------- Errors ------------------------------ /// @notice ERC20 `transfer` / `transferFrom` returned false for `bzzToken`. error TransferFailed(); - /// @notice Caller is not `DEFAULT_ADMIN_ROLE` (e.g. pause, unpause, `changeNetworkId`). - error Unauthorized(); /// @notice Caller lacks `REDISTRIBUTOR_ROLE` (`freezeDeposit`, `slashDeposit`). error OnlyRedistributor(); /// @notice Stake amount `have` is below protocol minimum `need` for the operation (deposit, height, or post-withdraw remainder). @@ -114,6 +117,8 @@ contract StakeRegistry is AccessControl, Pausable { error FrozenWithdrawal(); /// @notice Overlay or withdrawal wait rounds must be at least `waitBase` (`waitOverlayChange` / `waitWithdrawal` were below). error InvalidWaitConfiguration(uint64 waitBase, uint64 waitOverlayChange, uint64 waitWithdrawal); + /// @notice `height` exceeds `MAX_STAKING_HEIGHT` (stake math would overflow). + error StakingHeightTooLarge(uint8 height, uint8 maxHeight); constructor( address _bzzToken, @@ -156,7 +161,7 @@ contract StakeRegistry is AccessControl, Pausable { if (_amount < minStake) revert BelowMinimumStake(_amount, minStake); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); - _pullTokens(msg.sender, _amount); + _pullTokens(_amount); effectiveFromRound = _enqueueUpdate( msg.sender, @@ -180,7 +185,7 @@ contract StakeRegistry is AccessControl, Pausable { Stake memory plannedStake = _previewStake(msg.sender, true); if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); - _pullTokens(msg.sender, _amount); + _pullTokens(_amount); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); emit TokensAdded(msg.sender, effectiveFromRound, _amount); @@ -226,7 +231,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Schedules a partial withdrawal after the withdrawal delay. * @param _amount The amount of BZZ to withdraw from the stake. * @dev A full unwind must use `exit()`, not `withdraw(balance)`. Overdrawing reverts with `ExceedsBalance`; leaving a remainder below the height minimum reverts with `BelowMinimumStake`. - * @return effectiveFromRound Round when the queued update becomes effective (matches event). + * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); @@ -242,12 +247,12 @@ contract StakeRegistry is AccessControl, Pausable { if (balanceAfter < minAfterWithdraw) revert BelowMinimumStake(balanceAfter, minAfterWithdraw); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); - emit Withdrawal(msg.sender, effectiveFromRound, _amount); + emit WithdrawalQueued(msg.sender, effectiveFromRound, _amount); } /** * @notice Schedules a full exit after the withdrawal delay. - * @return effectiveFromRound Round when the queued update becomes effective (matches event). + * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function exit() external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); @@ -256,7 +261,7 @@ contract StakeRegistry is AccessControl, Pausable { effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); _queueClosed[msg.sender] = true; - emit Withdrawal(msg.sender, effectiveFromRound, plannedStake.balance); + emit WithdrawalQueued(msg.sender, effectiveFromRound, plannedStake.balance); } /** @@ -317,7 +322,7 @@ contract StakeRegistry is AccessControl, Pausable { * @dev If an existing freeze ends later than `block.number + _time`, it is kept (monotonic). The * deadline is stored per account and survives exit, `migrateStake`, and stake deletion. */ - function freezeDeposit(address _owner, uint256 _time) external { + function freezeDeposit(address _owner, uint256 _time) external whenNotPaused { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); uint256 until = block.number + _time; @@ -348,7 +353,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _owner The staker to slash. * @param _amount The amount to slash from the active stake. */ - function slashDeposit(address _owner, uint256 _amount) external { + function slashDeposit(address _owner, uint256 _amount) external whenNotPaused { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); _applyReadyUpdates(_owner); @@ -360,6 +365,7 @@ contract StakeRegistry is AccessControl, Pausable { if (stake.balance > _amount) { stake.balance -= _amount; _reconcileQueuedWithdrawals(_owner); + _syncHeightToBalance(stake); } else if (_queueLength(_owner) > 0) { stake.balance = 0; _reconcileQueuedWithdrawals(_owner); @@ -371,19 +377,25 @@ contract StakeRegistry is AccessControl, Pausable { emit StakeSlashed(_owner, previousOverlay, _amount); } + /** + * @notice Updates the Swarm network identifier used in overlay derivation. + * @param _networkId The new network id. + */ + function changeNetworkId(uint64 _networkId) external onlyRole(DEFAULT_ADMIN_ROLE) { + networkId = _networkId; + } + /** * @notice Pauses staking mutations. */ - function pause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); } /** * @notice Unpauses staking mutations. */ - function unPause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); } @@ -528,7 +540,10 @@ contract StakeRegistry is AccessControl, Pausable { uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; stake.balance -= paid; - if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); + if (paid > 0) { + if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); + emit Withdrawal(_owner, currentRound(), paid); + } } return; } @@ -536,7 +551,10 @@ contract StakeRegistry is AccessControl, Pausable { if (scheduled.kind == UpdateKind.ExitStake) { uint256 balance = stake.balance; delete _stakes[_owner]; - if (balance > 0 && !ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); + if (balance > 0) { + if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); + emit Withdrawal(_owner, currentRound(), balance); + } } } @@ -759,17 +777,32 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Pulls BZZ into the staking contract. + * @notice Pulls BZZ from `msg.sender` into the staking contract. */ - function _pullTokens(address _owner, uint256 _amount) internal { + function _pullTokens(uint256 _amount) internal { if (_amount == 0) revert InvalidAmount(); - if (!ERC20(bzzToken).transferFrom(_owner, address(this), _amount)) revert TransferFailed(); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); + } + + /** + * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible. + */ + function _syncHeightToBalance(Stake storage stake) internal { + if (stake.overlay == bytes32(0)) return; + uint8 h = stake.height; + while (h > 0 && stake.balance < _minimumStakeForHeight(h)) { + unchecked { + h--; + } + } + stake.height = h; } /** * @notice Returns the minimum stake required for a given height. */ function _minimumStakeForHeight(uint8 _height) internal pure returns (uint256) { + if (_height > MAX_STAKING_HEIGHT) revert StakingHeightTooLarge(_height, MAX_STAKING_HEIGHT); return MIN_STAKE * (2 ** _height); } diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 70fa4ae4..39a5b17b 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -276,7 +276,7 @@ describe('Staking', function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); - await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'Withdrawal'); + await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'WithdrawalQueued'); expect(await token.balanceOf(staker_0)).to.be.eq(0); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(doubleStakeAmount_0); @@ -319,7 +319,7 @@ describe('Staking', function () { await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0); const withdrawalReceipt = await (await srStaker0.withdraw(withdrawAmount)).wait(); - const withdrawalEvent = withdrawalReceipt.events?.find((event: Event) => event.event === 'Withdrawal'); + const withdrawalEvent = withdrawalReceipt.events?.find((event: Event) => event.event === 'WithdrawalQueued'); const effectiveRound = withdrawalEvent?.args?.effectiveFromRound ?? withdrawalEvent?.args?.[1]; await advanceToRoundCommitPhase(redistribution, effectiveRound); @@ -342,7 +342,7 @@ describe('Staking', function () { await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); const exitReceipt = await (await srStaker0.exit()).wait(); - const exitEvent = exitReceipt.events?.find((event: Event) => event.event === 'Withdrawal'); + const exitEvent = exitReceipt.events?.find((event: Event) => event.event === 'WithdrawalQueued'); const effectiveRound = exitEvent?.args?.effectiveFromRound ?? exitEvent?.args?.[1]; await advanceToRoundCommitPhase(redistribution, effectiveRound); @@ -361,7 +361,7 @@ describe('Staking', function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); - await expect(srStaker0.exit()).to.emit(srStaker0, 'Withdrawal'); + await expect(srStaker0.exit()).to.emit(srStaker0, 'WithdrawalQueued'); await advanceRounds(); expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); @@ -519,7 +519,7 @@ describe('Staking', function () { const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); await stakeRegistryRedistributor.freezeDeposit(staker_0, longFreezeTime); - await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'Withdrawal'); + await expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'WithdrawalQueued'); await advanceRounds(); await expect(srStaker0.applyUpdates(staker_0)).to.be.revertedWith(errors.general.frozenWithdrawal); @@ -576,6 +576,43 @@ describe('Staking', function () { expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); }); + it('should lower height after slash when balance no longer meets the previous minimum', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, doubleStakeAmount_0, height_0_n_1); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + + await srRedis.slashDeposit(staker_0, slashAmount); + + expect((await srStaker0.stakes(staker_0)).balance).to.eq(BigNumber.from(doubleStakeAmount_0).sub(slashAmount)); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0); + }); + + it('should reject staking height above MAX_STAKING_HEIGHT', async function () { + const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); + const maxH = Number(await srStaker1.MAX_STAKING_HEIGHT()); + await mintAndApprove(staker_1, srStaker1.address, stakeAmount_0); + await expectRevertReasonSubstring(srStaker1.createDeposit(nonce_0, stakeAmount_0, maxH + 1), 'StakingHeightTooLarge'); + }); + + it('should not allow freeze or slash while staking is paused', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + const srDeployer = await ethers.getContract('StakeRegistry', deployer); + await srDeployer.grantRole(await srDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + + const srPauser = await ethers.getContract('StakeRegistry', pauser); + await srPauser.pause(); + + await expect(srRedis.freezeDeposit(staker_0, freezeTime)).to.be.revertedWith(errors.pause.currentlyPaused); + await expect(srRedis.slashDeposit(staker_0, slashAmount)).to.be.revertedWith(errors.pause.currentlyPaused); + }); + it('should not allow stake migration while unpaused and should include queued deposits when paused', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); @@ -601,7 +638,7 @@ describe('Staking', function () { errors.pause.currentlyPaused ); - await stakeRegistryPauser.unPause(); + await stakeRegistryPauser.unpause(); await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; }); @@ -647,23 +684,23 @@ describe('Staking', function () { expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); - it('should match withdraw callStatic return to Withdrawal round', async function () { + it('should match withdraw callStatic return to WithdrawalQueued round', async function () { const sr = await ethers.getContract('StakeRegistry', staker_0); await activateStake(sr, staker_0, nonce_0, doubleStakeAmount_0, height_0); const fromCall = await sr.callStatic.withdraw(withdrawAmount); const tx = await sr.withdraw(withdrawAmount); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: Event) => e.event === 'Withdrawal'); + const ev = receipt.events!.find((e: Event) => e.event === 'WithdrawalQueued'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); - it('should match exit callStatic return to Withdrawal round', async function () { + it('should match exit callStatic return to WithdrawalQueued round', async function () { const sr = await ethers.getContract('StakeRegistry', staker_1); await activateStake(sr, staker_1, nonce_0, stakeAmount_0, height_0); const fromCall = await sr.callStatic.exit(); const tx = await sr.exit(); const receipt = await tx.wait(); - const ev = receipt.events!.find((e: Event) => e.event === 'Withdrawal'); + const ev = receipt.events!.find((e: Event) => e.event === 'WithdrawalQueued'); expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); From 9d9fdb86cf0516de77e37c30525b0dbe3500aaea Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 18 May 2026 23:50:46 +0200 Subject: [PATCH 33/58] code fixes and optimizations --- src/Staking.sol | 160 ++++++++++++++++++++----------------------- test/Staking.test.ts | 12 ++-- 2 files changed, 79 insertions(+), 93 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 013182c9..e1f28ac3 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -18,7 +18,8 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- State variables ------------------------------ uint256 public constant ROUND_LENGTH = 152; - uint256 private constant MIN_STAKE = 100000000000000000; + /// @notice Minimum BZZ base unit at staking height 0 (`MIN_STAKE * 2**height` for higher heights). + uint256 public constant MIN_STAKE = 100000000000000000; uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; /// @notice Maximum staking height; prevents `2**height` overflow in `MIN_STAKE * (2 ** height)`. uint8 public constant MAX_STAKING_HEIGHT = 128; @@ -42,6 +43,7 @@ contract StakeRegistry is AccessControl, Pausable { ExceedsBalance } + /// @dev Committed stake is indicated by `overlay != bytes32(0)` (see `_hasCommittedStake`). struct Stake { bytes32 overlay; uint256 balance; @@ -88,7 +90,9 @@ contract StakeRegistry is AccessControl, Pausable { /// @notice BZZ was transferred to `owner` when a queued withdrawal or exit was applied (`executedInRound` is the round at execution). event Withdrawal(address indexed owner, uint64 executedInRound, uint256 amount); event StakeSlashed(address indexed owner, bytes32 overlay, uint256 amount); - event StakeFrozen(address indexed frozen, bytes32 overlay, uint256 time); + event StakeFrozen(address indexed frozen, bytes32 indexed overlay, uint256 durationBlocks); + /// @notice Account-level freeze recorded when there is no stake/queue (overlay zero in `StakeFrozen`). + event AccountFreezeExtended(address indexed account, uint256 freezeUntilBlock); event StakeMigrated(address indexed owner, uint256 totalReturned); // ----------------------------- Errors ------------------------------ @@ -119,6 +123,8 @@ contract StakeRegistry is AccessControl, Pausable { error InvalidWaitConfiguration(uint64 waitBase, uint64 waitOverlayChange, uint64 waitWithdrawal); /// @notice `height` exceeds `MAX_STAKING_HEIGHT` (stake math would overflow). error StakingHeightTooLarge(uint8 height, uint8 maxHeight); + /// @notice `changeOverlay` was called with a nonce that produces the current overlay. + error OverlayUnchanged(); constructor( address _bzzToken, @@ -156,7 +162,7 @@ contract StakeRegistry is AccessControl, Pausable { ) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (_isInitialized(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); + if (_hasCommittedStake(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); uint256 minStake = _minimumStakeForHeight(_height); if (_amount < minStake) revert BelowMinimumStake(_amount, minStake); @@ -183,7 +189,7 @@ contract StakeRegistry is AccessControl, Pausable { function addTokens(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); _pullTokens(_amount); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); @@ -194,15 +200,16 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules an overlay change after the configured overlay delay. * @param _setNonce The nonce used to derive the new overlay. - * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. + * @return effectiveFromRound Round when the queued update becomes effective (matches `OverlayChanged`). + * @dev Reverts with `OverlayUnchanged` if the derived overlay equals the current one (no sentinel return value). */ function changeOverlay(bytes32 _setNonce) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); - if (newOverlay == plannedStake.overlay) return 0; + if (newOverlay == plannedStake.overlay) revert OverlayUnchanged(); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); @@ -217,7 +224,7 @@ contract StakeRegistry is AccessControl, Pausable { function increaseHeight(uint8 _height) external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); if (_height == plannedStake.height) return 0; uint256 minForHeight = _minimumStakeForHeight(_height); @@ -230,7 +237,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules a partial withdrawal after the withdrawal delay. * @param _amount The amount of BZZ to withdraw from the stake. - * @dev A full unwind must use `exit()`, not `withdraw(balance)`. Overdrawing reverts with `ExceedsBalance`; leaving a remainder below the height minimum reverts with `BelowMinimumStake`. + * @dev A full unwind must use `exit()`, not `withdraw(balance)`. Overdrawing reverts with `ExceedsBalance`; leaving a remainder below the height minimum reverts with `BelowMinimumStake`. Effective round stacking follows `_enqueueUpdate` (FIFO vs delay rounds). * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { @@ -238,7 +245,7 @@ contract StakeRegistry is AccessControl, Pausable { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_amount > plannedStake.balance) { revert InvalidWithdrawalAmount(WithdrawalAmountIssue.ExceedsBalance); } @@ -252,12 +259,13 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Schedules a full exit after the withdrawal delay. + * @dev Uses the same effective-round stacking as `withdraw()`; see `_enqueueUpdate`. * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function exit() external whenNotPaused returns (uint64 effectiveFromRound) { if (_queueClosed[msg.sender]) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_isInitialized(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); _queueClosed[msg.sender] = true; @@ -275,7 +283,7 @@ contract StakeRegistry is AccessControl, Pausable { if ( head < queue.length && queue[head].effectiveFromRound <= currentRound() && - _blocksQueuedWithdrawalExecution(_owner, queue[head].kind) + _queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0) ) { revert FrozenWithdrawal(); } @@ -328,9 +336,10 @@ contract StakeRegistry is AccessControl, Pausable { uint256 until = block.number + _time; // No stake and no queue: only record account-level penalty. - if (!_isInitialized(_owner) && _queueLength(_owner) == 0) { + if (!_hasCommittedStake(_owner) && _queueLength(_owner) == 0) { if (freezeUntilBlock[_owner] < until) { freezeUntilBlock[_owner] = until; + emit AccountFreezeExtended(_owner, freezeUntilBlock[_owner]); } return; } @@ -343,7 +352,7 @@ contract StakeRegistry is AccessControl, Pausable { freezeUntilBlock[_owner] = until; } - if (_isInitialized(_owner)) { + if (_hasCommittedStake(_owner)) { emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); } } @@ -355,13 +364,14 @@ contract StakeRegistry is AccessControl, Pausable { */ function slashDeposit(address _owner, uint256 _amount) external whenNotPaused { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + if (_amount == 0) revert InvalidAmount(); _applyReadyUpdates(_owner); Stake storage stake = _stakes[_owner]; bytes32 previousOverlay = stake.overlay; - if (_isInitialized(_owner)) { + if (previousOverlay != bytes32(0)) { if (stake.balance > _amount) { stake.balance -= _amount; _reconcileQueuedWithdrawals(_owner); @@ -374,7 +384,9 @@ contract StakeRegistry is AccessControl, Pausable { } } - emit StakeSlashed(_owner, previousOverlay, _amount); + if (previousOverlay != bytes32(0)) { + emit StakeSlashed(_owner, previousOverlay, _amount); + } } /** @@ -417,7 +429,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!_addressNotFrozen(_owner)) return 0; Stake memory preview = _previewStake(_owner, false); - return _isInitialized(preview) ? preview.balance : 0; + return _hasCommittedStake(preview) ? preview.balance : 0; } /** @@ -425,7 +437,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function overlayOfAddress(address _owner) public view returns (bytes32) { Stake memory preview = _previewStake(_owner, false); - return _isInitialized(preview) ? preview.overlay : bytes32(0); + return _hasCommittedStake(preview) ? preview.overlay : bytes32(0); } /** @@ -433,7 +445,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function heightOfAddress(address _owner) public view returns (uint8) { Stake memory preview = _previewStake(_owner, false); - return _isInitialized(preview) ? preview.height : 0; + return _hasCommittedStake(preview) ? preview.height : 0; } /** @@ -443,7 +455,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!_addressNotFrozenLookahead(_owner, _lookahead)) return 0; Stake memory preview = _previewStakeLookahead(_owner, _lookahead); - return _isInitialized(preview) ? preview.balance : 0; + return _hasCommittedStake(preview) ? preview.balance : 0; } /** @@ -451,7 +463,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function overlayOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (bytes32) { Stake memory preview = _previewStakeLookahead(_owner, _lookahead); - return _isInitialized(preview) ? preview.overlay : bytes32(0); + return _hasCommittedStake(preview) ? preview.overlay : bytes32(0); } /** @@ -459,7 +471,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function heightOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (uint8) { Stake memory preview = _previewStakeLookahead(_owner, _lookahead); - return _isInitialized(preview) ? preview.height : 0; + return _hasCommittedStake(preview) ? preview.height : 0; } /** @@ -486,7 +498,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { - if (_blocksQueuedWithdrawalExecution(_owner, queue[head].kind)) break; + if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; _applyStoredUpdate(_owner, queue[head]); delete queue[head]; unchecked { @@ -507,39 +519,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Applies a single queued update to storage. */ function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { - Stake storage stake = _stakes[_owner]; - - if (scheduled.kind == UpdateKind.CreateDeposit) { - stake.overlay = _deriveOverlay(_owner, scheduled.nonce); - stake.balance = scheduled.amount; - stake.height = scheduled.height; - return; - } - - if (scheduled.kind == UpdateKind.AddTokens) { - stake.balance += scheduled.amount; - return; - } - - if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (_isInitialized(_owner) && scheduled.height > stake.height) { - stake.height = scheduled.height; - } - return; - } - - if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (_isInitialized(_owner)) { - stake.overlay = _deriveOverlay(_owner, scheduled.nonce); - } - return; - } - if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (_isInitialized(_owner)) { + Stake storage stake = _stakes[_owner]; + if (stake.overlay != bytes32(0)) { uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; stake.balance -= paid; - if (paid > 0) { if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); emit Withdrawal(_owner, currentRound(), paid); @@ -549,41 +533,39 @@ contract StakeRegistry is AccessControl, Pausable { } if (scheduled.kind == UpdateKind.ExitStake) { - uint256 balance = stake.balance; + Stake storage stakeRef = _stakes[_owner]; + uint256 balance = stakeRef.balance; delete _stakes[_owner]; if (balance > 0) { if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); emit Withdrawal(_owner, currentRound(), balance); } - } - } - - /** - * @notice Returns true when a queued withdrawal or exit must stay pending for the current round. - * @dev Current-round participation does not block execution once the withdrawal or exit is effective. - */ - function _blocksQueuedWithdrawalExecution(address _owner, UpdateKind _kind) internal view returns (bool) { - if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { - return false; + return; } - return !_addressNotFrozen(_owner); + Stake storage stRef = _stakes[_owner]; + Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); + s = _applyPreviewUpdate(_owner, s, scheduled); + stRef.overlay = s.overlay; + stRef.balance = s.balance; + stRef.height = s.height; } /** - * @notice Returns true when a queued withdrawal or exit would still be blocked after the given round lookahead. - * @dev Lookahead previews only defer execution while the node remains frozen. + * @dev Whether a due withdrawal/exit is blocked by freeze when evaluating unfrozen state at `_lookaheadRounds`: + * `_lookaheadRounds == 0` uses `_addressNotFrozen` (strict `block.number`). `_lookaheadRounds > 0` + * uses the first block of round `currentRound() + _lookaheadRounds`. Those bases differ, so + * behavior is not a simple extension of the `_lookaheadRounds == 0` case at round boundaries. */ - function _blocksQueuedWithdrawalExecutionLookahead( + function _queuedWithdrawalExecutionFrozen( address _owner, UpdateKind _kind, - uint64 _lookahead + uint64 _lookaheadRounds ) internal view returns (bool) { if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { return false; } - - return !_addressNotFrozenLookahead(_owner, _lookahead); + return !_addressNotFrozenLookahead(_owner, _lookaheadRounds); } /** @@ -601,7 +583,7 @@ contract StakeRegistry is AccessControl, Pausable { if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; } - if (!includeFutureUpdates && _blocksQueuedWithdrawalExecution(_owner, scheduled.kind)) { + if (!includeFutureUpdates && _queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, 0)) { break; } @@ -628,7 +610,7 @@ contract StakeRegistry is AccessControl, Pausable { if (scheduled.effectiveFromRound > targetRound) { break; } - if (_blocksQueuedWithdrawalExecutionLookahead(_owner, scheduled.kind, _lookahead)) { + if (_queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, _lookahead)) { break; } @@ -642,6 +624,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to an in-memory preview state. + * @dev Must match non-transfer semantics applied in `_applyStoredUpdate` for the same `kind` (excluding token transfers). */ function _applyPreviewUpdate( address _owner, @@ -661,21 +644,21 @@ contract StakeRegistry is AccessControl, Pausable { } if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (_isInitialized(preview) && scheduled.height > preview.height) { + if (_hasCommittedStake(preview) && scheduled.height > preview.height) { preview.height = scheduled.height; } return preview; } if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (_isInitialized(preview)) { + if (_hasCommittedStake(preview)) { preview.overlay = _deriveOverlay(_owner, scheduled.nonce); } return preview; } if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (_isInitialized(preview)) { + if (_hasCommittedStake(preview)) { if (scheduled.amount >= preview.balance) { preview.balance = 0; } else { @@ -694,6 +677,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Appends a new queued update and assigns the first valid effective round. + * @dev `effectiveFromRound` is `max(currentRound() + _minimumWait, lastQueuedRound)` so FIFO is preserved when waits differ; a withdrawal/exit may become effective later than `_minimumWait` rounds after prior queue items. */ function _enqueueUpdate( address _owner, @@ -740,14 +724,17 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns true when the owner would be unfrozen after the given round lookahead. + * @notice True if `freezeUntilBlock` is strictly before the reference block for this lookahead. + * @dev `_lookaheadRounds == 0`: reference is current `block.number` (same as `_addressNotFrozen`). + * `_lookaheadRounds > 0`: reference is the first block of staking round `currentRound() + _lookaheadRounds` + * (not `block.number + _lookaheadRounds * ROUND_LENGTH`), so preview semantics are round-anchored. */ - function _addressNotFrozenLookahead(address _owner, uint64 _lookahead) internal view returns (bool) { - if (_lookahead == 0) { + function _addressNotFrozenLookahead(address _owner, uint64 _lookaheadRounds) internal view returns (bool) { + if (_lookaheadRounds == 0) { return _addressNotFrozen(_owner); } - return freezeUntilBlock[_owner] < (uint256(currentRound()) + uint256(_lookahead)) * ROUND_LENGTH; + return freezeUntilBlock[_owner] < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; } /** @@ -762,7 +749,7 @@ contract StakeRegistry is AccessControl, Pausable { for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; - if (scheduled.kind == UpdateKind.WithdrawTokens && _isInitialized(preview)) { + if (scheduled.kind == UpdateKind.WithdrawTokens && _hasCommittedStake(preview)) { if (scheduled.amount > preview.balance) { scheduled.amount = preview.balance; } @@ -814,16 +801,15 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Returns true when the stored stake for an owner is initialized. + * @notice True when the on-chain stake record is committed for `owner`. + * @dev Commitment is indicated by `overlay != bytes32(0)`; collision with keccak256 output is negligible. */ - function _isInitialized(address _owner) internal view returns (bool) { + function _hasCommittedStake(address _owner) internal view returns (bool) { return _stakes[_owner].overlay != bytes32(0); } - /** - * @notice Returns true when an in-memory stake state is initialized. - */ - function _isInitialized(Stake memory _stake) internal pure returns (bool) { + /// @notice Same commitment predicate for an in-memory stake (e.g. queue preview). + function _hasCommittedStake(Stake memory _stake) internal pure returns (bool) { return _stake.overlay != bytes32(0); } diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 39a5b17b..96be4c74 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -29,6 +29,7 @@ const errors = { }, slash: { noRole: 'OnlyRedistributor()', + invalidAmount: 'InvalidAmount()', }, freeze: { noRole: 'OnlyRedistributor()', @@ -40,7 +41,7 @@ const errors = { onlyPauseCanUnPause: 'Unauthorized()', }, general: { - queueClosed: 'QueueClosed()', + overlayUnchanged: 'OverlayUnchanged()', frozenWithdrawal: 'FrozenWithdrawal()', queueFull: 'UpdateQueueFull', invalidWaitConfig: 'InvalidWaitConfiguration', @@ -542,6 +543,8 @@ describe('Staking', function () { await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(stakeRegistryRedistributor.slashDeposit(staker_0, '0')).to.be.revertedWith(errors.slash.invalidAmount); + await expect(stakeRegistryRedistributor.slashDeposit(staker_0, slashAmount)) .to.emit(srStaker0, 'StakeSlashed') .withArgs(staker_0, overlay_0, slashAmount); @@ -704,13 +707,10 @@ describe('Staking', function () { expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); }); - it('should return 0 and emit nothing when overlay is unchanged', async function () { + it('should revert when overlay is unchanged', async function () { const sr = await ethers.getContract('StakeRegistry', staker_0); await activateStake(sr, staker_0, nonce_0, stakeAmount_0, height_0); - expect(await sr.callStatic.changeOverlay(nonce_0)).to.eq(0); - const tx = await sr.changeOverlay(nonce_0); - const receipt = await tx.wait(); - expect(receipt.events?.some((e: Event) => e.event === 'OverlayChanged')).to.eq(false); + await expect(sr.changeOverlay(nonce_0)).to.be.revertedWith(errors.general.overlayUnchanged); }); it('should return 0 and emit nothing when height is unchanged', async function () { From 261e199c50b1c3d94d51d257bcc82deac0e36efd Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 19 May 2026 00:17:06 +0200 Subject: [PATCH 34/58] docs(staking): clarify FrozenWithdrawal and satisfy Prettier CI Document applyUpdates head-of-queue freeze revert for integrators; expand FrozenWithdrawal NatSpec. Format staking test revert assertion for yarn format:check. --- README.md | 2 ++ src/Staking.sol | 9 ++++++++- test/Staking.test.ts | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index adcda8ac..68048e47 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ This project includes the following smart contracts and their metadata: - HitchensOrderStatisticsTreeLib - Test Token +**`StakeRegistry` / `applyUpdates` (bots & backends):** `applyUpdates(address)` applies the ready prefix of each owner’s update queue. If the next pending item at the queue head is a **due** `WithdrawTokens` or `ExitStake` and payouts are blocked by an active freeze, the call **reverts** with `FrozenWithdrawal()` and the **whole transaction rolls back** (no partial commit from that call). Integrators should treat that as retry-after-unfreeze, or rely on paths that advance the queue internally under different constraints (redistributor-triggered txs, paused migration flows). Inline NatSpec next to `applyUpdates` matches this behaviour. + - Metadata ([Testnet](./testnet_deployed.json),[Mainnet](./mainnet_deployed.json)) - **Chain ID**: Chain ID of the blockchain. - **Network ID**: Network ID. diff --git a/src/Staking.sol b/src/Staking.sol index e1f28ac3..1dc407b7 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -117,7 +117,8 @@ contract StakeRegistry is AccessControl, Pausable { error UpdateQueueFull(uint256 queuedCount, uint256 limit); /// @notice An exit is scheduled; no further mutations allowed until processed or migrated. error QueueClosed(); - /// @notice Cannot finish applying updates while the head item is a due withdrawal/exit and the stake is frozen. + /// @notice Thrown only by `applyUpdates`: head queue item is due `WithdrawTokens`/`ExitStake` but frozen. + /// @dev Full tx revert; see `applyUpdates` NatSpec — no checkpointed partial progress from that call. error FrozenWithdrawal(); /// @notice Overlay or withdrawal wait rounds must be at least `waitBase` (`waitOverlayChange` / `waitWithdrawal` were below). error InvalidWaitConfiguration(uint64 waitBase, uint64 waitOverlayChange, uint64 waitWithdrawal); @@ -275,6 +276,12 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies all updates that are ready for the given owner. * @param _owner The address whose queue should be processed. + * @dev Integrators / bots / backends: `_applyReadyUpdates` runs first. If the next pending item at `head` + * is a due withdrawal or exit and execution is blocked by freeze, this function reverts with + * `FrozenWithdrawal()` — the **entire transaction** reverts, so no partial state from this call persists. + * When that happens (e.g. user frozen with a matured withdrawal queued), callers may retry after + * unfreeze, or advance the queue indirectly via functions that invoke `_applyReadyUpdates` internally + * under different rules (`freezeDeposit`, `slashDeposit`, `migrateStake` when paused). */ function applyUpdates(address _owner) public { _applyReadyUpdates(_owner); diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 96be4c74..1cb94b2d 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -598,7 +598,10 @@ describe('Staking', function () { const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); const maxH = Number(await srStaker1.MAX_STAKING_HEIGHT()); await mintAndApprove(staker_1, srStaker1.address, stakeAmount_0); - await expectRevertReasonSubstring(srStaker1.createDeposit(nonce_0, stakeAmount_0, maxH + 1), 'StakingHeightTooLarge'); + await expectRevertReasonSubstring( + srStaker1.createDeposit(nonce_0, stakeAmount_0, maxH + 1), + 'StakingHeightTooLarge' + ); }); it('should not allow freeze or slash while staking is paused', async function () { From e5ee89b498d757c77329ad991fb842c5a386fd50 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 19 May 2026 12:07:20 +0200 Subject: [PATCH 35/58] test(staking): add missing QueueClosed entry to errors map Test references errors.general.queueClosed for the post-exit createDeposit revert assertion; missing key was failing TypeScript compile in CI. --- test/Staking.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 1cb94b2d..c6e5eb26 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -44,6 +44,7 @@ const errors = { overlayUnchanged: 'OverlayUnchanged()', frozenWithdrawal: 'FrozenWithdrawal()', queueFull: 'UpdateQueueFull', + queueClosed: 'QueueClosed()', invalidWaitConfig: 'InvalidWaitConfiguration', }, }; From 706b2923a5f28c83024ab7d0416152d9d98d9272 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 19 May 2026 17:22:17 +0200 Subject: [PATCH 36/58] Migrate freeze test --- test/Staking.test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/Staking.test.ts b/test/Staking.test.ts index c6e5eb26..9399277f 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -438,6 +438,37 @@ describe('Staking', function () { expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); }); + it('should keep account freeze across migrateStake and block effective stake until it expires', async function () { + const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); + const longFreezeTime = roundLength * 3; + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); + await srRedis.freezeDeposit(staker_0, longFreezeTime); + expect(await srStaker0.freezeUntilBlock(staker_0)).to.be.gt(0); + + const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); + await stakeRegistryPauser.pause(); + await expect(srStaker0.migrateStake()).to.emit(srStaker0, 'StakeMigrated').withArgs(staker_0, stakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect((await srStaker0.stakes(staker_0)).overlay).to.be.eq(zeroBytes32); + expect(await srStaker0.freezeUntilBlock(staker_0)).to.be.gt(0); + + await stakeRegistryPauser.unpause(); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0); + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + + await mineNBlocks(longFreezeTime + 1); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + }); + it('should not shorten an existing freeze when a shorter freeze is applied', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); From 332070d28486596a8727cc0705ddd4d71714e79a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 11:41:42 +0200 Subject: [PATCH 37/58] refactor(staking): move mutators to changing section --- src/Staking.sol | 282 ++++++++++++++++++++++++------------------------ 1 file changed, 141 insertions(+), 141 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 1dc407b7..551fa8f7 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -418,6 +418,147 @@ contract StakeRegistry is AccessControl, Pausable { _unpause(); } + /** + * @notice Applies all queued updates that are effective in the current round. + * @dev Stops at the first frozen withdrawal/exit without reverting. + */ + function _applyReadyUpdates(address _owner) internal { + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + uint64 roundNumber = currentRound(); + + while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { + if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; + _applyStoredUpdate(_owner, queue[head]); + delete queue[head]; + unchecked { + ++head; + } + } + + if (head == queue.length) { + delete _updateQueues[_owner]; + delete _queueHeads[_owner]; + delete _queueClosed[_owner]; + } else { + _queueHeads[_owner] = head; + } + } + + /** + * @notice Applies a single queued update to storage. + */ + function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { + if (scheduled.kind == UpdateKind.WithdrawTokens) { + Stake storage stake = _stakes[_owner]; + if (stake.overlay != bytes32(0)) { + uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; + stake.balance -= paid; + if (paid > 0) { + if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); + emit Withdrawal(_owner, currentRound(), paid); + } + } + return; + } + + if (scheduled.kind == UpdateKind.ExitStake) { + Stake storage stakeRef = _stakes[_owner]; + uint256 balance = stakeRef.balance; + delete _stakes[_owner]; + if (balance > 0) { + if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); + emit Withdrawal(_owner, currentRound(), balance); + } + return; + } + + Stake storage stRef = _stakes[_owner]; + Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); + s = _applyPreviewUpdate(_owner, s, scheduled); + stRef.overlay = s.overlay; + stRef.balance = s.balance; + stRef.height = s.height; + } + + /** + * @notice Appends a new queued update and assigns the first valid effective round. + * @dev `effectiveFromRound` is `max(currentRound() + _minimumWait, lastQueuedRound)` so FIFO is preserved when waits differ; a withdrawal/exit may become effective later than `_minimumWait` rounds after prior queue items. + */ + function _enqueueUpdate( + address _owner, + UpdateKind _kind, + uint64 _minimumWait, + bytes32 _nonce, + uint256 _amount, + uint8 _height + ) internal returns (uint64 effectiveFromRound) { + uint256 queued = _queueLength(_owner); + if (queued >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(queued, UPDATE_QUEUE_MAX_LENGTH); + + uint64 candidateRound = currentRound() + _minimumWait; + uint64 lastRound = _lastScheduledRound(_owner); + effectiveFromRound = candidateRound > lastRound ? candidateRound : lastRound; + + _updateQueues[_owner].push( + ScheduledUpdate({ + kind: _kind, + effectiveFromRound: effectiveFromRound, + nonce: _nonce, + amount: _amount, + height: _height + }) + ); + } + + /** + * @notice Shrinks queued withdrawals when slashing leaves less balance than they expect. + * @dev This preserves queue order while preventing later withdrawals from overpaying the owner. + */ + function _reconcileQueuedWithdrawals(address _owner) internal { + ScheduledUpdate[] storage queue = _updateQueues[_owner]; + uint256 head = _queueHeads[_owner]; + Stake memory preview = _stakes[_owner]; + + for (uint256 i = head; i < queue.length; ) { + ScheduledUpdate storage scheduled = queue[i]; + + if (scheduled.kind == UpdateKind.WithdrawTokens && _hasCommittedStake(preview)) { + if (scheduled.amount > preview.balance) { + scheduled.amount = preview.balance; + } + } + + preview = _applyPreviewUpdate(_owner, preview, scheduled); + + unchecked { + ++i; + } + } + } + + /** + * @notice Pulls BZZ from `msg.sender` into the staking contract. + */ + function _pullTokens(uint256 _amount) internal { + if (_amount == 0) revert InvalidAmount(); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); + } + + /** + * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible. + */ + function _syncHeightToBalance(Stake storage stake) internal { + if (stake.overlay == bytes32(0)) return; + uint8 h = stake.height; + while (h > 0 && stake.balance < _minimumStakeForHeight(h)) { + unchecked { + h--; + } + } + stake.height = h; + } + //////////////////////////////////////// // STATE READING // //////////////////////////////////////// @@ -495,69 +636,6 @@ contract StakeRegistry is AccessControl, Pausable { return freezeUntilBlock[_owner] < block.number; } - /** - * @notice Applies all queued updates that are effective in the current round. - * @dev Stops at the first frozen withdrawal/exit without reverting. - */ - function _applyReadyUpdates(address _owner) internal { - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; - uint64 roundNumber = currentRound(); - - while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { - if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; - _applyStoredUpdate(_owner, queue[head]); - delete queue[head]; - unchecked { - ++head; - } - } - - if (head == queue.length) { - delete _updateQueues[_owner]; - delete _queueHeads[_owner]; - delete _queueClosed[_owner]; - } else { - _queueHeads[_owner] = head; - } - } - - /** - * @notice Applies a single queued update to storage. - */ - function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { - if (scheduled.kind == UpdateKind.WithdrawTokens) { - Stake storage stake = _stakes[_owner]; - if (stake.overlay != bytes32(0)) { - uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; - stake.balance -= paid; - if (paid > 0) { - if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); - emit Withdrawal(_owner, currentRound(), paid); - } - } - return; - } - - if (scheduled.kind == UpdateKind.ExitStake) { - Stake storage stakeRef = _stakes[_owner]; - uint256 balance = stakeRef.balance; - delete _stakes[_owner]; - if (balance > 0) { - if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); - emit Withdrawal(_owner, currentRound(), balance); - } - return; - } - - Stake storage stRef = _stakes[_owner]; - Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); - s = _applyPreviewUpdate(_owner, s, scheduled); - stRef.overlay = s.overlay; - stRef.balance = s.balance; - stRef.height = s.height; - } - /** * @dev Whether a due withdrawal/exit is blocked by freeze when evaluating unfrozen state at `_lookaheadRounds`: * `_lookaheadRounds == 0` uses `_addressNotFrozen` (strict `block.number`). `_lookaheadRounds > 0` @@ -682,36 +760,6 @@ contract StakeRegistry is AccessControl, Pausable { return preview; } - /** - * @notice Appends a new queued update and assigns the first valid effective round. - * @dev `effectiveFromRound` is `max(currentRound() + _minimumWait, lastQueuedRound)` so FIFO is preserved when waits differ; a withdrawal/exit may become effective later than `_minimumWait` rounds after prior queue items. - */ - function _enqueueUpdate( - address _owner, - UpdateKind _kind, - uint64 _minimumWait, - bytes32 _nonce, - uint256 _amount, - uint8 _height - ) internal returns (uint64 effectiveFromRound) { - uint256 queued = _queueLength(_owner); - if (queued >= UPDATE_QUEUE_MAX_LENGTH) revert UpdateQueueFull(queued, UPDATE_QUEUE_MAX_LENGTH); - - uint64 candidateRound = currentRound() + _minimumWait; - uint64 lastRound = _lastScheduledRound(_owner); - effectiveFromRound = candidateRound > lastRound ? candidateRound : lastRound; - - _updateQueues[_owner].push( - ScheduledUpdate({ - kind: _kind, - effectiveFromRound: effectiveFromRound, - nonce: _nonce, - amount: _amount, - height: _height - }) - ); - } - /** * @notice Returns the effective round of the last queued update. */ @@ -744,54 +792,6 @@ contract StakeRegistry is AccessControl, Pausable { return freezeUntilBlock[_owner] < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; } - /** - * @notice Shrinks queued withdrawals when slashing leaves less balance than they expect. - * @dev This preserves queue order while preventing later withdrawals from overpaying the owner. - */ - function _reconcileQueuedWithdrawals(address _owner) internal { - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; - Stake memory preview = _stakes[_owner]; - - for (uint256 i = head; i < queue.length; ) { - ScheduledUpdate storage scheduled = queue[i]; - - if (scheduled.kind == UpdateKind.WithdrawTokens && _hasCommittedStake(preview)) { - if (scheduled.amount > preview.balance) { - scheduled.amount = preview.balance; - } - } - - preview = _applyPreviewUpdate(_owner, preview, scheduled); - - unchecked { - ++i; - } - } - } - - /** - * @notice Pulls BZZ from `msg.sender` into the staking contract. - */ - function _pullTokens(uint256 _amount) internal { - if (_amount == 0) revert InvalidAmount(); - if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); - } - - /** - * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible. - */ - function _syncHeightToBalance(Stake storage stake) internal { - if (stake.overlay == bytes32(0)) return; - uint8 h = stake.height; - while (h > 0 && stake.balance < _minimumStakeForHeight(h)) { - unchecked { - h--; - } - } - stake.height = h; - } - /** * @notice Returns the minimum stake required for a given height. */ From b984e2aab37fdba6ee32d31cd664137cb3789994 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 12:00:20 +0200 Subject: [PATCH 38/58] docs(staking): clarify queue break on frozen withdrawal Explain that break exits the loop and leaves the blocked item at head unapplied so FIFO is preserved for retry on a later call. --- src/Staking.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Staking.sol b/src/Staking.sol index 551fa8f7..5270a6d3 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -428,6 +428,8 @@ contract StakeRegistry is AccessControl, Pausable { uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { + // Exit the loop (do not skip to the next item): leave the due withdrawal/exit at `head` + // unapplied so FIFO is preserved; `_queueHeads` is updated below for retry on a later call. if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; _applyStoredUpdate(_owner, queue[head]); delete queue[head]; From d8e8091cabd94d2376e5965d5a0dfc75e1a10af8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 12:06:00 +0200 Subject: [PATCH 39/58] docs(staking): document _applyStoredUpdate paths Label the three execution branches so queue application logic is easier to follow when reading _applyStoredUpdate. --- src/Staking.sol | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Staking.sol b/src/Staking.sol index 5270a6d3..5abd8759 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -449,8 +449,11 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to storage. + * @dev Three paths: partial withdrawal (transfer + balance), full exit (delete stake + transfer), + * or stake mutation via `_applyPreviewUpdate` (deposit, add, height, overlay). */ function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { + // Path 1: partial withdrawal — pay out capped at current balance (may be slashed since queued). if (scheduled.kind == UpdateKind.WithdrawTokens) { Stake storage stake = _stakes[_owner]; if (stake.overlay != bytes32(0)) { @@ -464,6 +467,7 @@ contract StakeRegistry is AccessControl, Pausable { return; } + // Path 2: full exit — delete stake and return all remaining BZZ. if (scheduled.kind == UpdateKind.ExitStake) { Stake storage stakeRef = _stakes[_owner]; uint256 balance = stakeRef.balance; @@ -475,6 +479,7 @@ contract StakeRegistry is AccessControl, Pausable { return; } + // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). Stake storage stRef = _stakes[_owner]; Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); s = _applyPreviewUpdate(_owner, s, scheduled); From 2d547a40735cf21eeab2449ad1f32a1b583eb237 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 12:24:03 +0200 Subject: [PATCH 40/58] Add freeze migration function --- src/Staking.sol | 47 +++++++++++++++++++++++++++------ test/Staking.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 5abd8759..05ad3c50 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -126,6 +126,8 @@ contract StakeRegistry is AccessControl, Pausable { error StakingHeightTooLarge(uint8 height, uint8 maxHeight); /// @notice `changeOverlay` was called with a nonce that produces the current overlay. error OverlayUnchanged(); + /// @notice Parallel arrays passed to a batch admin helper have different lengths. + error ArrayLengthMismatch(); constructor( address _bzzToken, @@ -330,6 +332,34 @@ contract StakeRegistry is AccessControl, Pausable { } } + /** + * @notice Seeds `freezeUntilBlock` on a successor registry during contract migration. + * @param _accounts Accounts whose freeze deadlines should be carried over. + * @param _untilBlocks Matching `freezeUntilBlock` values from the predecessor contract. + * @dev Only extends each account's freeze (monotonic, same rule as `freezeDeposit`). Intended for + * admin migration tooling after nodes call `migrateStake` on the old contract and before they + * restake here; stake and queue state are not imported. + */ + function importFreezeUntilBlocks( + address[] calldata _accounts, + uint256[] calldata _untilBlocks + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_accounts.length != _untilBlocks.length) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < _accounts.length; ) { + address account = _accounts[i]; + uint256 until = _untilBlocks[i]; + if (freezeUntilBlock[account] < until) { + freezeUntilBlock[account] = until; + emit AccountFreezeExtended(account, freezeUntilBlock[account]); + } + + unchecked { + ++i; + } + } + } + /** * @notice Extends the account freeze and blocks queued withdrawals while the freeze lasts. * @param _owner The staker to freeze. @@ -517,6 +547,13 @@ contract StakeRegistry is AccessControl, Pausable { }) ); } + /** + * @notice Pulls BZZ from `msg.sender` into the staking contract. + */ + function _pullTokens(uint256 _amount) internal { + if (_amount == 0) revert InvalidAmount(); + if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); + } /** * @notice Shrinks queued withdrawals when slashing leaves less balance than they expect. @@ -544,16 +581,10 @@ contract StakeRegistry is AccessControl, Pausable { } } - /** - * @notice Pulls BZZ from `msg.sender` into the staking contract. - */ - function _pullTokens(uint256 _amount) internal { - if (_amount == 0) revert InvalidAmount(); - if (!ERC20(bzzToken).transferFrom(msg.sender, address(this), _amount)) revert TransferFailed(); - } + /** - * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible. + * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible, happens in slashing */ function _syncHeightToBalance(Stake storage stake) internal { if (stake.overlay == bytes32(0)) return; diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 9399277f..c2cc32b2 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -666,6 +666,69 @@ describe('Staking', function () { expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); }); + describe('contract migration freeze import', function () { + it('should import freezeUntilBlock and block effective stake after restake on successor registry', async function () { + const longFreezeTime = roundLength * 3; + const Factory = await ethers.getContractFactory('StakeRegistry'); + const srOld = await Factory.deploy(token.address, await stakeRegistry.networkId(), 2, 10, 2); + await srOld.deployed(); + await srOld.grantRole(await srOld.REDISTRIBUTOR_ROLE(), redistributor); + + const srOldStaker = srOld.connect(await getSignerFor(staker_0)); + await mintAndApprove(staker_0, srOld.address, stakeAmount_0); + await srOldStaker.createDeposit(nonce_0, stakeAmount_0, height_0); + await advanceRounds(); + await srOld.applyUpdates(staker_0); + + const srOldRedis = srOld.connect(await getSignerFor(redistributor)); + await srOldRedis.freezeDeposit(staker_0, longFreezeTime); + const importedUntil = await srOld.freezeUntilBlock(staker_0); + + const srNew = await Factory.deploy(token.address, await stakeRegistry.networkId(), 2, 10, 2); + await srNew.deployed(); + const srNewAdmin = srNew.connect(await getSignerFor(deployer)); + await expect(srNewAdmin.importFreezeUntilBlocks([staker_0], [importedUntil])) + .to.emit(srNew, 'AccountFreezeExtended') + .withArgs(staker_0, importedUntil); + expect(await srNew.freezeUntilBlock(staker_0)).to.eq(importedUntil); + + const srNewStaker = srNew.connect(await getSignerFor(staker_0)); + await mintAndApprove(staker_0, srNew.address, stakeAmount_0); + await srNewStaker.createDeposit(nonce_1_n_25, stakeAmount_0, height_0); + await advanceRounds(); + await srNew.applyUpdates(staker_0); + + expect((await srNew.stakes(staker_0)).balance).to.eq(stakeAmount_0); + expect(await srNew.nodeEffectiveStake(staker_0)).to.eq(0); + + await mineNBlocks(longFreezeTime + 1); + expect(await srNew.nodeEffectiveStake(staker_0)).to.eq(stakeAmount_0); + }); + + it('should reject importFreezeUntilBlocks from non-admin and on array length mismatch', async function () { + const srAdmin = stakeRegistry.connect(await getSignerFor(deployer)); + const srStaker = stakeRegistry.connect(await getSignerFor(staker_0)); + const until = (await ethers.provider.getBlock('latest'))!.number + 100; + + await expect(srStaker.importFreezeUntilBlocks([staker_0], [until])).to.be.revertedWith('AccessControl'); + await expect(srAdmin.importFreezeUntilBlocks([staker_0], [until, until + 1])).to.be.revertedWith( + 'ArrayLengthMismatch()' + ); + }); + + it('should not shorten an existing freeze when importing a lower deadline', async function () { + const srAdmin = stakeRegistry.connect(await getSignerFor(deployer)); + const laterUntil = (await ethers.provider.getBlock('latest'))!.number + 500; + const earlierUntil = laterUntil - 200; + + await srAdmin.importFreezeUntilBlocks([staker_0], [laterUntil]); + expect(await stakeRegistry.freezeUntilBlock(staker_0)).to.eq(laterUntil); + + await srAdmin.importFreezeUntilBlocks([staker_0], [earlierUntil]); + expect(await stakeRegistry.freezeUntilBlock(staker_0)).to.eq(laterUntil); + }); + }); + it('should not allow staking while paused and should allow it again after unpause', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); From eb8e635baaaa9944b1bc9d4e9086ce6c2db656ce Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 20 May 2026 12:28:31 +0200 Subject: [PATCH 41/58] refactor(staking): rename _applyPreviewUpdate to _simulateUpdate Clarifies the helper simulates queue effects in memory without writing storage, distinct from _applyStoredUpdate. --- src/Staking.sol | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 05ad3c50..f3a1da5e 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -480,7 +480,7 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Applies a single queued update to storage. * @dev Three paths: partial withdrawal (transfer + balance), full exit (delete stake + transfer), - * or stake mutation via `_applyPreviewUpdate` (deposit, add, height, overlay). + * or stake mutation via `_simulateUpdate` (deposit, add, height, overlay). */ function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { // Path 1: partial withdrawal — pay out capped at current balance (may be slashed since queued). @@ -512,7 +512,7 @@ contract StakeRegistry is AccessControl, Pausable { // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). Stake storage stRef = _stakes[_owner]; Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); - s = _applyPreviewUpdate(_owner, s, scheduled); + s = _simulateUpdate(_owner, s, scheduled); stRef.overlay = s.overlay; stRef.balance = s.balance; stRef.height = s.height; @@ -573,7 +573,7 @@ contract StakeRegistry is AccessControl, Pausable { } } - preview = _applyPreviewUpdate(_owner, preview, scheduled); + preview = _simulateUpdate(_owner, preview, scheduled); unchecked { ++i; @@ -581,8 +581,6 @@ contract StakeRegistry is AccessControl, Pausable { } } - - /** * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible, happens in slashing */ @@ -710,7 +708,7 @@ contract StakeRegistry is AccessControl, Pausable { break; } - preview = _applyPreviewUpdate(_owner, preview, scheduled); + preview = _simulateUpdate(_owner, preview, scheduled); unchecked { ++i; @@ -737,7 +735,7 @@ contract StakeRegistry is AccessControl, Pausable { break; } - preview = _applyPreviewUpdate(_owner, preview, scheduled); + preview = _simulateUpdate(_owner, preview, scheduled); unchecked { ++i; @@ -746,10 +744,10 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @notice Applies a single queued update to an in-memory preview state. - * @dev Must match non-transfer semantics applied in `_applyStoredUpdate` for the same `kind` (excluding token transfers). + * @notice Simulates a single queued update on in-memory stake state. + * @dev Does not write storage. Must match non-transfer semantics in `_applyStoredUpdate` for the same `kind`. */ - function _applyPreviewUpdate( + function _simulateUpdate( address _owner, Stake memory preview, ScheduledUpdate storage scheduled From 7102938580683895d57091a392bb3d5d55b8d84e Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 21 May 2026 15:04:47 +0200 Subject: [PATCH 42/58] refactor(staking): consolidate per-address Account Group stake, freezeUntilBlock, and update queue in one struct. Use _clearStake/_clearQueue so penalties survive exit and migrate. Refs #309 --- src/Staking.sol | 168 ++++++++++++++++++++++++++++-------------------- 1 file changed, 99 insertions(+), 69 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index f3a1da5e..f471cdb2 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -58,12 +58,17 @@ contract StakeRegistry is AccessControl, Pausable { uint8 height; } - mapping(address => Stake) private _stakes; - /// @notice End block of the protocol freeze for this account (exclusive: unfrozen when `block.number` > this value). Persists across exit and migration. - mapping(address => uint256) public freezeUntilBlock; - mapping(address => ScheduledUpdate[]) private _updateQueues; - mapping(address => uint256) private _queueHeads; - mapping(address => bool) private _queueClosed; + /// @dev Per-account stake, freeze penalty, and update queue share one lifecycle bucket; never `delete` the whole struct — use `_clearStake` / `_clearQueue` so `freezeUntilBlock` survives. + struct Account { + Stake stake; + /// @notice End block of the protocol freeze (exclusive: unfrozen when `block.number` > this value). Persists across exit and migration. + uint256 freezeUntilBlock; + ScheduledUpdate[] updateQueue; + uint64 queueHead; + bool queueClosed; + } + + mapping(address => Account) private _accounts; bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); @@ -163,7 +168,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 _amount, uint8 _height ) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (_hasCommittedStake(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); uint256 minStake = _minimumStakeForHeight(_height); @@ -190,7 +195,7 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ function addTokens(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -207,7 +212,7 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Reverts with `OverlayUnchanged` if the derived overlay equals the current one (no sentinel return value). */ function changeOverlay(bytes32 _setNonce) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -225,7 +230,7 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. */ function increaseHeight(uint8 _height) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); @@ -245,7 +250,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -266,12 +271,12 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function exit() external whenNotPaused returns (uint64 effectiveFromRound) { - if (_queueClosed[msg.sender]) revert QueueClosed(); + if (_accounts[msg.sender].queueClosed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); - _queueClosed[msg.sender] = true; + _accounts[msg.sender].queueClosed = true; emit WithdrawalQueued(msg.sender, effectiveFromRound, plannedStake.balance); } @@ -287,8 +292,9 @@ contract StakeRegistry is AccessControl, Pausable { */ function applyUpdates(address _owner) public { _applyReadyUpdates(_owner); - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; + Account storage account = _accounts[_owner]; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; if ( head < queue.length && queue[head].effectiveFromRound <= currentRound() && @@ -305,9 +311,10 @@ contract StakeRegistry is AccessControl, Pausable { function migrateStake() external whenPaused { _applyReadyUpdates(msg.sender); - uint256 payout = _stakes[msg.sender].balance; - ScheduledUpdate[] storage queue = _updateQueues[msg.sender]; - uint256 head = _queueHeads[msg.sender]; + Account storage account = _accounts[msg.sender]; + uint256 payout = account.stake.balance; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; @@ -320,10 +327,7 @@ contract StakeRegistry is AccessControl, Pausable { } } - delete _stakes[msg.sender]; - delete _updateQueues[msg.sender]; - delete _queueHeads[msg.sender]; - delete _queueClosed[msg.sender]; + _clearStakeAndQueue(msg.sender); emit StakeMigrated(msg.sender, payout); @@ -334,24 +338,24 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Seeds `freezeUntilBlock` on a successor registry during contract migration. - * @param _accounts Accounts whose freeze deadlines should be carried over. - * @param _untilBlocks Matching `freezeUntilBlock` values from the predecessor contract. + * @param accounts Accounts whose freeze deadlines should be carried over. + * @param untilBlocks Matching `freezeUntilBlock` values from the predecessor contract. * @dev Only extends each account's freeze (monotonic, same rule as `freezeDeposit`). Intended for * admin migration tooling after nodes call `migrateStake` on the old contract and before they * restake here; stake and queue state are not imported. */ function importFreezeUntilBlocks( - address[] calldata _accounts, - uint256[] calldata _untilBlocks + address[] calldata accounts, + uint256[] calldata untilBlocks ) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (_accounts.length != _untilBlocks.length) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < _accounts.length; ) { - address account = _accounts[i]; - uint256 until = _untilBlocks[i]; - if (freezeUntilBlock[account] < until) { - freezeUntilBlock[account] = until; - emit AccountFreezeExtended(account, freezeUntilBlock[account]); + if (accounts.length != untilBlocks.length) revert ArrayLengthMismatch(); + + for (uint256 i = 0; i < accounts.length; ) { + address account = accounts[i]; + uint256 until = untilBlocks[i]; + if (_accounts[account].freezeUntilBlock < until) { + _accounts[account].freezeUntilBlock = until; + emit AccountFreezeExtended(account, _accounts[account].freezeUntilBlock); } unchecked { @@ -374,9 +378,9 @@ contract StakeRegistry is AccessControl, Pausable { // No stake and no queue: only record account-level penalty. if (!_hasCommittedStake(_owner) && _queueLength(_owner) == 0) { - if (freezeUntilBlock[_owner] < until) { - freezeUntilBlock[_owner] = until; - emit AccountFreezeExtended(_owner, freezeUntilBlock[_owner]); + if (_accounts[_owner].freezeUntilBlock < until) { + _accounts[_owner].freezeUntilBlock = until; + emit AccountFreezeExtended(_owner, _accounts[_owner].freezeUntilBlock); } return; } @@ -385,12 +389,12 @@ contract StakeRegistry is AccessControl, Pausable { // withdrawal in the same transaction is not blocked by the new penalty start. _applyReadyUpdates(_owner); - if (freezeUntilBlock[_owner] < until) { - freezeUntilBlock[_owner] = until; + if (_accounts[_owner].freezeUntilBlock < until) { + _accounts[_owner].freezeUntilBlock = until; } if (_hasCommittedStake(_owner)) { - emit StakeFrozen(_owner, _stakes[_owner].overlay, _time); + emit StakeFrozen(_owner, _accounts[_owner].stake.overlay, _time); } } @@ -405,7 +409,7 @@ contract StakeRegistry is AccessControl, Pausable { _applyReadyUpdates(_owner); - Stake storage stake = _stakes[_owner]; + Stake storage stake = _accounts[_owner].stake; bytes32 previousOverlay = stake.overlay; if (previousOverlay != bytes32(0)) { @@ -417,7 +421,7 @@ contract StakeRegistry is AccessControl, Pausable { stake.balance = 0; _reconcileQueuedWithdrawals(_owner); } else { - delete _stakes[_owner]; + _clearStake(_owner); } } @@ -452,14 +456,31 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Applies all queued updates that are effective in the current round. * @dev Stops at the first frozen withdrawal/exit without reverting. */ + function _clearStake(address _owner) internal { + delete _accounts[_owner].stake; + } + + function _clearQueue(address _owner) internal { + Account storage account = _accounts[_owner]; + delete account.updateQueue; + account.queueHead = 0; + account.queueClosed = false; + } + + function _clearStakeAndQueue(address _owner) internal { + _clearStake(_owner); + _clearQueue(_owner); + } + function _applyReadyUpdates(address _owner) internal { - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; + Account storage account = _accounts[_owner]; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; uint64 roundNumber = currentRound(); while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { // Exit the loop (do not skip to the next item): leave the due withdrawal/exit at `head` - // unapplied so FIFO is preserved; `_queueHeads` is updated below for retry on a later call. + // unapplied so FIFO is preserved; `queueHead` is updated below for retry on a later call. if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; _applyStoredUpdate(_owner, queue[head]); delete queue[head]; @@ -469,11 +490,9 @@ contract StakeRegistry is AccessControl, Pausable { } if (head == queue.length) { - delete _updateQueues[_owner]; - delete _queueHeads[_owner]; - delete _queueClosed[_owner]; + _clearQueue(_owner); } else { - _queueHeads[_owner] = head; + account.queueHead = uint64(head); } } @@ -485,7 +504,7 @@ contract StakeRegistry is AccessControl, Pausable { function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { // Path 1: partial withdrawal — pay out capped at current balance (may be slashed since queued). if (scheduled.kind == UpdateKind.WithdrawTokens) { - Stake storage stake = _stakes[_owner]; + Stake storage stake = _accounts[_owner].stake; if (stake.overlay != bytes32(0)) { uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; stake.balance -= paid; @@ -499,9 +518,9 @@ contract StakeRegistry is AccessControl, Pausable { // Path 2: full exit — delete stake and return all remaining BZZ. if (scheduled.kind == UpdateKind.ExitStake) { - Stake storage stakeRef = _stakes[_owner]; + Stake storage stakeRef = _accounts[_owner].stake; uint256 balance = stakeRef.balance; - delete _stakes[_owner]; + _clearStake(_owner); if (balance > 0) { if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); emit Withdrawal(_owner, currentRound(), balance); @@ -510,7 +529,7 @@ contract StakeRegistry is AccessControl, Pausable { } // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). - Stake storage stRef = _stakes[_owner]; + Stake storage stRef = _accounts[_owner].stake; Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); s = _simulateUpdate(_owner, s, scheduled); stRef.overlay = s.overlay; @@ -537,7 +556,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 lastRound = _lastScheduledRound(_owner); effectiveFromRound = candidateRound > lastRound ? candidateRound : lastRound; - _updateQueues[_owner].push( + _accounts[_owner].updateQueue.push( ScheduledUpdate({ kind: _kind, effectiveFromRound: effectiveFromRound, @@ -560,9 +579,10 @@ contract StakeRegistry is AccessControl, Pausable { * @dev This preserves queue order while preventing later withdrawals from overpaying the owner. */ function _reconcileQueuedWithdrawals(address _owner) internal { - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; - Stake memory preview = _stakes[_owner]; + Account storage account = _accounts[_owner]; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; + Stake memory preview = account.stake; for (uint256 i = head; i < queue.length; ) { ScheduledUpdate storage scheduled = queue[i]; @@ -599,6 +619,13 @@ contract StakeRegistry is AccessControl, Pausable { // STATE READING // //////////////////////////////////////// + /** + * @notice Returns the end block of the protocol freeze for an account. + */ + function freezeUntilBlock(address _owner) external view returns (uint256) { + return _accounts[_owner].freezeUntilBlock; + } + /** * @notice Returns the currently visible stake state for an owner. */ @@ -666,10 +693,10 @@ contract StakeRegistry is AccessControl, Pausable { } /** - * @dev True when `freezeUntilBlock[_owner] < block.number` (current block is past the penalty window). + * @dev True when `_accounts[_owner].freezeUntilBlock < block.number` (current block is past the penalty window). */ function _addressNotFrozen(address _owner) internal view returns (bool) { - return freezeUntilBlock[_owner] < block.number; + return _accounts[_owner].freezeUntilBlock < block.number; } /** @@ -693,10 +720,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Previews stake state using the current round, optionally including future queued updates. */ function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (Stake memory preview) { - preview = _stakes[_owner]; + Account storage account = _accounts[_owner]; + preview = account.stake; - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; uint64 roundNumber = currentRound(); for (uint256 i = head; i < queue.length; ) { @@ -720,10 +748,11 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Previews stake state as it would look after the given round lookahead. */ function _previewStakeLookahead(address _owner, uint64 _lookahead) internal view returns (Stake memory preview) { - preview = _stakes[_owner]; + Account storage account = _accounts[_owner]; + preview = account.stake; - ScheduledUpdate[] storage queue = _updateQueues[_owner]; - uint256 head = _queueHeads[_owner]; + ScheduledUpdate[] storage queue = account.updateQueue; + uint256 head = account.queueHead; uint64 targetRound = currentRound() + _lookahead; for (uint256 i = head; i < queue.length; ) { @@ -800,7 +829,7 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the effective round of the last queued update. */ function _lastScheduledRound(address _owner) internal view returns (uint64) { - ScheduledUpdate[] storage queue = _updateQueues[_owner]; + ScheduledUpdate[] storage queue = _accounts[_owner].updateQueue; if (_queueLength(_owner) == 0) { return 0; } @@ -811,7 +840,8 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the number of pending queued updates. */ function _queueLength(address _owner) internal view returns (uint256) { - return _updateQueues[_owner].length - _queueHeads[_owner]; + Account storage account = _accounts[_owner]; + return account.updateQueue.length - account.queueHead; } /** @@ -825,7 +855,7 @@ contract StakeRegistry is AccessControl, Pausable { return _addressNotFrozen(_owner); } - return freezeUntilBlock[_owner] < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; + return _accounts[_owner].freezeUntilBlock < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; } /** @@ -848,7 +878,7 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Commitment is indicated by `overlay != bytes32(0)`; collision with keccak256 output is negligible. */ function _hasCommittedStake(address _owner) internal view returns (bool) { - return _stakes[_owner].overlay != bytes32(0); + return _accounts[_owner].stake.overlay != bytes32(0); } /// @notice Same commitment predicate for an in-memory stake (e.g. queue preview). From 5086b5b85417233a9716b98ed213b3248eb71502 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 21 May 2026 15:06:32 +0200 Subject: [PATCH 43/58] style(staking): format Staking.sol with Prettier --- src/Staking.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Staking.sol b/src/Staking.sol index f471cdb2..864c7a4b 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -855,7 +855,8 @@ contract StakeRegistry is AccessControl, Pausable { return _addressNotFrozen(_owner); } - return _accounts[_owner].freezeUntilBlock < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; + return + _accounts[_owner].freezeUntilBlock < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; } /** From 9bebce558238803823945827b8559f7b2286dfd6 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 13:25:29 +0200 Subject: [PATCH 44/58] Add Echidna harness for queued-update StakeRegistry Replace the legacy manageStake fuzz target with EchidnaStakingHarness covering deposits, queue apply, freeze/slash, and migration. Update system/claim mocks so the echidna suite compiles against the new staking API. --- src/echidna/EchidnaMocks.sol | 12 + .../EchidnaRedistributionClaimHarness.sol | 2 + src/echidna/EchidnaStakeRegistryHarness.sol | 667 ------------------ src/echidna/EchidnaStakingHarness.sol | 549 ++++++++++++++ src/echidna/EchidnaSystemHarness.sol | 32 +- 5 files changed, 579 insertions(+), 683 deletions(-) delete mode 100644 src/echidna/EchidnaStakeRegistryHarness.sol create mode 100644 src/echidna/EchidnaStakingHarness.sol diff --git a/src/echidna/EchidnaMocks.sol b/src/echidna/EchidnaMocks.sol index bb00eff1..f6d530de 100644 --- a/src/echidna/EchidnaMocks.sol +++ b/src/echidna/EchidnaMocks.sol @@ -53,6 +53,18 @@ contract EchidnaStakeRegistryMock is IStakeRegistry { function nodeEffectiveStake(address _owner) external view returns (uint256) { return nodes[_owner].effectiveStake; } + + function overlayOfAddressLookahead(address _owner, uint64) external view returns (bytes32) { + return nodes[_owner].overlay; + } + + function heightOfAddressLookahead(address _owner, uint64) external view returns (uint8) { + return nodes[_owner].height; + } + + function nodeEffectiveStakeLookahead(address _owner, uint64) external view returns (uint256) { + return nodes[_owner].effectiveStake; + } } /// @notice Shared price oracle mock for redistribution harnesses. diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index 593abdb4..26a1e3cd 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -83,6 +83,8 @@ contract EchidnaPostageStampPotMock is IPostageStamp { } contract RedistributionClaimStub is Redistribution { + event WithdrawFailed(address indexed winner); + constructor( address staking, address postageContract, diff --git a/src/echidna/EchidnaStakeRegistryHarness.sol b/src/echidna/EchidnaStakeRegistryHarness.sol deleted file mode 100644 index ba99631a..00000000 --- a/src/echidna/EchidnaStakeRegistryHarness.sol +++ /dev/null @@ -1,667 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -pragma solidity ^0.8.19; - -import "../TestToken.sol"; -import "../Staking.sol"; - -contract ConstantPriceOracle is IPriceOracle { - uint32 internal immutable _price; - - constructor(uint32 price_) { - _price = price_; - } - - function currentPrice() external view returns (uint32) { - return _price; - } -} - -contract EchidnaStakeActor { - TestToken internal immutable token; - StakeRegistry internal immutable registry; - - constructor(TestToken token_, StakeRegistry registry_) { - token = token_; - registry = registry_; - token.approve(address(registry), type(uint256).max); - } - - function manageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external returns (bool ok) { - (ok, ) = address(registry).call( - abi.encodeWithSelector(registry.manageStake.selector, setNonce, addAmount, height) - ); - } - - function withdrawFromStake() external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.withdrawFromStake.selector)); - } - - function migrateStake() external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.migrateStake.selector)); - } - - function tryPause() external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.pause.selector)); - } - - function tryUnpause() external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.unPause.selector)); - } - - function tryChangeNetworkId(uint64 newNetworkId) external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); - } - - function tryFreezeDeposit(address owner, uint256 time) external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.freezeDeposit.selector, owner, time)); - } - - function trySlashDeposit(address owner, uint256 amount) external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.slashDeposit.selector, owner, amount)); - } -} - -/// @notice Echidna harness for stateful, multi-actor fuzzing of StakeRegistry. -/// @dev Echidna calls public/external functions on this contract. -contract EchidnaStakeRegistryHarness { - TestToken internal immutable token; - StakeRegistry internal immutable registry; - ConstantPriceOracle internal immutable oracle; - - uint256 internal immutable initialSupply; - - uint256 internal constant MIN_STAKE = 100000000000000000; // 1e17 (matches StakeRegistry) - uint32 internal constant ORACLE_PRICE = 1; - - uint256 internal constant ACTOR_COUNT = 3; - EchidnaStakeActor[3] internal actors; - EchidnaStakeActor internal redistributor; - - uint64 internal trackedNetworkId; - - // Tracking per-actor last successful state. - uint256[3] internal lastCommittedStakeByActor; - bytes32[3] internal lastSetNonceByActor; - uint64[3] internal networkIdAtLastStakeByActor; - - // “Must never happen” flags (set by actions, checked by properties). - bool internal unauthorizedAdminCallSucceeded; - bool internal unauthorizedFreezeSlashSucceeded; - bool internal pausedManageStakeSucceeded; - bool internal frozenManageStakeSucceeded; - bool internal actionInvariantViolated; - - // Post-condition checks for the last *successful* manageStake(add > 0). - // We keep these checks "pending" only until the next action, so properties - // validate the immediate post-state without being invalidated by later actions. - bool internal pendingManageStakeAddCheck; - uint256 internal pendingActorIdx; - uint256 internal pendingAddAmount; - uint8 internal pendingHeight; - uint256 internal pendingPotentialBefore; - uint256 internal pendingRegistryBalanceBefore; - - // Post-condition checks for the last freeze/slash/migrate call (pending until next action). - bool internal pendingFreezeCheck; - uint256 internal pendingFreezeIdx; - bool internal pendingFreezeHadStake; - bytes32 internal pendingFreezeOverlay; - uint256 internal pendingFreezeCommitted; - uint256 internal pendingFreezePotential; - uint8 internal pendingFreezeHeight; - uint256 internal pendingFreezeExpectedLastUpdated; - - bool internal pendingSlashCheck; - uint256 internal pendingSlashIdx; - bool internal pendingSlashHadStake; - bytes32 internal pendingSlashOverlay; - uint256 internal pendingSlashCommitted; - uint256 internal pendingSlashPotential; - uint8 internal pendingSlashHeight; - uint256 internal pendingSlashLastUpdated; - uint256 internal pendingSlashAmount; - uint256 internal pendingSlashExpectedBlockNumber; - - bool internal pendingMigrateCheck; - uint256 internal pendingMigrateIdx; - bool internal pendingMigrateHadStake; - uint256 internal pendingMigratePotentialBefore; - uint256 internal pendingMigrateActorBalanceBefore; - uint256 internal pendingMigrateLastUpdatedBefore; - bool internal migrateSucceededWhileUnpaused; - - constructor() { - // Keep values modest so arithmetic in invariants stays safe. - initialSupply = 1_000_000_000_000_000_000_000_000; // 1e24 - - token = new TestToken("TestToken", "TT", initialSupply); - oracle = new ConstantPriceOracle(ORACLE_PRICE); - trackedNetworkId = 10; - registry = new StakeRegistry(address(token), trackedNetworkId, address(oracle)); - - // Create actors and fund them. - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - actors[i] = new EchidnaStakeActor(token, registry); - token.transfer(address(actors[i]), initialSupply / 20); // 5% each - networkIdAtLastStakeByActor[i] = trackedNetworkId; - } - - // A dedicated redistributor actor (role granted by admin = this harness). - redistributor = new EchidnaStakeActor(token, registry); - registry.grantRole(registry.REDISTRIBUTOR_ROLE(), address(redistributor)); - } - - // ----------------------------- - // Actions (state transitions) - // ----------------------------- - - function act_fundActor(uint8 actorId, uint256 amount) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - - EchidnaStakeActor a = _actor(actorId); - uint256 bal = token.balanceOf(address(this)); - if (bal == 0) return; - uint256 x = amount % (bal + 1); - if (x == 0) return; - token.transfer(address(a), x); - - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { - _clearPendingChecks(); - - uint256 idx = uint256(actorId) % ACTOR_COUNT; - EchidnaStakeActor actor = actors[idx]; - // Keep height small to avoid huge powers of two. - uint8 h = uint8(height % 16); - - uint256 available = token.balanceOf(address(actor)); - if (available == 0) return; - - uint256 add = addAmount % (available + 1); - - // If this is the first stake update, enforce the minimum stake rule - // (or skip the call when we can't satisfy it). - uint256 lastUpdated = registry.lastUpdatedBlockNumberOfAddress(address(actor)); - if (lastUpdated == 0 && add > 0) { - uint256 minStake = MIN_STAKE * (1 << h); - if (add < minStake) { - add = minStake; - if (add > available) return; - } - } - - // If paused, manageStake must not succeed. - if (registry.paused()) { - bool okPaused = actor.manageStake(setNonce, add, h); - if (okPaused) pausedManageStakeSucceeded = true; - return; - } - - // If frozen (including same-block update), manageStake must not succeed. - if (lastUpdated != 0 && lastUpdated >= block.number) { - bool okFrozen = actor.manageStake(setNonce, add, h); - if (okFrozen) frozenManageStakeSucceeded = true; - return; - } - - // Snapshot other actors so we can detect unintended writes. - (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - - // Prepare pending post-conditions only for add > 0. - if (add > 0) { - pendingActorIdx = idx; - pendingAddAmount = add; - pendingHeight = h; - (, , pendingPotentialBefore, , ) = registry.stakes(address(actor)); - pendingRegistryBalanceBefore = token.balanceOf(address(registry)); - } - - bool ok = actor.manageStake(setNonce, add, h); - if (!ok) return; - - _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - - (, uint256 committedStake, , , ) = registry.stakes(address(actor)); - if (committedStake < lastCommittedStakeByActor[idx]) actionInvariantViolated = true; - lastCommittedStakeByActor[idx] = committedStake; - lastSetNonceByActor[idx] = setNonce; - networkIdAtLastStakeByActor[idx] = trackedNetworkId; - - // Arm post-condition properties for the immediate post-state. - if (add > 0) pendingManageStakeAddCheck = true; - } - - function act_actor_withdrawSurplus(uint8 actorId) external { - _clearPendingChecks(); - - uint256 idx = uint256(actorId) % ACTOR_COUNT; - EchidnaStakeActor a = actors[idx]; - (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - - (bytes32 ov, uint256 committed, uint256 potential, , uint8 h) = registry.stakes(address(a)); - uint256 beforeBal = token.balanceOf(address(a)); - - bool ok = a.withdrawFromStake(); - if (!ok) return; - - _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - - (bytes32 ov2, , uint256 potentialAfter, , ) = registry.stakes(address(a)); - uint256 afterBal = token.balanceOf(address(a)); - - // No changes to overlay expected from withdraw. - if (ov2 != ov) actionInvariantViolated = true; - - // Expected surplus based on contract math. - uint256 effective = _min(potential, committed * (1 << h) * uint256(ORACLE_PRICE)); - uint256 surplus = potential - effective; - - if (surplus == 0) { - if (potentialAfter != potential) actionInvariantViolated = true; - if (afterBal != beforeBal) actionInvariantViolated = true; - return; - } - - if (potentialAfter + surplus != potential) actionInvariantViolated = true; - if (afterBal != beforeBal + surplus) actionInvariantViolated = true; - } - - function act_actor_migrateStake(uint8 actorId) external { - _clearPendingChecks(); - - uint256 idx = uint256(actorId) % ACTOR_COUNT; - EchidnaStakeActor a = actors[idx]; - (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - - pendingMigrateIdx = idx; - pendingMigrateActorBalanceBefore = token.balanceOf(address(a)); - (, , pendingMigratePotentialBefore, pendingMigrateLastUpdatedBefore, ) = registry.stakes(address(a)); - pendingMigrateHadStake = pendingMigrateLastUpdatedBefore != 0; - - bool ok = a.migrateStake(); - if (!ok) return; - - if (!registry.paused()) migrateSucceededWhileUnpaused = true; - _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - pendingMigrateCheck = true; - - // If a stake existed, migrateStake refunds and deletes it. Reset per-actor tracking - // so future re-stakes don't incorrectly look like "commitment decreased". - if (pendingMigrateHadStake) { - lastCommittedStakeByActor[idx] = 0; - lastSetNonceByActor[idx] = bytes32(0); - networkIdAtLastStakeByActor[idx] = trackedNetworkId; - } - } - - function act_admin_pause() external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - registry.pause(); - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_admin_unpause() external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - registry.unPause(); - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_admin_changeNetworkId(uint64 newNetworkId) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - registry.changeNetworkId(newNetworkId); - trackedNetworkId = newNetworkId; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_actor_tryPause(uint8 actorId) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).tryPause(); - if (ok) unauthorizedAdminCallSucceeded = true; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_actor_tryUnpause(uint8 actorId) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).tryUnpause(); - if (ok) unauthorizedAdminCallSucceeded = true; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).tryChangeNetworkId(newNetworkId); - if (ok) unauthorizedAdminCallSucceeded = true; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { - _clearPendingChecks(); - - uint256 idx = uint256(targetActorId) % ACTOR_COUNT; - EchidnaStakeActor t = actors[idx]; - (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - - ( - pendingFreezeOverlay, - pendingFreezeCommitted, - pendingFreezePotential, - pendingFreezeExpectedLastUpdated, - pendingFreezeHeight - ) = registry.stakes(address(t)); - pendingFreezeIdx = idx; - pendingFreezeHadStake = pendingFreezeExpectedLastUpdated != 0; - pendingFreezeExpectedLastUpdated = pendingFreezeHadStake ? block.number + uint256(time) : 0; - - bool ok = redistributor.tryFreezeDeposit(address(t), uint256(time)); - if (!ok) return; - - _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - pendingFreezeCheck = true; - } - - function act_redistributor_slash(uint8 targetActorId, uint256 amount) external { - _clearPendingChecks(); - - uint256 idx = uint256(targetActorId) % ACTOR_COUNT; - EchidnaStakeActor t = actors[idx]; - (bytes32 otherDigestA, bytes32 otherDigestB) = _otherDigests(idx); - - ( - pendingSlashOverlay, - pendingSlashCommitted, - pendingSlashPotential, - pendingSlashLastUpdated, - pendingSlashHeight - ) = registry.stakes(address(t)); - pendingSlashIdx = idx; - pendingSlashHadStake = pendingSlashLastUpdated != 0; - pendingSlashAmount = amount; - pendingSlashExpectedBlockNumber = block.number; - - bool ok = redistributor.trySlashDeposit(address(t), amount); - if (!ok) return; - - _checkOtherDigestsUnchanged(idx, otherDigestA, otherDigestB); - pendingSlashCheck = true; - - // If the slash deleted the stake (amount >= potential), reset per-actor tracking - // so future re-stakes don't incorrectly look like "commitment decreased". - if (pendingSlashHadStake && pendingSlashPotential <= pendingSlashAmount) { - lastCommittedStakeByActor[idx] = 0; - lastSetNonceByActor[idx] = bytes32(0); - networkIdAtLastStakeByActor[idx] = trackedNetworkId; - } - } - - function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).tryFreezeDeposit(address(_actor(targetActorId)), uint256(time)); - if (ok) unauthorizedFreezeSlashSucceeded = true; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - function act_actor_trySlash(uint8 actorId, uint8 targetActorId, uint256 amount) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).trySlashDeposit(address(_actor(targetActorId)), amount); - if (ok) unauthorizedFreezeSlashSucceeded = true; - if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; - } - - // ----------------------------- - // Properties (checked by Echidna) - // ----------------------------- - - function echidna_never_performed_forbidden_calls() external view returns (bool) { - return - !unauthorizedAdminCallSucceeded && - !unauthorizedFreezeSlashSucceeded && - !pausedManageStakeSucceeded && - !frozenManageStakeSucceeded && - !actionInvariantViolated; - } - - function echidna_registry_balance_covers_sum_potential() external view returns (bool) { - uint256 sumPotential; - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (, , uint256 potentialStake, , ) = registry.stakes(address(actors[i])); - sumPotential += potentialStake; - } - return token.balanceOf(address(registry)) >= sumPotential; - } - - /// @notice After a successful manageStake(add > 0), potential and registry balance - /// must both increase by exactly `add`. - function echidna_last_manageStake_add_updates_potential_and_registry_balance() external view returns (bool) { - if (!pendingManageStakeAddCheck) return true; - address a = address(actors[pendingActorIdx]); - (, , uint256 potentialAfter, , ) = registry.stakes(a); - if (potentialAfter != pendingPotentialBefore + pendingAddAmount) return false; - if (token.balanceOf(address(registry)) != pendingRegistryBalanceBefore + pendingAddAmount) return false; - return true; - } - - /// @notice After a successful manageStake(add > 0), committedStake must be - /// recomputed to floor(potential / (price * 2**height)). - function echidna_last_manageStake_add_recomputes_committedStake() external view returns (bool) { - if (!pendingManageStakeAddCheck) return true; - address a = address(actors[pendingActorIdx]); - (, uint256 committedAfter, uint256 potentialAfter, , uint8 hAfter) = registry.stakes(a); - if (hAfter != pendingHeight) return false; - uint256 denom = uint256(ORACLE_PRICE) * (1 << pendingHeight); - uint256 expectedCommitted = potentialAfter / denom; - return committedAfter == expectedCommitted; - } - - function echidna_migrate_never_succeeds_while_unpaused() external view returns (bool) { - return !migrateSucceededWhileUnpaused; - } - - function echidna_last_migrate_refunds_and_deletes_when_stake_exists() external view returns (bool) { - if (!pendingMigrateCheck) return true; - address a = address(actors[pendingMigrateIdx]); - - // migrateStake has whenPaused; if the call succeeded, we must be paused. - if (!registry.paused()) return false; - - if (!pendingMigrateHadStake) { - // No stake existed; migrate is a no-op. - if (token.balanceOf(a) != pendingMigrateActorBalanceBefore) return false; - return registry.lastUpdatedBlockNumberOfAddress(a) == 0; - } - - // Stake existed; it must be deleted and balance refunded. - if (token.balanceOf(a) != pendingMigrateActorBalanceBefore + pendingMigratePotentialBefore) return false; - return registry.lastUpdatedBlockNumberOfAddress(a) == 0; - } - - function echidna_last_freeze_only_updates_lastUpdated() external view returns (bool) { - if (!pendingFreezeCheck) return true; - address a = address(actors[pendingFreezeIdx]); - (bytes32 ov, uint256 c, uint256 p, uint256 u, uint8 h) = registry.stakes(a); - - if (!pendingFreezeHadStake) { - return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; - } - - if (ov != pendingFreezeOverlay) return false; - if (c != pendingFreezeCommitted) return false; - if (p != pendingFreezePotential) return false; - if (h != pendingFreezeHeight) return false; - return u == pendingFreezeExpectedLastUpdated; - } - - function echidna_last_slash_updates_expected_fields() external view returns (bool) { - if (!pendingSlashCheck) return true; - address a = address(actors[pendingSlashIdx]); - (bytes32 ov, uint256 c, uint256 p, uint256 u, uint8 h) = registry.stakes(a); - - if (!pendingSlashHadStake) { - // No stake existed; slash does nothing. - return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; - } - - if (pendingSlashPotential > pendingSlashAmount) { - // Partial slash: only potential decreases, lastUpdated set to block.number at slash time. - if (ov != pendingSlashOverlay) return false; - if (c != pendingSlashCommitted) return false; - if (h != pendingSlashHeight) return false; - if (p != pendingSlashPotential - pendingSlashAmount) return false; - return u == pendingSlashExpectedBlockNumber; - } - - // Full slash: stake deleted. - return ov == bytes32(0) && c == 0 && p == 0 && u == 0 && h == 0; - } - - function echidna_stake_committed_never_decreases_per_actor() external view returns (bool) { - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (, uint256 committedStake, , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); - if (lastUpdated == 0) continue; - if (committedStake < lastCommittedStakeByActor[i]) return false; - } - return true; - } - - function echidna_nodeEffective_matches_freeze_rule_per_actor() external view returns (bool) { - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry.stakes( - address(actors[i]) - ); - uint256 fromView = registry.nodeEffectiveStake(address(actors[i])); - if (lastUpdated == 0) { - if (fromView != 0) return false; - continue; - } - if (lastUpdated >= block.number) { - if (fromView != 0) return false; - continue; - } - uint256 expected = _min(potentialStake, committedStake * (1 << h) * uint256(ORACLE_PRICE)); - if (fromView != expected) return false; - } - return true; - } - - function echidna_empty_state_is_zeroed_for_all() external view returns (bool) { - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry - .stakes(address(actors[i])); - if (lastUpdated != 0) continue; - if (overlay != bytes32(0) || committedStake != 0 || potentialStake != 0 || h != 0) return false; - } - return true; - } - - function echidna_overlay_matches_last_manageStake_for_all() external view returns (bool) { - for (uint256 i = 0; i < ACTOR_COUNT; i++) { - (bytes32 overlay, , , uint256 lastUpdated, ) = registry.stakes(address(actors[i])); - if (lastUpdated == 0) continue; - bytes32 expected = keccak256( - abi.encodePacked(address(actors[i]), _reverse(networkIdAtLastStakeByActor[i]), lastSetNonceByActor[i]) - ); - if (overlay != expected) return false; - } - return true; - } - - function _min(uint256 a, uint256 b) internal pure returns (uint256) { - return a < b ? a : b; - } - - function _reverse(uint64 input) internal pure returns (uint64 v) { - v = input; - v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); - v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); - v = (v >> 32) | (v << 32); - } - - function _actor(uint8 actorId) internal view returns (EchidnaStakeActor) { - return actors[uint256(actorId) % ACTOR_COUNT]; - } - - function _stakeDigest(address who) internal view returns (bytes32) { - (bytes32 overlay, uint256 committedStake, uint256 potentialStake, uint256 lastUpdated, uint8 h) = registry - .stakes(who); - return keccak256(abi.encodePacked(overlay, committedStake, potentialStake, lastUpdated, h)); - } - - function _otherDigests(uint256 idx) internal view returns (bytes32 dA, bytes32 dB) { - if (idx == 0) { - dA = _stakeDigest(address(actors[1])); - dB = _stakeDigest(address(actors[2])); - } else if (idx == 1) { - dA = _stakeDigest(address(actors[0])); - dB = _stakeDigest(address(actors[2])); - } else { - dA = _stakeDigest(address(actors[0])); - dB = _stakeDigest(address(actors[1])); - } - } - - function _checkOtherDigestsUnchanged(uint256 idx, bytes32 dA, bytes32 dB) internal { - if (idx == 0) { - if (_stakeDigest(address(actors[1])) != dA) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; - } else if (idx == 1) { - if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; - if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; - } else { - if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; - if (_stakeDigest(address(actors[1])) != dB) actionInvariantViolated = true; - } - } - - function _clearPendingChecks() internal { - pendingManageStakeAddCheck = false; - pendingFreezeCheck = false; - pendingSlashCheck = false; - pendingMigrateCheck = false; - } -} diff --git a/src/echidna/EchidnaStakingHarness.sol b/src/echidna/EchidnaStakingHarness.sol new file mode 100644 index 00000000..178a1378 --- /dev/null +++ b/src/echidna/EchidnaStakingHarness.sol @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../TestToken.sol"; +import "../Staking.sol"; + +contract EchidnaStakingActor { + TestToken internal immutable token; + StakeRegistry internal immutable registry; + + constructor(TestToken token_, StakeRegistry registry_) { + token = token_; + registry = registry_; + token.approve(address(registry), type(uint256).max); + } + + function createDeposit(bytes32 setNonce, uint256 amount, uint8 height) external returns (bool ok) { + (ok, ) = address(registry).call( + abi.encodeWithSelector(registry.createDeposit.selector, setNonce, amount, height) + ); + } + + function addTokens(uint256 amount) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.addTokens.selector, amount)); + } + + function changeOverlay(bytes32 setNonce) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeOverlay.selector, setNonce)); + } + + function increaseHeight(uint8 height) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.increaseHeight.selector, height)); + } + + function withdraw(uint256 amount) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.withdraw.selector, amount)); + } + + function exit() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.exit.selector)); + } + + function migrateStake() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.migrateStake.selector)); + } + + function tryPause() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.pause.selector)); + } + + function tryUnpause() external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.unpause.selector)); + } + + function tryChangeNetworkId(uint64 newNetworkId) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); + } + + function tryFreezeDeposit(address owner, uint256 time) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.freezeDeposit.selector, owner, time)); + } + + function trySlashDeposit(address owner, uint256 amount) external returns (bool ok) { + (ok, ) = address(registry).call(abi.encodeWithSelector(registry.slashDeposit.selector, owner, amount)); + } +} + +/// @notice Echidna harness for the queued-update StakeRegistry (src/Staking.sol). +contract EchidnaStakingHarness { + TestToken internal immutable token; + StakeRegistry internal immutable registry; + + uint256 internal constant MIN_STAKE = 100000000000000000; + uint256 internal constant ACTOR_COUNT = 3; + uint64 internal constant WAIT_BASE = 2; + uint64 internal constant WAIT_OVERLAY = 2; + uint64 internal constant WAIT_WITHDRAWAL = 2; + + uint256 internal immutable initialSupply; + + EchidnaStakingActor[3] internal actors; + EchidnaStakingActor internal redistributor; + + uint64 internal trackedNetworkId; + bytes32[3] internal lastSetNonceByActor; + + bool internal unauthorizedAdminCallSucceeded; + bool internal unauthorizedFreezeSlashSucceeded; + bool internal pausedMutationSucceeded; + bool internal migrateSucceededWhileUnpaused; + bool internal actionInvariantViolated; + + bool internal pendingDepositCheck; + uint256 internal pendingDepositIdx; + uint256 internal pendingDepositAmount; + uint256 internal pendingRegistryBalanceBefore; + + bool internal pendingMigrateCheck; + uint256 internal pendingMigrateIdx; + uint256 internal pendingMigrateBalanceBefore; + uint256 internal pendingMigrateRegistryBalBefore; + bool internal pendingMigrateHadStake; + + constructor() { + initialSupply = 1_000_000_000_000_000_000_000_000; + + token = new TestToken("TestToken", "TT", initialSupply); + trackedNetworkId = 10; + registry = new StakeRegistry( + address(token), + trackedNetworkId, + WAIT_BASE, + WAIT_OVERLAY, + WAIT_WITHDRAWAL + ); + + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + actors[i] = new EchidnaStakingActor(token, registry); + token.transfer(address(actors[i]), initialSupply / 20); + } + + redistributor = new EchidnaStakingActor(token, registry); + registry.grantRole(registry.REDISTRIBUTOR_ROLE(), address(redistributor)); + } + + // ----------------------------- + // Actions + // ----------------------------- + + function act_tick() external { + _clearPendingChecks(); + } + + function act_fundActor(uint8 actorId, uint256 amount) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + + EchidnaStakingActor a = _actor(actorId); + uint256 bal = token.balanceOf(address(this)); + if (bal == 0) return; + uint256 x = amount % (bal + 1); + if (x == 0) return; + token.transfer(address(a), x); + + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_applyUpdates(uint8 actorId) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + + address owner = address(_actor(actorId)); + try registry.applyUpdates(owner) {} catch {} + + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_actor_createDeposit(uint8 actorId, bytes32 setNonce, uint256 amount, uint8 height) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + uint8 h = uint8(height % 16); + + uint256 available = token.balanceOf(address(actor)); + if (available == 0) return; + + uint256 minStake = MIN_STAKE * (1 << h); + uint256 amt = amount % (available + 1); + if (amt < minStake) { + if (minStake > available) return; + amt = minStake; + } + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + + if (registry.paused()) { + bool okPaused = actor.createDeposit(setNonce, amt, h); + if (okPaused) pausedMutationSucceeded = true; + return; + } + + pendingDepositIdx = idx; + pendingDepositAmount = amt; + pendingRegistryBalanceBefore = token.balanceOf(address(registry)); + + bool ok = actor.createDeposit(setNonce, amt, h); + if (!ok) return; + + _checkOtherDigestsUnchanged(idx, otherA, otherB); + lastSetNonceByActor[idx] = setNonce; + pendingDepositCheck = true; + } + + function act_actor_addTokens(uint8 actorId, uint256 amount) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (bytes32 ov, uint256 bal, ) = _stakes(address(actor)); + if (ov == bytes32(0) || bal == 0) return; + + uint256 available = token.balanceOf(address(actor)); + if (available == 0) return; + uint256 amt = amount % (available + 1); + if (amt == 0) return; + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + + if (registry.paused()) { + bool okPaused = actor.addTokens(amt); + if (okPaused) pausedMutationSucceeded = true; + return; + } + + uint256 regBefore = token.balanceOf(address(registry)); + bool ok = actor.addTokens(amt); + if (!ok) return; + + _checkOtherDigestsUnchanged(idx, otherA, otherB); + if (token.balanceOf(address(registry)) != regBefore + amt) actionInvariantViolated = true; + } + + function act_actor_changeOverlay(uint8 actorId, bytes32 setNonce) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (bytes32 ov, uint256 bal, ) = _stakes(address(actor)); + if (ov == bytes32(0) || bal == 0) return; + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + bool ok = actor.changeOverlay(setNonce); + if (!ok) return; + + _checkOtherDigestsUnchanged(idx, otherA, otherB); + } + + function act_actor_increaseHeight(uint8 actorId, uint8 height) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (, uint256 bal, uint8 curH) = _stakes(address(actor)); + if (bal == 0) return; + + uint8 h = uint8(height % 16); + if (h <= curH) return; + + uint256 minForH = MIN_STAKE * (1 << h); + if (bal < minForH) return; + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + bool ok = actor.increaseHeight(h); + if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + } + + function act_actor_withdraw(uint8 actorId, uint256 amount) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (, uint256 bal, uint8 h) = _stakes(address(actor)); + if (bal == 0) return; + + uint256 minRemain = MIN_STAKE * (1 << h); + if (bal <= minRemain) return; + + uint256 maxWithdraw = bal - minRemain; + uint256 amt = amount % (maxWithdraw + 1); + if (amt == 0) return; + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + bool ok = actor.withdraw(amt); + if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + } + + function act_actor_exit(uint8 actorId) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (, uint256 bal, ) = _stakes(address(actor)); + if (bal == 0) return; + + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + bool ok = actor.exit(); + if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + } + + function act_actor_migrateStake(uint8 actorId) external { + _clearPendingChecks(); + + uint256 idx = uint256(actorId) % ACTOR_COUNT; + EchidnaStakingActor actor = actors[idx]; + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + + pendingMigrateIdx = idx; + pendingMigrateBalanceBefore = token.balanceOf(address(actor)); + (, uint256 bal, ) = _stakes(address(actor)); + pendingMigrateHadStake = bal > 0; + pendingMigrateRegistryBalBefore = token.balanceOf(address(registry)); + + bool ok = actor.migrateStake(); + if (!ok) return; + + if (!registry.paused()) migrateSucceededWhileUnpaused = true; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + pendingMigrateCheck = true; + + if (pendingMigrateHadStake) { + lastSetNonceByActor[idx] = bytes32(0); + } + } + + function act_admin_pause() external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + registry.pause(); + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_admin_unpause() external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + registry.unpause(); + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_admin_changeNetworkId(uint64 newNetworkId) external { + _clearPendingChecks(); + // Overlay derivation uses networkId; preview digests may change without a bug. + registry.changeNetworkId(newNetworkId); + trackedNetworkId = newNetworkId; + } + + function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { + _clearPendingChecks(); + + uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + address t = address(actors[idx]); + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + + bool ok = redistributor.tryFreezeDeposit(t, uint256(time)); + if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + } + + function act_redistributor_slash(uint8 targetActorId, uint256 amount) external { + _clearPendingChecks(); + + uint256 idx = uint256(targetActorId) % ACTOR_COUNT; + address t = address(actors[idx]); + (, uint256 balBefore, ) = _stakes(t); + (bytes32 otherA, bytes32 otherB) = _otherDigests(idx); + + bool ok = redistributor.trySlashDeposit(t, amount); + if (!ok) return; + _checkOtherDigestsUnchanged(idx, otherA, otherB); + + (, uint256 balAfter, ) = _stakes(t); + if (balAfter > balBefore) actionInvariantViolated = true; + } + + function act_actor_tryPause(uint8 actorId) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + bool ok = _actor(actorId).tryPause(); + if (ok) unauthorizedAdminCallSucceeded = true; + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_actor_tryUnpause(uint8 actorId) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + bool ok = _actor(actorId).tryUnpause(); + if (ok) unauthorizedAdminCallSucceeded = true; + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + bool ok = _actor(actorId).tryChangeNetworkId(newNetworkId); + if (ok) unauthorizedAdminCallSucceeded = true; + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + bool ok = _actor(actorId).tryFreezeDeposit(address(_actor(targetActorId)), uint256(time)); + if (ok) unauthorizedFreezeSlashSucceeded = true; + _checkDigestsUnchanged(d0, d1, d2); + } + + function act_actor_trySlash(uint8 actorId, uint8 targetActorId, uint256 amount) external { + _clearPendingChecks(); + bytes32 d0 = _stakeDigest(address(actors[0])); + bytes32 d1 = _stakeDigest(address(actors[1])); + bytes32 d2 = _stakeDigest(address(actors[2])); + bool ok = _actor(actorId).trySlashDeposit(address(_actor(targetActorId)), amount); + if (ok) unauthorizedFreezeSlashSucceeded = true; + _checkDigestsUnchanged(d0, d1, d2); + } + + // ----------------------------- + // Properties + // ----------------------------- + + function echidna_never_performed_forbidden_calls() external view returns (bool) { + return + !unauthorizedAdminCallSucceeded && + !unauthorizedFreezeSlashSucceeded && + !pausedMutationSucceeded && + !actionInvariantViolated; + } + + function echidna_migrate_never_succeeds_while_unpaused() external view returns (bool) { + return !migrateSucceededWhileUnpaused; + } + + function echidna_registry_balance_covers_previewed_balances() external view returns (bool) { + uint256 sum; + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (, uint256 bal, ) = _stakes(address(actors[i])); + sum += bal; + } + return token.balanceOf(address(registry)) >= sum; + } + + function echidna_last_createDeposit_increases_registry_balance() external view returns (bool) { + if (!pendingDepositCheck) return true; + return token.balanceOf(address(registry)) == pendingRegistryBalanceBefore + pendingDepositAmount; + } + + function echidna_frozen_accounts_have_zero_effective_stake() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + address a = address(actors[i]); + if (registry.freezeUntilBlock(a) >= block.number) { + if (registry.nodeEffectiveStake(a) != 0) return false; + } + } + return true; + } + + function echidna_empty_overlay_means_zero_balance_and_height() external view returns (bool) { + for (uint256 i = 0; i < ACTOR_COUNT; i++) { + (bytes32 ov, uint256 bal, uint8 h) = _stakes(address(actors[i])); + if (ov == bytes32(0)) { + if (bal != 0 || h != 0) return false; + } + } + return true; + } + + function echidna_last_migrate_refunds_when_stake_exists() external view returns (bool) { + if (!pendingMigrateCheck) return true; + if (!registry.paused()) return false; + + address a = address(actors[pendingMigrateIdx]); + (, uint256 balAfter, ) = _stakes(a); + if (balAfter != 0) return false; + + // migrateStake also refunds queued create/add amounts even when preview balance was zero. + return token.balanceOf(a) >= pendingMigrateBalanceBefore; + } + + // ----------------------------- + // Internal helpers + // ----------------------------- + + function _actor(uint8 actorId) internal view returns (EchidnaStakingActor) { + return actors[uint256(actorId) % ACTOR_COUNT]; + } + + function _stakes(address who) internal view returns (bytes32 ov, uint256 bal, uint8 h) { + StakeRegistry.Stake memory s = registry.stakes(who); + return (s.overlay, s.balance, s.height); + } + + function _stakeDigest(address who) internal view returns (bytes32) { + (bytes32 ov, uint256 bal, uint8 h) = _stakes(who); + return keccak256(abi.encodePacked(ov, bal, h, registry.freezeUntilBlock(who))); + } + + function _otherDigests(uint256 idx) internal view returns (bytes32 dA, bytes32 dB) { + if (idx == 0) { + dA = _stakeDigest(address(actors[1])); + dB = _stakeDigest(address(actors[2])); + } else if (idx == 1) { + dA = _stakeDigest(address(actors[0])); + dB = _stakeDigest(address(actors[2])); + } else { + dA = _stakeDigest(address(actors[0])); + dB = _stakeDigest(address(actors[1])); + } + } + + function _checkOtherDigestsUnchanged(uint256 idx, bytes32 dA, bytes32 dB) internal { + if (idx == 0) { + if (_stakeDigest(address(actors[1])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; + } else if (idx == 1) { + if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != dB) actionInvariantViolated = true; + } else { + if (_stakeDigest(address(actors[0])) != dA) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != dB) actionInvariantViolated = true; + } + } + + function _checkDigestsUnchanged(bytes32 d0, bytes32 d1, bytes32 d2) internal { + if (_stakeDigest(address(actors[0])) != d0) actionInvariantViolated = true; + if (_stakeDigest(address(actors[1])) != d1) actionInvariantViolated = true; + if (_stakeDigest(address(actors[2])) != d2) actionInvariantViolated = true; + } + + function _clearPendingChecks() internal { + pendingDepositCheck = false; + pendingMigrateCheck = false; + } + + function _reverse(uint64 input) internal pure returns (uint64 v) { + v = input; + v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); + v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); + v = (v >> 32) | (v << 32); + } +} diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 7ee68734..be57eec9 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -26,12 +26,14 @@ contract EchidnaSystemActor { token.approve(address(stamp), type(uint256).max); } - function callManageStake(bytes32 setNonce, uint256 addAmount, uint8 height) external returns (bool ok) { - (ok, ) = address(stake).call(abi.encodeWithSelector(stake.manageStake.selector, setNonce, addAmount, height)); + function callCreateDeposit(bytes32 setNonce, uint256 amount, uint8 height) external returns (bool ok) { + (ok, ) = address(stake).call( + abi.encodeWithSelector(stake.createDeposit.selector, setNonce, amount, height) + ); } - function callWithdrawFromStake() external returns (bool ok) { - (ok, ) = address(stake).call(abi.encodeWithSelector(stake.withdrawFromStake.selector)); + function callApplyUpdates() external returns (bool ok) { + (ok, ) = address(stake).call(abi.encodeWithSelector(stake.applyUpdates.selector, address(this))); } function callCreateBatch( @@ -116,8 +118,8 @@ contract EchidnaSystemHarness { // Wire roles: the oracle must be able to call PostageStamp.setPrice. stamp.grantRole(stamp.PRICE_ORACLE_ROLE(), address(oracle)); - // Deploy stake registry (uses oracle.currentPrice()). - stake = new StakingMod.StakeRegistry(address(token), 1, address(oracle)); + // Deploy stake registry (queued-update model; wait rounds kept small for fuzzing). + stake = new StakingMod.StakeRegistry(address(token), 1, 2, 2, 2); // Deploy redistribution (uses stake/stamp/oracle). Exposed wrapper adds length helpers for harness scans. redist = RedistMod.Redistribution( @@ -134,8 +136,8 @@ contract EchidnaSystemHarness { bootstrapNonce[i] = keccak256(abi.encodePacked("bootstrap", i)); // Mint enough for both staking and postage operations. token.mint(address(actors[i]), 1e28); - // Bootstrap a stake early so that after 2 rounds pass, commit() can succeed. - actors[i].callManageStake(bootstrapNonce[i], 1e24, uint8(BOOTSTRAP_HEIGHT)); + // Bootstrap a queued deposit; applyUpdates + round delays activate it during fuzzing. + actors[i].callCreateDeposit(bootstrapNonce[i], 1e24, uint8(BOOTSTRAP_HEIGHT)); } // Create a dedicated price updater (actor[0]) for adjustPrice. @@ -150,16 +152,16 @@ contract EchidnaSystemHarness { /// helping the fuzzer walk through round phases. function act_tick() external {} - function act_actor_manageStake(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { + function act_actor_createDeposit(uint8 actorId, bytes32 setNonce, uint256 addAmount, uint8 height) external { EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; uint8 h = uint8(height % 32); uint256 amt = _boundStakeAdd(addAmount); - a.callManageStake(setNonce, amt, h); + a.callCreateDeposit(setNonce, amt, h); } - function act_actor_withdrawSurplus(uint8 actorId) external { + function act_actor_applyStakeUpdates(uint8 actorId) external { EchidnaSystemActor a = actors[uint256(actorId) % ACTOR_COUNT]; - a.callWithdrawFromStake(); + a.callApplyUpdates(); } function act_actor_createBatch( @@ -228,10 +230,8 @@ contract EchidnaSystemHarness { uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaSystemActor a = actors[idx]; - // Must have staked at least 2 rounds prior. - uint256 lastUpdated = stake.lastUpdatedBlockNumberOfAddress(address(a)); - if (lastUpdated == 0) return; - if (lastUpdated >= block.number - 2 * 152) return; + // Must have active effective stake (deposit applied and not frozen). + if (stake.nodeEffectiveStake(address(a)) == 0) return; // Use the actor's current staking height as the reveal depth (depthResponsibility = 0 => proximity always passes). uint8 height = stake.heightOfAddress(address(a)); From 401bfd38ca6e7b76990b50806a9eb1998b7b217c Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 13:51:34 +0200 Subject: [PATCH 45/58] style(echidna): format staking harnesses with Prettier --- src/echidna/EchidnaStakingHarness.sol | 8 +------- src/echidna/EchidnaSystemHarness.sol | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/echidna/EchidnaStakingHarness.sol b/src/echidna/EchidnaStakingHarness.sol index 178a1378..837e09d4 100644 --- a/src/echidna/EchidnaStakingHarness.sol +++ b/src/echidna/EchidnaStakingHarness.sol @@ -106,13 +106,7 @@ contract EchidnaStakingHarness { token = new TestToken("TestToken", "TT", initialSupply); trackedNetworkId = 10; - registry = new StakeRegistry( - address(token), - trackedNetworkId, - WAIT_BASE, - WAIT_OVERLAY, - WAIT_WITHDRAWAL - ); + registry = new StakeRegistry(address(token), trackedNetworkId, WAIT_BASE, WAIT_OVERLAY, WAIT_WITHDRAWAL); for (uint256 i = 0; i < ACTOR_COUNT; i++) { actors[i] = new EchidnaStakingActor(token, registry); diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index be57eec9..62107ad5 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -27,9 +27,7 @@ contract EchidnaSystemActor { } function callCreateDeposit(bytes32 setNonce, uint256 amount, uint8 height) external returns (bool ok) { - (ok, ) = address(stake).call( - abi.encodeWithSelector(stake.createDeposit.selector, setNonce, amount, height) - ); + (ok, ) = address(stake).call(abi.encodeWithSelector(stake.createDeposit.selector, setNonce, amount, height)); } function callApplyUpdates() external returns (bool ok) { From 1413ca2ceadb91c2798ed1624a1fc2a35599a07f Mon Sep 17 00:00:00 2001 From: Cardinal Date: Fri, 22 May 2026 15:25:35 +0200 Subject: [PATCH 46/58] refactor(staking): nest UpdateQueue in Account Group queue items, head, and closed flag in UpdateQueue. Remove importFreezeUntilBlocks and its tests. Refs #309 --- src/Staking.sol | 143 +++++++++++++++++-------------------------- test/Staking.test.ts | 63 ------------------- 2 files changed, 55 insertions(+), 151 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 864c7a4b..0b8e7890 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -10,8 +10,8 @@ import "@openzeppelin/contracts/security/Pausable.sol"; * @dev Allows users to stake tokens in order to be eligible for the Redistribution Schelling co-ordination game. * Stakes are frozen or slashed by the Redistribution contract in response to violations of the * protocol. - * @dev Freeze penalties are stored per account (`freezeUntilBlock`); they are not cleared by exit, - * migration, or stake deletion. A new deposit after exit still cannot participate until the freeze ends. + * @dev Freeze penalties are stored per account (`freezeUntilBlock`); they are not cleared by exit + * or stake deletion. A new deposit after exit still cannot participate until the freeze ends. */ contract StakeRegistry is AccessControl, Pausable { @@ -58,14 +58,18 @@ contract StakeRegistry is AccessControl, Pausable { uint8 height; } + struct UpdateQueue { + ScheduledUpdate[] items; + uint64 head; + bool closed; + } + /// @dev Per-account stake, freeze penalty, and update queue share one lifecycle bucket; never `delete` the whole struct — use `_clearStake` / `_clearQueue` so `freezeUntilBlock` survives. struct Account { Stake stake; - /// @notice End block of the protocol freeze (exclusive: unfrozen when `block.number` > this value). Persists across exit and migration. + /// @notice End block of the protocol freeze (exclusive: unfrozen when `block.number` > this value). Persists across exit and stake deletion. uint256 freezeUntilBlock; - ScheduledUpdate[] updateQueue; - uint64 queueHead; - bool queueClosed; + UpdateQueue queue; } mapping(address => Account) private _accounts; @@ -131,9 +135,6 @@ contract StakeRegistry is AccessControl, Pausable { error StakingHeightTooLarge(uint8 height, uint8 maxHeight); /// @notice `changeOverlay` was called with a nonce that produces the current overlay. error OverlayUnchanged(); - /// @notice Parallel arrays passed to a batch admin helper have different lengths. - error ArrayLengthMismatch(); - constructor( address _bzzToken, uint64 _networkId, @@ -168,7 +169,7 @@ contract StakeRegistry is AccessControl, Pausable { uint256 _amount, uint8 _height ) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (_hasCommittedStake(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); uint256 minStake = _minimumStakeForHeight(_height); @@ -195,7 +196,7 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ function addTokens(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -212,7 +213,7 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Reverts with `OverlayUnchanged` if the derived overlay equals the current one (no sentinel return value). */ function changeOverlay(bytes32 _setNonce) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -230,7 +231,7 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. */ function increaseHeight(uint8 _height) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); @@ -250,7 +251,7 @@ contract StakeRegistry is AccessControl, Pausable { */ function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); @@ -271,12 +272,12 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ function exit() external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queueClosed) revert QueueClosed(); + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); - _accounts[msg.sender].queueClosed = true; + _accounts[msg.sender].queue.closed = true; emit WithdrawalQueued(msg.sender, effectiveFromRound, plannedStake.balance); } @@ -292,13 +293,12 @@ contract StakeRegistry is AccessControl, Pausable { */ function applyUpdates(address _owner) public { _applyReadyUpdates(_owner); - Account storage account = _accounts[_owner]; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; if ( - head < queue.length && - queue[head].effectiveFromRound <= currentRound() && - _queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0) + head < queue.items.length && + queue.items[head].effectiveFromRound <= currentRound() && + _queuedWithdrawalExecutionFrozen(_owner, queue.items[head].kind, 0) ) { revert FrozenWithdrawal(); } @@ -313,11 +313,11 @@ contract StakeRegistry is AccessControl, Pausable { Account storage account = _accounts[msg.sender]; uint256 payout = account.stake.balance; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; - for (uint256 i = head; i < queue.length; ) { - ScheduledUpdate storage scheduled = queue[i]; + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; if (scheduled.kind == UpdateKind.CreateDeposit || scheduled.kind == UpdateKind.AddTokens) { payout += scheduled.amount; } @@ -336,40 +336,12 @@ contract StakeRegistry is AccessControl, Pausable { } } - /** - * @notice Seeds `freezeUntilBlock` on a successor registry during contract migration. - * @param accounts Accounts whose freeze deadlines should be carried over. - * @param untilBlocks Matching `freezeUntilBlock` values from the predecessor contract. - * @dev Only extends each account's freeze (monotonic, same rule as `freezeDeposit`). Intended for - * admin migration tooling after nodes call `migrateStake` on the old contract and before they - * restake here; stake and queue state are not imported. - */ - function importFreezeUntilBlocks( - address[] calldata accounts, - uint256[] calldata untilBlocks - ) external onlyRole(DEFAULT_ADMIN_ROLE) { - if (accounts.length != untilBlocks.length) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < accounts.length; ) { - address account = accounts[i]; - uint256 until = untilBlocks[i]; - if (_accounts[account].freezeUntilBlock < until) { - _accounts[account].freezeUntilBlock = until; - emit AccountFreezeExtended(account, _accounts[account].freezeUntilBlock); - } - - unchecked { - ++i; - } - } - } - /** * @notice Extends the account freeze and blocks queued withdrawals while the freeze lasts. * @param _owner The staker to freeze. * @param _time The freeze duration in blocks from `block.number`. * @dev If an existing freeze ends later than `block.number + _time`, it is kept (monotonic). The - * deadline is stored per account and survives exit, `migrateStake`, and stake deletion. + * deadline is stored per account and survives exit and stake deletion. */ function freezeDeposit(address _owner, uint256 _time) external whenNotPaused { if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); @@ -461,10 +433,7 @@ contract StakeRegistry is AccessControl, Pausable { } function _clearQueue(address _owner) internal { - Account storage account = _accounts[_owner]; - delete account.updateQueue; - account.queueHead = 0; - account.queueClosed = false; + delete _accounts[_owner].queue; } function _clearStakeAndQueue(address _owner) internal { @@ -473,26 +442,25 @@ contract StakeRegistry is AccessControl, Pausable { } function _applyReadyUpdates(address _owner) internal { - Account storage account = _accounts[_owner]; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; uint64 roundNumber = currentRound(); - while (head < queue.length && queue[head].effectiveFromRound <= roundNumber) { + while (head < queue.items.length && queue.items[head].effectiveFromRound <= roundNumber) { // Exit the loop (do not skip to the next item): leave the due withdrawal/exit at `head` - // unapplied so FIFO is preserved; `queueHead` is updated below for retry on a later call. - if (_queuedWithdrawalExecutionFrozen(_owner, queue[head].kind, 0)) break; - _applyStoredUpdate(_owner, queue[head]); - delete queue[head]; + // unapplied so FIFO is preserved; `queue.head` is updated below for retry on a later call. + if (_queuedWithdrawalExecutionFrozen(_owner, queue.items[head].kind, 0)) break; + _applyStoredUpdate(_owner, queue.items[head]); + delete queue.items[head]; unchecked { ++head; } } - if (head == queue.length) { + if (head == queue.items.length) { _clearQueue(_owner); } else { - account.queueHead = uint64(head); + queue.head = uint64(head); } } @@ -556,7 +524,7 @@ contract StakeRegistry is AccessControl, Pausable { uint64 lastRound = _lastScheduledRound(_owner); effectiveFromRound = candidateRound > lastRound ? candidateRound : lastRound; - _accounts[_owner].updateQueue.push( + _accounts[_owner].queue.items.push( ScheduledUpdate({ kind: _kind, effectiveFromRound: effectiveFromRound, @@ -579,13 +547,12 @@ contract StakeRegistry is AccessControl, Pausable { * @dev This preserves queue order while preventing later withdrawals from overpaying the owner. */ function _reconcileQueuedWithdrawals(address _owner) internal { - Account storage account = _accounts[_owner]; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; - Stake memory preview = account.stake; + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; + Stake memory preview = _accounts[_owner].stake; - for (uint256 i = head; i < queue.length; ) { - ScheduledUpdate storage scheduled = queue[i]; + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; if (scheduled.kind == UpdateKind.WithdrawTokens && _hasCommittedStake(preview)) { if (scheduled.amount > preview.balance) { @@ -723,12 +690,12 @@ contract StakeRegistry is AccessControl, Pausable { Account storage account = _accounts[_owner]; preview = account.stake; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.length; ) { - ScheduledUpdate storage scheduled = queue[i]; + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { break; } @@ -751,12 +718,12 @@ contract StakeRegistry is AccessControl, Pausable { Account storage account = _accounts[_owner]; preview = account.stake; - ScheduledUpdate[] storage queue = account.updateQueue; - uint256 head = account.queueHead; + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; uint64 targetRound = currentRound() + _lookahead; - for (uint256 i = head; i < queue.length; ) { - ScheduledUpdate storage scheduled = queue[i]; + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; if (scheduled.effectiveFromRound > targetRound) { break; } @@ -829,19 +796,19 @@ contract StakeRegistry is AccessControl, Pausable { * @notice Returns the effective round of the last queued update. */ function _lastScheduledRound(address _owner) internal view returns (uint64) { - ScheduledUpdate[] storage queue = _accounts[_owner].updateQueue; + ScheduledUpdate[] storage items = _accounts[_owner].queue.items; if (_queueLength(_owner) == 0) { return 0; } - return queue[queue.length - 1].effectiveFromRound; + return items[items.length - 1].effectiveFromRound; } /** * @notice Returns the number of pending queued updates. */ function _queueLength(address _owner) internal view returns (uint256) { - Account storage account = _accounts[_owner]; - return account.updateQueue.length - account.queueHead; + UpdateQueue storage queue = _accounts[_owner].queue; + return queue.items.length - queue.head; } /** diff --git a/test/Staking.test.ts b/test/Staking.test.ts index c2cc32b2..9399277f 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -666,69 +666,6 @@ describe('Staking', function () { expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); }); - describe('contract migration freeze import', function () { - it('should import freezeUntilBlock and block effective stake after restake on successor registry', async function () { - const longFreezeTime = roundLength * 3; - const Factory = await ethers.getContractFactory('StakeRegistry'); - const srOld = await Factory.deploy(token.address, await stakeRegistry.networkId(), 2, 10, 2); - await srOld.deployed(); - await srOld.grantRole(await srOld.REDISTRIBUTOR_ROLE(), redistributor); - - const srOldStaker = srOld.connect(await getSignerFor(staker_0)); - await mintAndApprove(staker_0, srOld.address, stakeAmount_0); - await srOldStaker.createDeposit(nonce_0, stakeAmount_0, height_0); - await advanceRounds(); - await srOld.applyUpdates(staker_0); - - const srOldRedis = srOld.connect(await getSignerFor(redistributor)); - await srOldRedis.freezeDeposit(staker_0, longFreezeTime); - const importedUntil = await srOld.freezeUntilBlock(staker_0); - - const srNew = await Factory.deploy(token.address, await stakeRegistry.networkId(), 2, 10, 2); - await srNew.deployed(); - const srNewAdmin = srNew.connect(await getSignerFor(deployer)); - await expect(srNewAdmin.importFreezeUntilBlocks([staker_0], [importedUntil])) - .to.emit(srNew, 'AccountFreezeExtended') - .withArgs(staker_0, importedUntil); - expect(await srNew.freezeUntilBlock(staker_0)).to.eq(importedUntil); - - const srNewStaker = srNew.connect(await getSignerFor(staker_0)); - await mintAndApprove(staker_0, srNew.address, stakeAmount_0); - await srNewStaker.createDeposit(nonce_1_n_25, stakeAmount_0, height_0); - await advanceRounds(); - await srNew.applyUpdates(staker_0); - - expect((await srNew.stakes(staker_0)).balance).to.eq(stakeAmount_0); - expect(await srNew.nodeEffectiveStake(staker_0)).to.eq(0); - - await mineNBlocks(longFreezeTime + 1); - expect(await srNew.nodeEffectiveStake(staker_0)).to.eq(stakeAmount_0); - }); - - it('should reject importFreezeUntilBlocks from non-admin and on array length mismatch', async function () { - const srAdmin = stakeRegistry.connect(await getSignerFor(deployer)); - const srStaker = stakeRegistry.connect(await getSignerFor(staker_0)); - const until = (await ethers.provider.getBlock('latest'))!.number + 100; - - await expect(srStaker.importFreezeUntilBlocks([staker_0], [until])).to.be.revertedWith('AccessControl'); - await expect(srAdmin.importFreezeUntilBlocks([staker_0], [until, until + 1])).to.be.revertedWith( - 'ArrayLengthMismatch()' - ); - }); - - it('should not shorten an existing freeze when importing a lower deadline', async function () { - const srAdmin = stakeRegistry.connect(await getSignerFor(deployer)); - const laterUntil = (await ethers.provider.getBlock('latest'))!.number + 500; - const earlierUntil = laterUntil - 200; - - await srAdmin.importFreezeUntilBlocks([staker_0], [laterUntil]); - expect(await stakeRegistry.freezeUntilBlock(staker_0)).to.eq(laterUntil); - - await srAdmin.importFreezeUntilBlocks([staker_0], [earlierUntil]); - expect(await stakeRegistry.freezeUntilBlock(staker_0)).to.eq(laterUntil); - }); - }); - it('should not allow staking while paused and should allow it again after unpause', async function () { const srStaker0 = await ethers.getContract('StakeRegistry', staker_0); const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); From ef2c940a071b6d2a8cdec82a7842ae44c336b542 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:01:51 +0200 Subject: [PATCH 47/58] fix description --- src/Staking.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 0b8e7890..bf46f9d2 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -10,8 +10,10 @@ import "@openzeppelin/contracts/security/Pausable.sol"; * @dev Allows users to stake tokens in order to be eligible for the Redistribution Schelling co-ordination game. * Stakes are frozen or slashed by the Redistribution contract in response to violations of the * protocol. - * @dev Freeze penalties are stored per account (`freezeUntilBlock`); they are not cleared by exit - * or stake deletion. A new deposit after exit still cannot participate until the freeze ends. + * @dev Freeze is a per-address penalty (`freezeUntilBlock`), not a fund lock: while active, + * `nodeEffectiveStake` is zero and due withdrawal/exit payouts are blocked; enqueueing deposits + * and other updates is still allowed. The deadline survives exit and stake deletion, redeposit + * on the same address does not restore participation until it passes. */ contract StakeRegistry is AccessControl, Pausable { From 68c29dfff0e5d9f8efe788414f992a4361bf69fc Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:05:30 +0200 Subject: [PATCH 48/58] Introducing constants to use accross the contracts --- src/PriceOracle.sol | 6 ++---- src/Redistribution.sol | 3 ++- src/Staking.sol | 3 ++- src/Util/Constants.sol | 10 ++++++++++ src/echidna/EchidnaRedistributionClaimHarness.sol | 3 ++- src/echidna/EchidnaRedistributionHarness.sol | 5 +++-- src/echidna/EchidnaSystemHarness.sol | 3 ++- test/PriceOracle.test.ts | 4 ++-- test/Redistribution.test.ts | 3 ++- test/Staking.test.ts | 4 ++-- test/util/tools.ts | 1 + 11 files changed, 30 insertions(+), 15 deletions(-) create mode 100644 src/Util/Constants.sol diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index 5675f4f9..0f6cc1b5 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/AccessControl.sol"; import "./interface/IPostageStamp.sol"; +import "./Util/Constants.sol"; /** * @title PriceOracle contract. @@ -38,9 +39,6 @@ contract PriceOracle is AccessControl { // Role allowed to update price bytes32 public immutable PRICE_UPDATER_ROLE; - // The length of a round in blocks. - uint8 private constant ROUND_LENGTH = 152; - // ----------------------------- Events ------------------------------ /** @@ -189,7 +187,7 @@ contract PriceOracle is AccessControl { // We downcasted to uint64 as uint64 has 18,446,744,073,709,551,616 places // as each round is 152 x 5 = 760, each day has around 113 rounds which is 41245 in a year // it results 4.4724801e+14 years to run this game - return uint64(block.number / uint256(ROUND_LENGTH)); + return uint64(block.number / Constants.ROUND_LENGTH); } /** diff --git a/src/Redistribution.sol b/src/Redistribution.sol index d2062965..08af9054 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -5,6 +5,7 @@ import "@openzeppelin/contracts/security/Pausable.sol"; import "./Util/TransformedChunkProof.sol"; import "./Util/ChunkProof.sol"; import "./Util/Signatures.sol"; +import "./Util/Constants.sol"; import "./interface/IPostageStamp.sol"; interface IPriceOracle { @@ -151,7 +152,7 @@ contract Redistribution is AccessControl, Pausable { Reveal public winner; // The length of a round in blocks. - uint256 private constant ROUND_LENGTH = 152; + uint256 private constant ROUND_LENGTH = Constants.ROUND_LENGTH; // Maximum value of the keccack256 hash. bytes32 private constant MAX_H = 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff; diff --git a/src/Staking.sol b/src/Staking.sol index bf46f9d2..fd476237 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.19; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/security/Pausable.sol"; +import "./Util/Constants.sol"; /** * @title Staking contract for the Swarm storage incentives @@ -19,7 +20,7 @@ import "@openzeppelin/contracts/security/Pausable.sol"; contract StakeRegistry is AccessControl, Pausable { // ----------------------------- State variables ------------------------------ - uint256 public constant ROUND_LENGTH = 152; + uint256 public constant ROUND_LENGTH = Constants.ROUND_LENGTH; /// @notice Minimum BZZ base unit at staking height 0 (`MIN_STAKE * 2**height` for higher heights). uint256 public constant MIN_STAKE = 100000000000000000; uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; diff --git a/src/Util/Constants.sol b/src/Util/Constants.sol new file mode 100644 index 00000000..a745584e --- /dev/null +++ b/src/Util/Constants.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +/** + * @title Protocol-wide constants for Swarm storage incentives. + */ +library Constants { + /// @notice Length of a round in blocks (~12.7 minutes at 5s/block). + uint256 internal constant ROUND_LENGTH = 152; +} diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index 26a1e3cd..cd7aa564 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; +import "../Util/Constants.sol"; import "../TestToken.sol"; import "../interface/IPostageStamp.sol"; import "./EchidnaMocks.sol"; @@ -132,7 +133,7 @@ contract EchidnaRedistributionClaimActor { /// @notice Harness to fuzz commit→reveal→claim-withdraw end-to-end (without proof verification). contract EchidnaRedistributionClaimHarness { uint256 internal constant ACTOR_COUNT = 3; - uint256 internal constant ROUND_LENGTH = 152; + uint256 internal constant ROUND_LENGTH = Constants.ROUND_LENGTH; TestToken internal immutable token; EchidnaStakeRegistryMock internal immutable stakeMock; diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 0631e744..06e79778 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.19; import "../Redistribution.sol"; +import "../Util/Constants.sol"; import "../interface/IPostageStamp.sol"; import "./RedistributionExposed.sol"; import "./EchidnaMocks.sol"; @@ -257,7 +258,7 @@ contract EchidnaRedistributionHarness { if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the "phase last block" restriction in commit phase. - if (block.number % 152 == (152 / 4) - 1) return; + if (block.number % Constants.ROUND_LENGTH == (Constants.ROUND_LENGTH / 4) - 1) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaRedistributionActor a = actors[idx]; @@ -625,7 +626,7 @@ contract EchidnaRedistributionHarness { } function _backdateLastUpdated() internal view returns (uint256) { - uint256 twoRounds = 2 * 152; + uint256 twoRounds = 2 * Constants.ROUND_LENGTH; if (block.number > twoRounds + 1) return block.number - twoRounds - 1; return 1; } diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 62107ad5..89ea3a1c 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -6,6 +6,7 @@ import "../PostageStamp.sol"; import "../PriceOracle.sol"; import "../Redistribution.sol" as RedistMod; import "../Staking.sol" as StakingMod; +import "../Util/Constants.sol"; import "./RedistributionExposed.sol"; contract EchidnaSystemActor { @@ -223,7 +224,7 @@ contract EchidnaSystemHarness { if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the commit-phase last-block restriction. - if (block.number % 152 == (152 / 4) - 1) return; + if (block.number % Constants.ROUND_LENGTH == (Constants.ROUND_LENGTH / 4) - 1) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaSystemActor a = actors[idx]; diff --git a/test/PriceOracle.test.ts b/test/PriceOracle.test.ts index 985b4abc..ab21a5dd 100644 --- a/test/PriceOracle.test.ts +++ b/test/PriceOracle.test.ts @@ -1,7 +1,7 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts, getUnnamedAccounts } from 'hardhat'; import { Contract } from 'ethers'; -import { mineNBlocks, getBlockNumber } from './util/tools'; +import { mineNBlocks, getBlockNumber, ROUND_LENGTH } from './util/tools'; // Named accounts used by tests. let updater: string; @@ -17,7 +17,7 @@ before(async function () { }); const changeRate = [1049417, 1049206, 1048996, 1048786, 1048576, 1048366, 1048156, 1047946, 1047736]; -const roundLength = 152; +const roundLength = ROUND_LENGTH; const errors = { manual: { diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index 6b66aef3..923335e0 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -15,6 +15,7 @@ import { getWalletOfFdpPlayQueen, WITNESS_COUNT, skippedRoundsIncrease, + ROUND_LENGTH, } from './util/tools'; import { proximity } from './util/tools'; import { node5_proof1, node5_soc_proof1 } from './claim-proofs'; @@ -36,7 +37,7 @@ import { constructPostageStamp } from './util/postage'; const { read, execute } = deployments; const phaseLength = 38; -const roundLength = 152; +const roundLength = ROUND_LENGTH; const increaseRate = [1049417, 1049206, 1048996, 1048786, 1048576, 1048366, 1048156, 1047946, 1047736]; diff --git a/test/Staking.test.ts b/test/Staking.test.ts index 9399277f..07305996 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -1,7 +1,7 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts } from 'hardhat'; import { BigNumber, Contract, ContractTransaction, Event } from 'ethers'; -import { mineNBlocks } from './util/tools'; +import { mineNBlocks, ROUND_LENGTH } from './util/tools'; const { read, execute } = deployments; @@ -12,7 +12,7 @@ let staker_0: string; let staker_1: string; /** Blocks per staking round; overwritten from `StakeRegistry.ROUND_LENGTH()` after fixture load. */ -let roundLength = 152; +let roundLength = ROUND_LENGTH; const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000'; const freezeTime = 3; diff --git a/test/util/tools.ts b/test/util/tools.ts index c413e240..e5af244c 100644 --- a/test/util/tools.ts +++ b/test/util/tools.ts @@ -9,6 +9,7 @@ export const equalBytes = BmtUtils.equalBytes; export const ZERO_32_BYTES = '0x' + '0'.repeat(64); export const PHASE_LENGTH = 38; +/** Must match `Constants.ROUND_LENGTH` in `src/Util/Constants.sol`. */ export const ROUND_LENGTH = 152; export const WITNESS_COUNT = 16; export const SEGMENT_COUNT_IN_CHUNK = 128; From 62f9737d2331e2405215f0ebfe55e5294b90020b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:18:40 +0200 Subject: [PATCH 49/58] add other values to constants as well, so we have it all in one place and reused. --- src/Redistribution.sol | 11 ++++++----- src/Staking.sol | 4 ++-- src/Util/ChunkProof.sol | 8 ++++---- src/Util/Constants.sol | 15 +++++++++++++++ src/Util/TransformedChunkProof.sol | 8 ++++---- src/echidna/EchidnaRedistributionClaimHarness.sol | 3 ++- src/echidna/EchidnaRedistributionHarness.sol | 2 +- src/echidna/EchidnaStakingHarness.sol | 3 ++- src/echidna/EchidnaSystemHarness.sol | 2 +- test/Redistribution.test.ts | 3 ++- test/util/tools.ts | 7 ++++--- 11 files changed, 43 insertions(+), 23 deletions(-) diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 08af9054..11336667 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -153,6 +153,7 @@ contract Redistribution is AccessControl, Pausable { // The length of a round in blocks. uint256 private constant ROUND_LENGTH = Constants.ROUND_LENGTH; + uint256 private constant PHASE_LENGTH = Constants.PHASE_LENGTH; // Maximum value of the keccack256 hash. bytes32 private constant MAX_H = 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff; @@ -304,7 +305,7 @@ contract Redistribution is AccessControl, Pausable { revert NotCommitPhase(); } - if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) { + if (block.number % ROUND_LENGTH == PHASE_LENGTH - 1) { revert PhaseLastBlock(); } @@ -563,7 +564,7 @@ contract Redistribution is AccessControl, Pausable { } function inclusionFunction(ChunkInclusionProof calldata entryProof, uint256 indexInRC) internal { - uint256 randomChunkSegmentIndex = uint256(seed) % 128; + uint256 randomChunkSegmentIndex = uint256(seed) % Constants.SEGMENTS_PER_CHUNK; bytes32 calculatedTransformedAddr = TransformedBMTChunk.transformedChunkAddressFromInclusionProof( entryProof.proofSegments3, entryProof.proveSegment2, @@ -798,7 +799,7 @@ contract Redistribution is AccessControl, Pausable { * @notice Returns true if current block is during commit phase. */ function currentPhaseCommit() public view returns (bool) { - if (block.number % ROUND_LENGTH < ROUND_LENGTH / 4) { + if (block.number % ROUND_LENGTH < PHASE_LENGTH) { return true; } return false; @@ -890,7 +891,7 @@ contract Redistribution is AccessControl, Pausable { */ function currentPhaseReveal() public view returns (bool) { uint256 number = block.number % ROUND_LENGTH; - if (number >= ROUND_LENGTH / 4 && number < ROUND_LENGTH / 2) { + if (number >= PHASE_LENGTH && number < PHASE_LENGTH * 2) { return true; } return false; @@ -917,7 +918,7 @@ contract Redistribution is AccessControl, Pausable { * @notice Returns true if current block is during claim phase. */ function currentPhaseClaim() public view returns (bool) { - if (block.number % ROUND_LENGTH >= ROUND_LENGTH / 2) { + if (block.number % ROUND_LENGTH >= PHASE_LENGTH * 2) { return true; } return false; diff --git a/src/Staking.sol b/src/Staking.sol index fd476237..d14ff545 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -21,8 +21,8 @@ contract StakeRegistry is AccessControl, Pausable { // ----------------------------- State variables ------------------------------ uint256 public constant ROUND_LENGTH = Constants.ROUND_LENGTH; - /// @notice Minimum BZZ base unit at staking height 0 (`MIN_STAKE * 2**height` for higher heights). - uint256 public constant MIN_STAKE = 100000000000000000; + /// @notice Minimum BZZ at staking height 0 (`MIN_STAKE * 2**height` for higher heights). + uint256 public constant MIN_STAKE = Constants.MIN_STAKE; uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10; /// @notice Maximum staking height; prevents `2**height` overflow in `MIN_STAKE * (2 ** height)`. uint8 public constant MAX_STAKING_HEIGHT = 128; diff --git a/src/Util/ChunkProof.sol b/src/Util/ChunkProof.sol index a2795d28..eb4af524 100644 --- a/src/Util/ChunkProof.sol +++ b/src/Util/ChunkProof.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; +import "./Constants.sol"; + library BMTChunk { - // max chunk payload size - uint256 public constant MAX_CHUNK_PAYLOAD_SIZE = 4096; - // segment byte size - uint256 public constant SEGMENT_SIZE = 32; + uint256 public constant MAX_CHUNK_PAYLOAD_SIZE = Constants.MAX_CHUNK_PAYLOAD_SIZE; + uint256 public constant SEGMENT_SIZE = Constants.SEGMENT_SIZE; /** * @notice Changes the endianness of a uint64. diff --git a/src/Util/Constants.sol b/src/Util/Constants.sol index a745584e..f4f2f6fb 100644 --- a/src/Util/Constants.sol +++ b/src/Util/Constants.sol @@ -7,4 +7,19 @@ pragma solidity ^0.8.19; library Constants { /// @notice Length of a round in blocks (~12.7 minutes at 5s/block). uint256 internal constant ROUND_LENGTH = 152; + + /// @notice Length of a single round phase in blocks (commit, reveal, or claim). + uint256 internal constant PHASE_LENGTH = ROUND_LENGTH / 4; + + /// @notice Minimum BZZ at staking height 0 (`MIN_STAKE * 2**height` for higher heights). + uint256 internal constant MIN_STAKE = 10 * 1e16; + + /// @notice Maximum chunk payload size in bytes. + uint256 internal constant MAX_CHUNK_PAYLOAD_SIZE = 4096; + + /// @notice Segment byte size in BMT chunk proofs. + uint256 internal constant SEGMENT_SIZE = 32; + + /// @notice Number of segments in a max-size chunk (`MAX_CHUNK_PAYLOAD_SIZE / SEGMENT_SIZE`). + uint256 internal constant SEGMENTS_PER_CHUNK = MAX_CHUNK_PAYLOAD_SIZE / SEGMENT_SIZE; } diff --git a/src/Util/TransformedChunkProof.sol b/src/Util/TransformedChunkProof.sol index 51acdabf..474fea67 100644 --- a/src/Util/TransformedChunkProof.sol +++ b/src/Util/TransformedChunkProof.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; +import "./Constants.sol"; + library TransformedBMTChunk { - // max chunk payload size - uint256 public constant MAX_CHUNK_PAYLOAD_SIZE = 4096; - // segment byte size - uint256 public constant SEGMENT_SIZE = 32; + uint256 public constant MAX_CHUNK_PAYLOAD_SIZE = Constants.MAX_CHUNK_PAYLOAD_SIZE; + uint256 public constant SEGMENT_SIZE = Constants.SEGMENT_SIZE; /** Calculates the root hash from the provided inclusion proof segments and its corresponding segment index * @param _proofSegments Proof segments. diff --git a/src/echidna/EchidnaRedistributionClaimHarness.sol b/src/echidna/EchidnaRedistributionClaimHarness.sol index cd7aa564..e8127e66 100644 --- a/src/echidna/EchidnaRedistributionClaimHarness.sol +++ b/src/echidna/EchidnaRedistributionClaimHarness.sol @@ -134,6 +134,7 @@ contract EchidnaRedistributionClaimActor { contract EchidnaRedistributionClaimHarness { uint256 internal constant ACTOR_COUNT = 3; uint256 internal constant ROUND_LENGTH = Constants.ROUND_LENGTH; + uint256 internal constant PHASE_LENGTH = Constants.PHASE_LENGTH; TestToken internal immutable token; EchidnaStakeRegistryMock internal immutable stakeMock; @@ -220,7 +221,7 @@ contract EchidnaRedistributionClaimHarness { function act_happyCommit(uint8 actorId, bytes32 hash, bytes32 nonce) external { _clearClaimPending(); if (!redist.currentPhaseCommit()) return; - if (block.number % ROUND_LENGTH == (ROUND_LENGTH / 4) - 1) return; + if (block.number % ROUND_LENGTH == PHASE_LENGTH - 1) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaRedistributionClaimActor a = actors[idx]; diff --git a/src/echidna/EchidnaRedistributionHarness.sol b/src/echidna/EchidnaRedistributionHarness.sol index 06e79778..f34d46e1 100644 --- a/src/echidna/EchidnaRedistributionHarness.sol +++ b/src/echidna/EchidnaRedistributionHarness.sol @@ -258,7 +258,7 @@ contract EchidnaRedistributionHarness { if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the "phase last block" restriction in commit phase. - if (block.number % Constants.ROUND_LENGTH == (Constants.ROUND_LENGTH / 4) - 1) return; + if (block.number % Constants.ROUND_LENGTH == Constants.PHASE_LENGTH - 1) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaRedistributionActor a = actors[idx]; diff --git a/src/echidna/EchidnaStakingHarness.sol b/src/echidna/EchidnaStakingHarness.sol index 837e09d4..ec8ae2d7 100644 --- a/src/echidna/EchidnaStakingHarness.sol +++ b/src/echidna/EchidnaStakingHarness.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.19; import "../TestToken.sol"; import "../Staking.sol"; +import "../Util/Constants.sol"; contract EchidnaStakingActor { TestToken internal immutable token; @@ -70,7 +71,7 @@ contract EchidnaStakingHarness { TestToken internal immutable token; StakeRegistry internal immutable registry; - uint256 internal constant MIN_STAKE = 100000000000000000; + uint256 internal constant MIN_STAKE = Constants.MIN_STAKE; uint256 internal constant ACTOR_COUNT = 3; uint64 internal constant WAIT_BASE = 2; uint64 internal constant WAIT_OVERLAY = 2; diff --git a/src/echidna/EchidnaSystemHarness.sol b/src/echidna/EchidnaSystemHarness.sol index 89ea3a1c..66d30672 100644 --- a/src/echidna/EchidnaSystemHarness.sol +++ b/src/echidna/EchidnaSystemHarness.sol @@ -224,7 +224,7 @@ contract EchidnaSystemHarness { if (redist.paused()) return; if (!redist.currentPhaseCommit()) return; // Avoid the commit-phase last-block restriction. - if (block.number % Constants.ROUND_LENGTH == (Constants.ROUND_LENGTH / 4) - 1) return; + if (block.number % Constants.ROUND_LENGTH == Constants.PHASE_LENGTH - 1) return; uint256 idx = uint256(actorId) % ACTOR_COUNT; EchidnaSystemActor a = actors[idx]; diff --git a/test/Redistribution.test.ts b/test/Redistribution.test.ts index 923335e0..267d73d2 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -16,6 +16,7 @@ import { WITNESS_COUNT, skippedRoundsIncrease, ROUND_LENGTH, + PHASE_LENGTH, } from './util/tools'; import { proximity } from './util/tools'; import { node5_proof1, node5_soc_proof1 } from './claim-proofs'; @@ -36,7 +37,7 @@ import { randomBytes } from 'crypto'; import { constructPostageStamp } from './util/postage'; const { read, execute } = deployments; -const phaseLength = 38; +const phaseLength = PHASE_LENGTH; const roundLength = ROUND_LENGTH; const increaseRate = [1049417, 1049206, 1048996, 1048786, 1048576, 1048366, 1048156, 1047946, 1047736]; diff --git a/test/util/tools.ts b/test/util/tools.ts index e5af244c..c3321afd 100644 --- a/test/util/tools.ts +++ b/test/util/tools.ts @@ -8,12 +8,13 @@ import { Utils as BmtUtils } from '@fairdatasociety/bmt-js'; export const equalBytes = BmtUtils.equalBytes; export const ZERO_32_BYTES = '0x' + '0'.repeat(64); -export const PHASE_LENGTH = 38; -/** Must match `Constants.ROUND_LENGTH` in `src/Util/Constants.sol`. */ +/** Must match `Constants.*` in `src/Util/Constants.sol`. */ export const ROUND_LENGTH = 152; +export const PHASE_LENGTH = ROUND_LENGTH / 4; export const WITNESS_COUNT = 16; -export const SEGMENT_COUNT_IN_CHUNK = 128; +export const MAX_CHUNK_PAYLOAD_SIZE = 4096; export const SEGMENT_BYTE_LENGTH = 32; +export const SEGMENT_COUNT_IN_CHUNK = MAX_CHUNK_PAYLOAD_SIZE / SEGMENT_BYTE_LENGTH; const zeroAddress = '0x0000000000000000000000000000000000000000'; type AwaitedTransaction = ContractTransaction & { From f1a8338c37619be182735d988a049b5ae93e6cb9 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:32:16 +0200 Subject: [PATCH 50/58] remove network ID change --- src/Staking.sol | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index d14ff545..343b1f57 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -405,13 +405,6 @@ contract StakeRegistry is AccessControl, Pausable { } } - /** - * @notice Updates the Swarm network identifier used in overlay derivation. - * @param _networkId The new network id. - */ - function changeNetworkId(uint64 _networkId) external onlyRole(DEFAULT_ADMIN_ROLE) { - networkId = _networkId; - } /** * @notice Pauses staking mutations. @@ -846,7 +839,6 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice True when the on-chain stake record is committed for `owner`. - * @dev Commitment is indicated by `overlay != bytes32(0)`; collision with keccak256 output is negligible. */ function _hasCommittedStake(address _owner) internal view returns (bool) { return _accounts[_owner].stake.overlay != bytes32(0); From 71db34409d3c5e65f26156b19090fef9f977708d Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:37:31 +0200 Subject: [PATCH 51/58] clean echidna and some refactoring --- src/Staking.sol | 39 ++++++++++++++++----------- src/echidna/EchidnaStakingHarness.sol | 25 +---------------- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 343b1f57..78f11b43 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -688,21 +688,30 @@ contract StakeRegistry is AccessControl, Pausable { UpdateQueue storage queue = account.queue; uint256 head = queue.head; - uint64 roundNumber = currentRound(); - for (uint256 i = head; i < queue.items.length; ) { - ScheduledUpdate storage scheduled = queue.items[i]; - if (!includeFutureUpdates && scheduled.effectiveFromRound > roundNumber) { - break; - } - if (!includeFutureUpdates && _queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, 0)) { - break; + if (includeFutureUpdates) { + for (uint256 i = head; i < queue.items.length; ) { + preview = _simulateUpdate(_owner, preview, queue.items[i]); + unchecked { + ++i; + } } + } else { + uint64 roundNumber = currentRound(); + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; + if ( + scheduled.effectiveFromRound > roundNumber || + _queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, 0) + ) { + break; + } - preview = _simulateUpdate(_owner, preview, scheduled); + preview = _simulateUpdate(_owner, preview, scheduled); - unchecked { - ++i; + unchecked { + ++i; + } } } } @@ -720,10 +729,10 @@ contract StakeRegistry is AccessControl, Pausable { for (uint256 i = head; i < queue.items.length; ) { ScheduledUpdate storage scheduled = queue.items[i]; - if (scheduled.effectiveFromRound > targetRound) { - break; - } - if (_queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, _lookahead)) { + if ( + scheduled.effectiveFromRound > targetRound || + _queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, _lookahead) + ) { break; } diff --git a/src/echidna/EchidnaStakingHarness.sol b/src/echidna/EchidnaStakingHarness.sol index ec8ae2d7..7139c13a 100644 --- a/src/echidna/EchidnaStakingHarness.sol +++ b/src/echidna/EchidnaStakingHarness.sol @@ -53,10 +53,6 @@ contract EchidnaStakingActor { (ok, ) = address(registry).call(abi.encodeWithSelector(registry.unpause.selector)); } - function tryChangeNetworkId(uint64 newNetworkId) external returns (bool ok) { - (ok, ) = address(registry).call(abi.encodeWithSelector(registry.changeNetworkId.selector, newNetworkId)); - } - function tryFreezeDeposit(address owner, uint256 time) external returns (bool ok) { (ok, ) = address(registry).call(abi.encodeWithSelector(registry.freezeDeposit.selector, owner, time)); } @@ -82,7 +78,6 @@ contract EchidnaStakingHarness { EchidnaStakingActor[3] internal actors; EchidnaStakingActor internal redistributor; - uint64 internal trackedNetworkId; bytes32[3] internal lastSetNonceByActor; bool internal unauthorizedAdminCallSucceeded; @@ -106,8 +101,7 @@ contract EchidnaStakingHarness { initialSupply = 1_000_000_000_000_000_000_000_000; token = new TestToken("TestToken", "TT", initialSupply); - trackedNetworkId = 10; - registry = new StakeRegistry(address(token), trackedNetworkId, WAIT_BASE, WAIT_OVERLAY, WAIT_WITHDRAWAL); + registry = new StakeRegistry(address(token), 10, WAIT_BASE, WAIT_OVERLAY, WAIT_WITHDRAWAL); for (uint256 i = 0; i < ACTOR_COUNT; i++) { actors[i] = new EchidnaStakingActor(token, registry); @@ -333,13 +327,6 @@ contract EchidnaStakingHarness { _checkDigestsUnchanged(d0, d1, d2); } - function act_admin_changeNetworkId(uint64 newNetworkId) external { - _clearPendingChecks(); - // Overlay derivation uses networkId; preview digests may change without a bug. - registry.changeNetworkId(newNetworkId); - trackedNetworkId = newNetworkId; - } - function act_redistributor_freeze(uint8 targetActorId, uint32 time) external { _clearPendingChecks(); @@ -388,16 +375,6 @@ contract EchidnaStakingHarness { _checkDigestsUnchanged(d0, d1, d2); } - function act_actor_tryChangeNetworkId(uint8 actorId, uint64 newNetworkId) external { - _clearPendingChecks(); - bytes32 d0 = _stakeDigest(address(actors[0])); - bytes32 d1 = _stakeDigest(address(actors[1])); - bytes32 d2 = _stakeDigest(address(actors[2])); - bool ok = _actor(actorId).tryChangeNetworkId(newNetworkId); - if (ok) unauthorizedAdminCallSucceeded = true; - _checkDigestsUnchanged(d0, d1, d2); - } - function act_actor_tryFreeze(uint8 actorId, uint8 targetActorId, uint32 time) external { _clearPendingChecks(); bytes32 d0 = _stakeDigest(address(actors[0])); From fc6b4c3e87901a2e3defd1c935336fc64c0e682c Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:40:03 +0200 Subject: [PATCH 52/58] fixed mumbo jumbo :) --- src/Staking.sol | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 78f11b43..ea1e4619 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -493,12 +493,9 @@ contract StakeRegistry is AccessControl, Pausable { } // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). - Stake storage stRef = _accounts[_owner].stake; - Stake memory s = Stake({overlay: stRef.overlay, balance: stRef.balance, height: stRef.height}); + Stake memory s = _accounts[_owner].stake; s = _simulateUpdate(_owner, s, scheduled); - stRef.overlay = s.overlay; - stRef.balance = s.balance; - stRef.height = s.height; + _accounts[_owner].stake = s; } /** From 3c670cae310cae94caef78befe42bc9e15c1c113 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:41:45 +0200 Subject: [PATCH 53/58] no reuse remove --- src/Staking.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index ea1e4619..3295be18 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -482,8 +482,7 @@ contract StakeRegistry is AccessControl, Pausable { // Path 2: full exit — delete stake and return all remaining BZZ. if (scheduled.kind == UpdateKind.ExitStake) { - Stake storage stakeRef = _accounts[_owner].stake; - uint256 balance = stakeRef.balance; + uint256 balance = _accounts[_owner].stake.balance; _clearStake(_owner); if (balance > 0) { if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); From 04882d0f63faf930cde92b8f4247e127805ccf3c Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:44:35 +0200 Subject: [PATCH 54/58] Add modifier which is reused --- src/Staking.sol | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 3295be18..529720f9 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -156,6 +156,16 @@ contract StakeRegistry is AccessControl, Pausable { _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } + modifier whenQueueOpen() { + if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + _; + } + + modifier onlyRedistributor() { + if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + _; + } + //////////////////////////////////////// // STATE CHANGING // //////////////////////////////////////// @@ -171,8 +181,7 @@ contract StakeRegistry is AccessControl, Pausable { bytes32 _setNonce, uint256 _amount, uint8 _height - ) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + ) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { Stake memory plannedStake = _previewStake(msg.sender, true); if (_hasCommittedStake(plannedStake) && plannedStake.balance > 0) revert AlreadyStaked(); uint256 minStake = _minimumStakeForHeight(_height); @@ -198,10 +207,9 @@ contract StakeRegistry is AccessControl, Pausable { * @param _amount The amount of BZZ to add to the stake. * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - function addTokens(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + function addTokens(uint256 _amount) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + _requirePreviewStaked(plannedStake); _pullTokens(_amount); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); @@ -215,10 +223,9 @@ contract StakeRegistry is AccessControl, Pausable { * @return effectiveFromRound Round when the queued update becomes effective (matches `OverlayChanged`). * @dev Reverts with `OverlayUnchanged` if the derived overlay equals the current one (no sentinel return value). */ - function changeOverlay(bytes32 _setNonce) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + function changeOverlay(bytes32 _setNonce) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + _requirePreviewStaked(plannedStake); bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); if (newOverlay == plannedStake.overlay) revert OverlayUnchanged(); @@ -233,10 +240,9 @@ contract StakeRegistry is AccessControl, Pausable { * @param _height The new staking height. * @return effectiveFromRound Round when the queued update becomes effective (matches event); 0 if unchanged. */ - function increaseHeight(uint8 _height) external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + function increaseHeight(uint8 _height) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + _requirePreviewStaked(plannedStake); if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); if (_height == plannedStake.height) return 0; uint256 minForHeight = _minimumStakeForHeight(_height); @@ -252,12 +258,11 @@ contract StakeRegistry is AccessControl, Pausable { * @dev A full unwind must use `exit()`, not `withdraw(balance)`. Overdrawing reverts with `ExceedsBalance`; leaving a remainder below the height minimum reverts with `BelowMinimumStake`. Effective round stacking follows `_enqueueUpdate` (FIFO vs delay rounds). * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ - function withdraw(uint256 _amount) external whenNotPaused returns (uint64 effectiveFromRound) { + function withdraw(uint256 _amount) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + _requirePreviewStaked(plannedStake); if (_amount > plannedStake.balance) { revert InvalidWithdrawalAmount(WithdrawalAmountIssue.ExceedsBalance); } @@ -274,10 +279,9 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Uses the same effective-round stacking as `withdraw()`; see `_enqueueUpdate`. * @return effectiveFromRound Round when the queued update becomes effective (matches `WithdrawalQueued`). */ - function exit() external whenNotPaused returns (uint64 effectiveFromRound) { - if (_accounts[msg.sender].queue.closed) revert QueueClosed(); + function exit() external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { Stake memory plannedStake = _previewStake(msg.sender, true); - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + _requirePreviewStaked(plannedStake); effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); _accounts[msg.sender].queue.closed = true; @@ -346,9 +350,7 @@ contract StakeRegistry is AccessControl, Pausable { * @dev If an existing freeze ends later than `block.number + _time`, it is kept (monotonic). The * deadline is stored per account and survives exit and stake deletion. */ - function freezeDeposit(address _owner, uint256 _time) external whenNotPaused { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); - + function freezeDeposit(address _owner, uint256 _time) external whenNotPaused onlyRedistributor { uint256 until = block.number + _time; // No stake and no queue: only record account-level penalty. @@ -378,8 +380,7 @@ contract StakeRegistry is AccessControl, Pausable { * @param _owner The staker to slash. * @param _amount The amount to slash from the active stake. */ - function slashDeposit(address _owner, uint256 _amount) external whenNotPaused { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + function slashDeposit(address _owner, uint256 _amount) external whenNotPaused onlyRedistributor { if (_amount == 0) revert InvalidAmount(); _applyReadyUpdates(_owner); @@ -842,6 +843,10 @@ contract StakeRegistry is AccessControl, Pausable { return keccak256(abi.encodePacked(_owner, reverse(networkId), _setNonce)); } + function _requirePreviewStaked(Stake memory plannedStake) internal pure { + if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + } + /** * @notice True when the on-chain stake record is committed for `owner`. */ From 0cb605386671614c6719e9592cb2fee1d07d2764 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:46:15 +0200 Subject: [PATCH 55/58] remove duplicate --- src/Staking.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 529720f9..9a118532 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -399,9 +399,6 @@ contract StakeRegistry is AccessControl, Pausable { } else { _clearStake(_owner); } - } - - if (previousOverlay != bytes32(0)) { emit StakeSlashed(_owner, previousOverlay, _amount); } } From c42c12f9f794bd0ef81d93cee90f331b6f1bd244 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 19:59:35 +0200 Subject: [PATCH 56/58] remove naming incosistencies --- src/Staking.sol | 86 ++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Staking.sol b/src/Staking.sol index 9a118532..ba36c8db 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -463,12 +463,12 @@ contract StakeRegistry is AccessControl, Pausable { * @dev Three paths: partial withdrawal (transfer + balance), full exit (delete stake + transfer), * or stake mutation via `_simulateUpdate` (deposit, add, height, overlay). */ - function _applyStoredUpdate(address _owner, ScheduledUpdate storage scheduled) internal { + function _applyStoredUpdate(address _owner, ScheduledUpdate storage _scheduled) internal { // Path 1: partial withdrawal — pay out capped at current balance (may be slashed since queued). - if (scheduled.kind == UpdateKind.WithdrawTokens) { + if (_scheduled.kind == UpdateKind.WithdrawTokens) { Stake storage stake = _accounts[_owner].stake; if (stake.overlay != bytes32(0)) { - uint256 paid = scheduled.amount > stake.balance ? stake.balance : scheduled.amount; + uint256 paid = _scheduled.amount > stake.balance ? stake.balance : _scheduled.amount; stake.balance -= paid; if (paid > 0) { if (!ERC20(bzzToken).transfer(_owner, paid)) revert TransferFailed(); @@ -479,7 +479,7 @@ contract StakeRegistry is AccessControl, Pausable { } // Path 2: full exit — delete stake and return all remaining BZZ. - if (scheduled.kind == UpdateKind.ExitStake) { + if (_scheduled.kind == UpdateKind.ExitStake) { uint256 balance = _accounts[_owner].stake.balance; _clearStake(_owner); if (balance > 0) { @@ -491,7 +491,7 @@ contract StakeRegistry is AccessControl, Pausable { // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). Stake memory s = _accounts[_owner].stake; - s = _simulateUpdate(_owner, s, scheduled); + s = _simulateUpdate(_owner, s, _scheduled); _accounts[_owner].stake = s; } @@ -561,15 +561,15 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible, happens in slashing */ - function _syncHeightToBalance(Stake storage stake) internal { - if (stake.overlay == bytes32(0)) return; - uint8 h = stake.height; - while (h > 0 && stake.balance < _minimumStakeForHeight(h)) { + function _syncHeightToBalance(Stake storage _stake) internal { + if (_stake.overlay == bytes32(0)) return; + uint8 h = _stake.height; + while (h > 0 && _stake.balance < _minimumStakeForHeight(h)) { unchecked { h--; } } - stake.height = h; + _stake.height = h; } //////////////////////////////////////// @@ -676,14 +676,14 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Previews stake state using the current round, optionally including future queued updates. */ - function _previewStake(address _owner, bool includeFutureUpdates) internal view returns (Stake memory preview) { + function _previewStake(address _owner, bool _includeFutureUpdates) internal view returns (Stake memory preview) { Account storage account = _accounts[_owner]; preview = account.stake; UpdateQueue storage queue = account.queue; uint256 head = queue.head; - if (includeFutureUpdates) { + if (_includeFutureUpdates) { for (uint256 i = head; i < queue.items.length; ) { preview = _simulateUpdate(_owner, preview, queue.items[i]); unchecked { @@ -744,51 +744,51 @@ contract StakeRegistry is AccessControl, Pausable { */ function _simulateUpdate( address _owner, - Stake memory preview, - ScheduledUpdate storage scheduled + Stake memory _preview, + ScheduledUpdate storage _scheduled ) internal view returns (Stake memory) { - if (scheduled.kind == UpdateKind.CreateDeposit) { - preview.overlay = _deriveOverlay(_owner, scheduled.nonce); - preview.balance = scheduled.amount; - preview.height = scheduled.height; - return preview; + if (_scheduled.kind == UpdateKind.CreateDeposit) { + _preview.overlay = _deriveOverlay(_owner, _scheduled.nonce); + _preview.balance = _scheduled.amount; + _preview.height = _scheduled.height; + return _preview; } - if (scheduled.kind == UpdateKind.AddTokens) { - preview.balance += scheduled.amount; - return preview; + if (_scheduled.kind == UpdateKind.AddTokens) { + _preview.balance += _scheduled.amount; + return _preview; } - if (scheduled.kind == UpdateKind.IncreaseHeight) { - if (_hasCommittedStake(preview) && scheduled.height > preview.height) { - preview.height = scheduled.height; + if (_scheduled.kind == UpdateKind.IncreaseHeight) { + if (_hasCommittedStake(_preview) && _scheduled.height > _preview.height) { + _preview.height = _scheduled.height; } - return preview; + return _preview; } - if (scheduled.kind == UpdateKind.ChangeOverlay) { - if (_hasCommittedStake(preview)) { - preview.overlay = _deriveOverlay(_owner, scheduled.nonce); + if (_scheduled.kind == UpdateKind.ChangeOverlay) { + if (_hasCommittedStake(_preview)) { + _preview.overlay = _deriveOverlay(_owner, _scheduled.nonce); } - return preview; + return _preview; } - if (scheduled.kind == UpdateKind.WithdrawTokens) { - if (_hasCommittedStake(preview)) { - if (scheduled.amount >= preview.balance) { - preview.balance = 0; + if (_scheduled.kind == UpdateKind.WithdrawTokens) { + if (_hasCommittedStake(_preview)) { + if (_scheduled.amount >= _preview.balance) { + _preview.balance = 0; } else { - preview.balance -= scheduled.amount; + _preview.balance -= _scheduled.amount; } } - return preview; + return _preview; } - if (scheduled.kind == UpdateKind.ExitStake) { - delete preview; + if (_scheduled.kind == UpdateKind.ExitStake) { + delete _preview; } - return preview; + return _preview; } /** @@ -840,8 +840,8 @@ contract StakeRegistry is AccessControl, Pausable { return keccak256(abi.encodePacked(_owner, reverse(networkId), _setNonce)); } - function _requirePreviewStaked(Stake memory plannedStake) internal pure { - if (!_hasCommittedStake(plannedStake) || plannedStake.balance == 0) revert NotStaked(); + function _requirePreviewStaked(Stake memory _plannedStake) internal pure { + if (!_hasCommittedStake(_plannedStake) || _plannedStake.balance == 0) revert NotStaked(); } /** @@ -859,8 +859,8 @@ contract StakeRegistry is AccessControl, Pausable { /** * @notice Reverses byte order for network id encoding in overlay derivation. */ - function reverse(uint64 input) internal pure returns (uint64 v) { - v = input; + function reverse(uint64 _input) internal pure returns (uint64 v) { + v = _input; v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); From 7181cc947c07a9363cc552a91dd3bf6edd1e6f3a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 1 Jun 2026 20:05:33 +0200 Subject: [PATCH 57/58] Prefix function parameters in Redistribution and PriceOracle. Aligns param naming with Staking: all function args use _ prefix. --- src/PriceOracle.sol | 8 +- src/Redistribution.sol | 196 ++++++++++++++++++++--------------------- src/Staking.sol | 1 - 3 files changed, 102 insertions(+), 103 deletions(-) diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index 0f6cc1b5..74a3ecc1 100644 --- a/src/PriceOracle.sol +++ b/src/PriceOracle.sol @@ -97,13 +97,13 @@ contract PriceOracle is AccessControl { return true; } - function adjustPrice(uint16 redundancy) external returns (bool) { + function adjustPrice(uint16 _redundancy) external returns (bool) { if (isPaused == false) { if (!hasRole(PRICE_UPDATER_ROLE, msg.sender)) { revert CallerNotPriceUpdater(); } - uint16 usedRedundancy = redundancy; + uint16 usedRedundancy = _redundancy; uint64 currentRoundNumber = currentRound(); // Price can only be adjusted once per round @@ -111,13 +111,13 @@ contract PriceOracle is AccessControl { revert PriceAlreadyAdjusted(); } // Redundancy may not be zero - if (redundancy == 0) { + if (_redundancy == 0) { revert UnexpectedZero(); } // Enforce maximum considered extra redundancy uint16 maxConsideredRedundancy = targetRedundancy + maxConsideredExtraRedundancy; - if (redundancy > maxConsideredRedundancy) { + if (_redundancy > maxConsideredRedundancy) { usedRedundancy = maxConsideredRedundancy; } diff --git a/src/Redistribution.sol b/src/Redistribution.sol index 11336667..262cb1e5 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -9,7 +9,7 @@ import "./Util/Constants.sol"; import "./interface/IPostageStamp.sol"; interface IPriceOracle { - function adjustPrice(uint16 redundancy) external returns (bool); + function adjustPrice(uint16 _redundancy) external returns (bool); } interface IStakeRegistry { @@ -258,14 +258,14 @@ contract Redistribution is AccessControl, Pausable { // ----------------------------- CONSTRUCTOR ------------------------------ /** - * @param staking the address of the linked Staking contract. - * @param postageContract the address of the linked PostageStamp contract. - * @param oracleContract the address of the linked PriceOracle contract. + * @param _staking the address of the linked Staking contract. + * @param _postageContract the address of the linked PostageStamp contract. + * @param _oracleContract the address of the linked PriceOracle contract. */ - constructor(address staking, address postageContract, address oracleContract) { - Stakes = IStakeRegistry(staking); - PostageContract = IPostageStamp(postageContract); - OracleContract = IPriceOracle(oracleContract); + constructor(address _staking, address _postageContract, address _oracleContract) { + Stakes = IStakeRegistry(_staking); + PostageContract = IPostageStamp(_postageContract); + OracleContract = IPriceOracle(_oracleContract); _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); } @@ -417,9 +417,9 @@ contract Redistribution is AccessControl, Pausable { * @dev */ function claim( - ChunkInclusionProof calldata entryProof1, - ChunkInclusionProof calldata entryProof2, - ChunkInclusionProof calldata entryProofLast + ChunkInclusionProof calldata _entryProof1, + ChunkInclusionProof calldata _entryProof2, + ChunkInclusionProof calldata _entryProofLast ) external whenNotPaused { winnerSelection(); @@ -437,39 +437,39 @@ contract Redistribution is AccessControl, Pausable { indexInRC2++; } - if (!inProximity(entryProofLast.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { + if (!inProximity(_entryProofLast.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { revert OutOfDepthClaim(3); } - inclusionFunction(entryProofLast, 30); - stampFunction(entryProofLast); - socFunction(entryProofLast); + inclusionFunction(_entryProofLast, 30); + stampFunction(_entryProofLast); + socFunction(_entryProofLast); - if (!inProximity(entryProof1.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { + if (!inProximity(_entryProof1.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { revert OutOfDepthClaim(2); } - inclusionFunction(entryProof1, indexInRC1 * 2); - stampFunction(entryProof1); - socFunction(entryProof1); + inclusionFunction(_entryProof1, indexInRC1 * 2); + stampFunction(_entryProof1); + socFunction(_entryProof1); - if (!inProximity(entryProof2.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { + if (!inProximity(_entryProof2.proveSegment, _currentRevealRoundAnchor, winnerSelected.depth)) { revert OutOfDepthClaim(1); } - inclusionFunction(entryProof2, indexInRC2 * 2); - stampFunction(entryProof2); - socFunction(entryProof2); + inclusionFunction(_entryProof2, indexInRC2 * 2); + stampFunction(_entryProof2); + socFunction(_entryProof2); checkOrder( indexInRC1, indexInRC2, - entryProof1.proofSegments[0], - entryProof2.proofSegments[0], - entryProofLast.proofSegments[0] + _entryProof1.proofSegments[0], + _entryProof2.proofSegments[0], + _entryProofLast.proofSegments[0] ); - estimateSize(entryProofLast.proofSegments[0]); + estimateSize(_entryProofLast.proofSegments[0]); PostageContract.withdraw(winnerSelected.owner); @@ -563,61 +563,61 @@ contract Redistribution is AccessControl, Pausable { currentClaimRound = cr; } - function inclusionFunction(ChunkInclusionProof calldata entryProof, uint256 indexInRC) internal { + function inclusionFunction(ChunkInclusionProof calldata _entryProof, uint256 _indexInRC) internal { uint256 randomChunkSegmentIndex = uint256(seed) % Constants.SEGMENTS_PER_CHUNK; bytes32 calculatedTransformedAddr = TransformedBMTChunk.transformedChunkAddressFromInclusionProof( - entryProof.proofSegments3, - entryProof.proveSegment2, + _entryProof.proofSegments3, + _entryProof.proveSegment2, randomChunkSegmentIndex, - entryProof.chunkSpan, + _entryProof.chunkSpan, currentRevealRoundAnchor ); - emit transformedChunkAddressFromInclusionProof(indexInRC, calculatedTransformedAddr); + emit transformedChunkAddressFromInclusionProof(_indexInRC, calculatedTransformedAddr); if ( winner.hash != BMTChunk.chunkAddressFromInclusionProof( - entryProof.proofSegments, - entryProof.proveSegment, - indexInRC, + _entryProof.proofSegments, + _entryProof.proveSegment, + _indexInRC, 32 * 32 ) ) { revert InclusionProofFailed(1, calculatedTransformedAddr); } - if (entryProof.proofSegments2[0] != entryProof.proofSegments3[0]) { + if (_entryProof.proofSegments2[0] != _entryProof.proofSegments3[0]) { revert InclusionProofFailed(2, calculatedTransformedAddr); } - bytes32 originalAddress = entryProof.socProof.length > 0 - ? entryProof.socProof[0].chunkAddr // soc attestation in socFunction - : entryProof.proveSegment; + bytes32 originalAddress = _entryProof.socProof.length > 0 + ? _entryProof.socProof[0].chunkAddr // soc attestation in socFunction + : _entryProof.proveSegment; if ( originalAddress != BMTChunk.chunkAddressFromInclusionProof( - entryProof.proofSegments2, - entryProof.proveSegment2, + _entryProof.proofSegments2, + _entryProof.proveSegment2, randomChunkSegmentIndex, - entryProof.chunkSpan + _entryProof.chunkSpan ) ) { revert InclusionProofFailed(3, calculatedTransformedAddr); } // In case of SOC, the transformed address is hashed together with its address in the sample - if (entryProof.socProof.length > 0) { + if (_entryProof.socProof.length > 0) { calculatedTransformedAddr = keccak256( abi.encode( - entryProof.proveSegment, // SOC address + _entryProof.proveSegment, // SOC address calculatedTransformedAddr ) ); } - if (entryProof.proofSegments[0] != calculatedTransformedAddr) { + if (_entryProof.proofSegments[0] != calculatedTransformedAddr) { revert InclusionProofFailed(4, calculatedTransformedAddr); } } @@ -774,16 +774,16 @@ contract Redistribution is AccessControl, Pausable { /** * @notice Returns true if an overlay address _A_ is within proximity order _minimum_ of _B_. - * @param A An overlay address to compare. - * @param B An overlay address to compare. - * @param minimum Minimum proximity order. + * @param _a An overlay address to compare. + * @param _b An overlay address to compare. + * @param _minimum Minimum proximity order. */ - function inProximity(bytes32 A, bytes32 B, uint8 minimum) public pure returns (bool) { - if (minimum == 0) { + function inProximity(bytes32 _a, bytes32 _b, uint8 _minimum) public pure returns (bool) { + if (_minimum == 0) { return true; } - return uint256(A ^ B) < uint256(2 ** (256 - minimum)); + return uint256(_a ^ _b) < uint256(2 ** (256 - _minimum)); } // ----------------------------- Commit ------------------------------ @@ -875,15 +875,15 @@ contract Redistribution is AccessControl, Pausable { * @param _overlay The overlay address of the applicant. * @param _depth The reported depth. * @param _hash The reserve commitment hash. - * @param revealNonce A random, single use, secret nonce. + * @param _revealNonce A random, single use, secret nonce. */ function wrapCommit( bytes32 _overlay, uint8 _depth, bytes32 _hash, - bytes32 revealNonce + bytes32 _revealNonce ) public pure returns (bytes32) { - return keccak256(abi.encodePacked(_overlay, _depth, _hash, revealNonce)); + return keccak256(abi.encodePacked(_overlay, _depth, _hash, _revealNonce)); } /** @@ -1026,110 +1026,110 @@ contract Redistribution is AccessControl, Pausable { // ----------------------------- Claim verifications ------------------------------ - function socFunction(ChunkInclusionProof calldata entryProof) internal pure { - if (entryProof.socProof.length == 0) return; + function socFunction(ChunkInclusionProof calldata _entryProof) internal pure { + if (_entryProof.socProof.length == 0) return; if ( !Signatures.socVerify( - entryProof.socProof[0].signer, // signer Ethereum address to check against - entryProof.socProof[0].signature, - entryProof.socProof[0].identifier, - entryProof.socProof[0].chunkAddr + _entryProof.socProof[0].signer, // signer Ethereum address to check against + _entryProof.socProof[0].signature, + _entryProof.socProof[0].identifier, + _entryProof.socProof[0].chunkAddr ) ) { - revert SocVerificationFailed(entryProof.socProof[0].chunkAddr); + revert SocVerificationFailed(_entryProof.socProof[0].chunkAddr); } if ( - calculateSocAddress(entryProof.socProof[0].identifier, entryProof.socProof[0].signer) != - entryProof.proveSegment + calculateSocAddress(_entryProof.socProof[0].identifier, _entryProof.socProof[0].signer) != + _entryProof.proveSegment ) { - revert SocCalcNotMatching(entryProof.socProof[0].chunkAddr); + revert SocCalcNotMatching(_entryProof.socProof[0].chunkAddr); } } - function stampFunction(ChunkInclusionProof calldata entryProof) internal view { + function stampFunction(ChunkInclusionProof calldata _entryProof) internal view { // authentic (address batchOwner, uint8 batchDepth, uint8 bucketDepth, , , ) = PostageContract.batches( - entryProof.postageProof.postageId + _entryProof.postageProof.postageId ); // alive if (batchOwner == address(0)) { - revert BatchDoesNotExist(entryProof.postageProof.postageId); // Batch does not exist or expired + revert BatchDoesNotExist(_entryProof.postageProof.postageId); // Batch does not exist or expired } - uint32 postageIndex = getPostageIndex(entryProof.postageProof.index); + uint32 postageIndex = getPostageIndex(_entryProof.postageProof.index); uint256 maxPostageIndex = postageStampIndexCount(batchDepth, bucketDepth); // available if (postageIndex >= maxPostageIndex) { - revert IndexOutsideSet(entryProof.postageProof.postageId); + revert IndexOutsideSet(_entryProof.postageProof.postageId); } // aligned - uint64 postageBucket = getPostageBucket(entryProof.postageProof.index); - uint64 addressBucket = addressToBucket(entryProof.proveSegment, bucketDepth); + uint64 postageBucket = getPostageBucket(_entryProof.postageProof.index); + uint64 addressBucket = addressToBucket(_entryProof.proveSegment, bucketDepth); if (postageBucket != addressBucket) { - revert BucketDiffers(entryProof.postageProof.postageId); + revert BucketDiffers(_entryProof.postageProof.postageId); } // authorized if ( !Signatures.postageVerify( batchOwner, - entryProof.postageProof.signature, - entryProof.proveSegment, - entryProof.postageProof.postageId, - entryProof.postageProof.index, - entryProof.postageProof.timeStamp + _entryProof.postageProof.signature, + _entryProof.proveSegment, + _entryProof.postageProof.postageId, + _entryProof.postageProof.index, + _entryProof.postageProof.timeStamp ) ) { - revert SigRecoveryFailed(entryProof.postageProof.postageId); + revert SigRecoveryFailed(_entryProof.postageProof.postageId); } } - function addressToBucket(bytes32 swarmAddress, uint8 bucketDepth) internal pure returns (uint32) { - uint32 prefix = uint32(uint256(swarmAddress) >> (256 - 32)); - return prefix >> (32 - bucketDepth); + function addressToBucket(bytes32 _swarmAddress, uint8 _bucketDepth) internal pure returns (uint32) { + uint32 prefix = uint32(uint256(_swarmAddress) >> (256 - 32)); + return prefix >> (32 - _bucketDepth); } - function postageStampIndexCount(uint8 postageDepth, uint8 bucketDepth) internal pure returns (uint256) { - return 1 << (postageDepth - bucketDepth); + function postageStampIndexCount(uint8 _postageDepth, uint8 _bucketDepth) internal pure returns (uint256) { + return 1 << (_postageDepth - _bucketDepth); } - function getPostageIndex(uint64 signedIndex) internal pure returns (uint32) { - return uint32(signedIndex); + function getPostageIndex(uint64 _signedIndex) internal pure returns (uint32) { + return uint32(_signedIndex); } - function getPostageBucket(uint64 signedIndex) internal pure returns (uint64) { - return uint32(signedIndex >> 32); + function getPostageBucket(uint64 _signedIndex) internal pure returns (uint64) { + return uint32(_signedIndex >> 32); } - function calculateSocAddress(bytes32 identifier, address signer) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(identifier, signer)); + function calculateSocAddress(bytes32 _identifier, address _signer) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(_identifier, _signer)); } - function checkOrder(uint256 a, uint256 b, bytes32 trA1, bytes32 trA2, bytes32 trALast) internal pure { - if (a < b) { - if (uint256(trA1) >= uint256(trA2)) { + function checkOrder(uint256 _a, uint256 _b, bytes32 _trA1, bytes32 _trA2, bytes32 _trALast) internal pure { + if (_a < _b) { + if (uint256(_trA1) >= uint256(_trA2)) { revert RandomElementCheckFailed(); } - if (uint256(trA2) >= uint256(trALast)) { + if (uint256(_trA2) >= uint256(_trALast)) { revert LastElementCheckFailed(); } } else { - if (uint256(trA2) >= uint256(trA1)) { + if (uint256(_trA2) >= uint256(_trA1)) { revert RandomElementCheckFailed(); } - if (uint256(trA1) >= uint256(trALast)) { + if (uint256(_trA1) >= uint256(_trALast)) { revert LastElementCheckFailed(); } } } - function estimateSize(bytes32 trALast) internal view { - if (uint256(trALast) >= sampleMaxValue) { - revert ReserveCheckFailed(trALast); + function estimateSize(bytes32 _trALast) internal view { + if (uint256(_trALast) >= sampleMaxValue) { + revert ReserveCheckFailed(_trALast); } } } diff --git a/src/Staking.sol b/src/Staking.sol index ba36c8db..93777e5c 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -403,7 +403,6 @@ contract StakeRegistry is AccessControl, Pausable { } } - /** * @notice Pauses staking mutations. */ From 99241ad12f525f73358196f83dd05968f1cc188d Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 2 Jun 2026 18:48:45 +0200 Subject: [PATCH 58/58] update docs --- docs/DEPLOYMENT.md | 4 +- docs/OVERVIEW.md | 27 ++- docs/README.md | 9 +- docs/STAKING.md | 481 ++++++++++++++++----------------------------- 4 files changed, 187 insertions(+), 334 deletions(-) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index da43b93e..ead4daea 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -18,7 +18,7 @@ The contracts must be deployed in this specific order due to dependencies: 1. Token (external or TestToken for testnets) 2. PostageStamp (depends on Token) 3. PriceOracle (depends on PostageStamp) -4. StakeRegistry (depends on Token and PriceOracle) +4. StakeRegistry (depends on Token) 5. Redistribution (depends on StakeRegistry, PostageStamp, PriceOracle) 6. Role Setup (connects contracts together) ``` @@ -111,7 +111,7 @@ npx hardhat deploy --network mainnet --tags oracle **Constructor**: ```typescript -[token.address, swarmNetworkId, priceOracle.address] +[token.address, swarmNetworkId, waitBase, waitOverlayChange, waitWithdrawal] ``` **Network IDs** (from `helper-hardhat-config.ts`): diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 1c2f973e..ec2d69ca 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -43,17 +43,17 @@ User → PostageStamp.createBatch() ### 2. Staking for Node Operators -Node operators stake tokens to participate in the redistribution game: +Node operators stake BZZ to participate in the redistribution game: ``` -Node Operator → StakeRegistry.manageStake() - ├─ Calculate overlay from network ID + nonce - ├─ Calculate committed stake (in storage units) - ├─ Track potential stake (in BZZ tokens) - └─ Allow withdrawal of surplus stake +Node Operator → StakeRegistry (queued updates) + ├─ createDeposit / addTokens / increaseHeight / changeOverlay + ├─ overlay = keccak256(owner, networkId, nonce) + ├─ applyUpdates after round delays + └─ withdraw / exit (with WAIT_WITHDRAWAL) ``` -**Key Concept**: Height parameter allows nodes to register additional storage capacity (2^height multiplier). +**Key Concept**: Height sets the minimum stake (`MIN_STAKE * 2^height`). Effective stake for the game is the previewed BZZ balance while not frozen (no oracle in staking). ### 3. Redistribution Game Phases @@ -105,14 +105,13 @@ remainingBalance = normalisedBalance - currentTotalOutPayment() ### Staking Economics -- **Committed Stake**: Amount of chunks pledged to store (in oracle price units) -- **Potential Stake**: Actual BZZ tokens staked -- **Effective Stake**: `min(committed_stake * price * 2^height, potential_stake)` +- **Balance**: BZZ locked in `StakeRegistry` for the node +- **Height**: Minimum balance scale (`MIN_STAKE * 2^height`); used with reported depth in redistribution +- **Effective Stake**: Previewed balance when overlay is set and account is not frozen **Example**: -- Node stakes 1000 BZZ at price 1000 chunks/BZZ with height 2 -- Committed stake: 100 chunks -- Effective stake: min(100 * 1000 * 4, 1000 BZZ) = 1000 BZZ +- Node deposits 1 BZZ at height 2 (minimum 0.1 * 4 = 0.4 BZZ) +- `nodeEffectiveStake` returns 1 BZZ (or 0 while frozen) ### Redistribution Economics @@ -138,7 +137,7 @@ Each contract defines specific roles: - `PRICE_UPDATER_ROLE`: Can adjust price based on redundancy (granted to Redistribution) **StakeRegistry**: -- `DEFAULT_ADMIN_ROLE`: Change network ID, pause +- `DEFAULT_ADMIN_ROLE`: Pause / unpause - `REDISTRIBUTOR_ROLE`: Freeze and slash deposits (granted to Redistribution) **Redistribution**: diff --git a/docs/README.md b/docs/README.md index 0614f6ae..42f3d24e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,10 +45,11 @@ Automatically adjusts the price per chunk based on network redundancy. Manages staking for node operators participating in the redistribution game. **Key Features:** -- Stake commitment and potential stake -- Overlay management for nodes -- Freeze and slash mechanisms for penalties -- Height-based reserve calculations +- Queued stake updates (deposit, top-up, height, overlay, withdraw, exit) +- Overlay derivation from network ID and nonce +- Effective stake = previewed BZZ balance (gated by freeze) +- Height-based minimum stake (`MIN_STAKE * 2^height`) +- Freeze and slash penalties via `REDISTRIBUTOR_ROLE` ### Redistribution (`Redistribution.sol`) Implements the Schelling coordination game for reserve commitment consensus. diff --git a/docs/STAKING.md b/docs/STAKING.md index caf745d7..5fd92bf9 100644 --- a/docs/STAKING.md +++ b/docs/STAKING.md @@ -2,426 +2,279 @@ ## Overview -The `StakeRegistry` (Staking) contract manages staking for node operators participating in the Swarm network's redistribution game. Nodes stake tokens to become eligible for rewards and penalties. +The `StakeRegistry` contract (`src/Staking.sol`) manages BZZ staking for node operators in the Swarm redistribution game. Nodes lock tokens, register an overlay and height, and become subject to freeze or slash penalties from the `Redistribution` contract. + +There is **no PriceOracle dependency**. Stake is a single on-chain BZZ balance per account, not a committed-chunk / potential-stake pair. ## Purpose The contract: -- Tracks node stakes with overlay addresses -- Manages committed vs potential stake -- Allows height-based reserve calculations -- Provides freeze/slash mechanisms for penalties -- Enables stake withdrawal for surplus amounts + +- Derives and tracks each node's overlay address +- Holds staked BZZ with height-based minimum requirements +- Queues stake changes with round-based delays (FIFO update queue) +- Exposes **effective** overlay, height, and balance to `Redistribution` (including matured-but-not-yet-applied queue items) +- Applies freeze penalties (participation exclusion) and slash (balance removal) via `REDISTRIBUTOR_ROLE` ## Key Concepts -### Overlay Address +### Overlay address -Each node has an "overlay address" which is derived from: ```solidity -overlay = keccak256(abi.encodePacked(nodeAddress, reverse(networkId), nonce)) +overlay = keccak256(abi.encodePacked(owner, reverse(networkId), nonce)) ``` -This creates a unique identifier for the node within a specific Swarm network. +`networkId` is set at deploy time and used for all new overlay derivations on that deployment. -### Two-Stake System +### Stake balance and height -The contract maintains two types of stake: +Each committed account has: -1. **Committed Stake**: Chunks pledged to store (in oracle price units) -2. **Potential Stake**: Actual BZZ tokens staked +- **`balance`**: BZZ held in the contract for that stake +- **`height`**: Staking height (0–128). Minimum required balance is: -The effective stake (used in redistribution) is the minimum of: ```solidity -effectiveStake = min( - committedStake * price * 2^height, - potentialStake -) +minimumForHeight(h) = MIN_STAKE * (2 ** h) ``` -### Height Parameter +Higher height means a higher minimum deposit and a higher minimum remainder after partial withdrawal. Height does **not** multiply effective stake in the redistribution game; it scales **eligibility requirements** and how `Redistribution` computes depth responsibility (`depth - height`). -The `height` parameter allows nodes to register additional capacity: -- Height 0: Normal capacity (committed stake * price) -- Height 1: Double capacity (committed stake * price * 2) -- Height 2: 4x capacity, etc. +### Effective stake (for Redistribution) -This allows nodes to increase their effective stake without depositing more tokens by registering additional storage space. - -## Functions - -### Node Functions +```solidity +nodeEffectiveStake(owner) = + addressNotFrozen(owner) && overlay != 0 + ? previewedBalance(owner) + : 0 +``` -#### manageStake() -Creates or updates a node's stake, optionally changing overlay. +`previewedBalance` includes all queue items at the account head whose `effectiveFromRound <= currentRound()` (and that are not blocked by freeze for withdrawal types), even if the owner has not called `applyUpdates()`. -**Parameters**: -- `_setNonce`: Nonce for overlay calculation -- `_addAmount`: Additional BZZ tokens to add (0 if only changing overlay) -- `_height`: Height multiplier (0-255) +**Freeze** is not a token lock: while `block.number <= freezeUntilBlock`, effective stake is zero, but the owner can still enqueue non-withdrawal updates. Due `withdraw` / `exit` payouts are blocked until unfreeze (or advanced via redistributor paths — see below). -**Requirements**: -- Minimum stake: `_addAmount >= MIN_STAKE * 2^height` (first deposit only) -- If frozen: transaction reverts with `Frozen()` error +### Update queue -**Logic**: -1. Calculate new overlay from nonce -2. If first stake: check minimum deposit requirement -3. If frozen: revert (can't change stake while frozen) -4. Update potential stake if depositing -5. Calculate new committed stake: `potentialStake / (price * 2^height)` -6. Never allow committed stake to decrease -7. Transfer tokens if depositing -8. Store new stake state -9. Emit events +Most mutations are **scheduled**, not immediate: -**Overlay Change**: -If overlay changes, emits `OverlayChanged` event (useful for monitoring). +| Function | Queue kind | Typical wait | +|----------|------------|--------------| +| `createDeposit` | `CreateDeposit` | `WAIT_BASE` rounds | +| `addTokens` | `AddTokens` | `WAIT_BASE` | +| `increaseHeight` | `IncreaseHeight` | `WAIT_BASE` | +| `changeOverlay` | `ChangeOverlay` | `WAIT_OVERLAY_CHANGE` | +| `withdraw` | `WithdrawTokens` | `WAIT_WITHDRAWAL` | +| `exit` | `ExitStake` | `WAIT_WITHDRAWAL` | -#### withdrawFromStake() -Withdraws surplus stake (difference between potential and effective stake). +Items become applicable when `effectiveFromRound <= currentRound()`. `applyUpdates(owner)` materializes the ready prefix of the queue. Queue length is capped at `UPDATE_QUEUE_MAX_LENGTH` (10). After `exit()` is queued, the queue is **closed** (no further mutations until processed or migrated). -**Requirements**: -- No special roles needed (only withdraws surplus) +Rounds are `block.number / ROUND_LENGTH` (152 blocks per round). -**Logic**: -```solidity -surplus = potentialStake - effectiveStake -if (surplus > 0) { - transfer tokens to node - potentialStake -= surplus -} -``` +## Functions -**Use Case**: If price increases or height decreases, effective stake may be less than potential, allowing withdrawal of the difference. +### Node functions -#### migrateStake() -Emergency withdrawal when contract is paused. +#### createDeposit(setNonce, amount, height) -**Requirements**: -- Contract must be paused -- Withdraws entire potential stake +First stake for an address (no existing committed stake with balance). -**Use Case**: For upgrading to new staking contracts. +- Pulls `amount` of BZZ via `transferFrom` +- Requires `amount >= MIN_STAKE * 2**height` +- Emits `DepositCreated` -### Redistributor Functions +#### addTokens(amount) -#### freezeDeposit() -Freezes a node's stake for a specified time (penalty). +Adds BZZ to an existing stake (queued). -**Parameters**: -- `_owner`: Node address to freeze -- `_time`: Duration in blocks +#### changeOverlay(setNonce) -**Requirements**: -- Only `REDISTRIBUTOR_ROLE` can call +Changes overlay after `WAIT_OVERLAY_CHANGE`; reverts `OverlayUnchanged` if derived overlay is unchanged. -**Logic**: -```solidity -stakes[_owner].lastUpdatedBlockNumber = block.number + _time -``` +#### increaseHeight(height) -While frozen: `stakes[_owner].lastUpdatedBlockNumber > block.number` +Increases height only (cannot decrease). Requires preview balance ≥ minimum for the new height at enqueue time. -**Effects**: -- Node cannot call `manageStake()` while frozen -- `nodeEffectiveStake()` returns 0 while frozen -- After freeze expires, can resume normal operations +#### withdraw(amount) -#### slashDeposit() -Slashes (removes) a specified amount from a node's stake. +Partial withdrawal after `WAIT_WITHDRAWAL`. Remainder must stay ≥ minimum for current height. Full unwind uses `exit()`, not `withdraw(fullBalance)`. -**Parameters**: -- `_owner`: Node address to slash -- `_amount`: BZZ amount to remove +#### exit() -**Requirements**: -- Only `REDISTRIBUTOR_ROLE` can call +Schedules full exit: clears stake and returns all balance when applied; closes the queue. -**Logic**: -```solidity -if (potentialStake > _amount) { - potentialStake -= _amount - lastUpdatedBlockNumber = block.number -} else { - delete stakes[_owner] // Remove entire stake -} -``` +#### applyUpdates(owner) -**Use Cases**: -- Severe protocol violations -- Currently not actively used (freezing is preferred) +Public. Applies all ready queue items in order. Reverts `FrozenWithdrawal()` if the head item is a due withdrawal/exit while frozen (whole tx rolls back). -### Admin Functions +Integrators and Bee should align commit/reveal data with **previewed** overlay/height/stake from view functions, not only storage before `applyUpdates`. -#### changeNetworkId() -Changes the Swarm network ID. - -**Parameters**: -- `_NetworkId`: New network ID +#### migrateStake() -**Requirements**: -- Only `DEFAULT_ADMIN_ROLE` can call +When contract is **paused**: returns active balance plus amounts from queued `CreateDeposit` / `AddTokens`, clears stake and queue. Freeze deadline on the account is unchanged. -**Effects**: -- New overlays will use new network ID -- Existing overlays remain valid +### Redistributor functions -#### pause() / unPause() -Pauses or unpauses the contract. +#### freezeDeposit(owner, time) -**Requirements**: -- Only `DEFAULT_ADMIN_ROLE` can call +- Extends `freezeUntilBlock` to at least `block.number + time` (monotonic — never shortened) +- Calls `_applyReadyUpdates` first: a **matured** withdrawal at queue head on an **unfrozen** account can pay out in the same tx before the new freeze applies +- While frozen: `nodeEffectiveStake` is 0; further due withdrawals are blocked +- Emits `StakeFrozen` when committed stake exists; otherwise `AccountFreezeExtended` for account-only freeze -**Effects**: -- Prevents `manageStake()` calls -- Allows `migrateStake()` calls +`Redistribution` uses freeze today; slash in redistribution is still commented out (ph5). -### View Functions +#### slashDeposit(owner, amount) -#### nodeEffectiveStake(address) -Returns the effective stake used in redistribution game. +- Applies ready updates first (same ordering caveat as freeze for withdrawals) +- Reduces `balance`, runs `_syncHeightToBalance` on partial slash, may zero balance while keeping overlay if queue non-empty +- Emits `StakeSlashed` with requested `amount` (not necessarily the balance actually removed) +- Not called from `Redistribution` in the current deployment -```solidity -if (addressNotFrozen(address)) { - return calculateEffectiveStake( - committedStake, - potentialStake, - height - ) -} else { - return 0 -} -``` +### Admin functions -#### withdrawableStake() -Returns the amount of surplus stake that can be withdrawn. +#### pause() / unpause() -#### lastUpdatedBlockNumberOfAddress(address) -Returns when stake was last updated (used to check if frozen). +`DEFAULT_ADMIN_ROLE`. Pauses user-facing mutations (`whenNotPaused`). `applyUpdates` and `migrateStake` are not gated by pause. -#### overlayOfAddress(address) -Returns the current overlay for a node. +There is no `changeNetworkId()` in the current contract; `networkId` is constructor-initialized only. -#### heightOfAddress(address) -Returns the height multiplier for a node. +### View functions -### Internal Functions +| Function | Returns | +|----------|---------| +| `stakes(owner)` | Previewed `Stake` (overlay, balance, height) | +| `nodeEffectiveStake(owner)` | Previewed balance if committed and not frozen, else 0 | +| `overlayOfAddress(owner)` | Previewed overlay if committed, else `0` | +| `heightOfAddress(owner)` | Previewed height if committed, else 0 | +| `nodeEffectiveStakeLookahead(owner, n)` | Same at round `currentRound() + n` | +| `overlayOfAddressLookahead` / `heightOfAddressLookahead` | Lookahead previews | +| `freezeUntilBlock(owner)` | Freeze deadline (exclusive: unfrozen when `block.number >` this) | +| `currentRound()` | `block.number / ROUND_LENGTH` | -#### calculateEffectiveStake() -Calculates effective stake based on committed stake and height. - -```solidity -committedStakeBzz = (2^height) * committedStake * oracle.currentPrice() -return min(committedStakeBzz, potentialStake) -``` - -#### addressNotFrozen() -Checks if a node is frozen: -```solidity -return stakes[_owner].lastUpdatedBlockNumber < block.number -``` - -#### reverse() -Byte-reverses a uint64 (for network ID in overlay calculation). - -## Stake Structure +## Data structures ```solidity struct Stake { - bytes32 overlay; // Node's overlay address - uint256 committedStake; // Chunks pledged - uint256 potentialStake; // BZZ tokens staked - uint256 lastUpdatedBlockNumber; // Update timestamp / freeze flag - uint8 height; // Reserve height multiplier + bytes32 overlay; // zero = not committed + uint256 balance; // BZZ in contract + uint8 height; +} + +struct ScheduledUpdate { + UpdateKind kind; + uint64 effectiveFromRound; + bytes32 nonce; + uint256 amount; + uint8 height; } ``` +Per-account `Account` holds `stake`, `freezeUntilBlock`, and `queue`. Freeze survives stake deletion and exit. + ## Events ```solidity -event StakeUpdated( - address indexed owner, - uint256 committedStake, - uint256 potentialStake, - bytes32 overlay, - uint256 lastUpdatedBlock, - uint8 height -); - -event OverlayChanged(address owner, bytes32 overlay); - -event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); - -event StakeFrozen(address frozen, bytes32 overlay, uint256 time); - -event StakeWithdrawn(address node, uint256 amount); +event DepositCreated(address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height); +event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); +event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); +event HeightIncreased(address indexed owner, uint64 registeredFromRound, uint8 height); +event WithdrawalQueued(address indexed owner, uint64 effectiveFromRound, uint256 amount); +event Withdrawal(address indexed owner, uint64 executedInRound, uint256 amount); +event StakeSlashed(address indexed owner, bytes32 overlay, uint256 amount); +event StakeFrozen(address indexed frozen, bytes32 indexed overlay, uint256 durationBlocks); +event AccountFreezeExtended(address indexed account, uint256 freezeUntilBlock); +event StakeMigrated(address indexed owner, uint256 totalReturned); ``` ## Roles -- **DEFAULT_ADMIN_ROLE**: Change network ID, pause/unpause -- **REDISTRIBUTOR_ROLE**: Freeze and slash stakes (typically Redistribution contract) +- **DEFAULT_ADMIN_ROLE**: pause / unpause +- **REDISTRIBUTOR_ROLE**: `freezeDeposit`, `slashDeposit` (typically granted to `Redistribution`) -## Deployment Configuration +## Deployment ```typescript -constructor(address _bzzToken, uint64 _NetworkId, address _oracleContract) +constructor( + bzzToken: address, + networkId: uint64, + waitBase: uint64, + waitOverlayChange: uint64, + waitWithdrawal: uint64 +) ``` -- `_bzzToken`: ERC20 token address for staking -- `_NetworkId`: Swarm network ID (1 for mainnet, 10 for testnet) -- `_oracleContract`: PriceOracle address for price queries +- `waitOverlayChange` and `waitWithdrawal` must be ≥ `waitBase` +- Example deploy args: `[token.address, swarmNetworkId, 2, 2, 2]` (see `deploy/*/003_deploy_staking.ts`) + +No oracle address in the constructor. ## Constants ```solidity -uint64 private constant MIN_STAKE = 100000000000000000; // 0.1 BZZ +MIN_STAKE = 10 * 1e16; // 0.1 BZZ at height 0 +ROUND_LENGTH = 152; +UPDATE_QUEUE_MAX_LENGTH = 10; +MAX_STAKING_HEIGHT = 128; ``` -## Stake Lifecycle +## Lifecycle examples -### 1. Initial Stake +### Initial deposit ```solidity -// Node calls with initial deposit -manageStake(nonce, 1000000000000000000, 1) -// Deposits 1 BZZ, sets height to 1 - -// At price 1000 chunks/BZZ, height 1: -// committedStake = 1000000000000000000 / (1000 * 2^1) = 500000 chunks -// effectiveStake = min(500000 * 1000 * 2, 1000000000000000000) = 1000000000000000000 +ERC20(bzz).approve(stakeRegistry, amount); +stakeRegistry.createDeposit(nonce, amount, height); +// ... advance rounds ... +stakeRegistry.applyUpdates(node); ``` -### 2. Stake Update - -```solidity -// Add more tokens -manageStake(nonce, 500000000000000000, 1) -// Deposits 0.5 BZZ more - -// Recalculate: -// potentialStake = 1500000000000000000 -// committedStake = 1500000000000000000 / (1000 * 2^1) = 750000 chunks -// effectiveStake = min(750000 * 1000 * 2, 1500000000000000000) = 1500000000000000000 -``` +At height 1, minimum deposit is `MIN_STAKE * 2`. -### 3. Surplus Withdrawal +### Partial withdrawal ```solidity -// Price increased from 1000 to 1200 chunks/BZZ -// committedStake = 750000 chunks -// effectiveStake = min(750000 * 1200 * 2, 1500000000000000000) = 1800000000000000000 -// But actual potentialStake = 1500000000000000000 -// Can withdraw: 0 (effective = potential) - -// OR height decreased from 1 to 0 -// effectiveStake = min(750000 * 1200 * 1, 1500000000000000000) = 900000000000000000 -// Can withdraw: 1500000000000000000 - 900000000000000000 = 600000000000000000 +stakeRegistry.withdraw(partialAmount); +// after WAIT_WITHDRAWAL rounds and applyUpdates (and not frozen): +// BZZ transferred, balance reduced ``` -### 4. Penalty Freeze +### Freeze penalty ```solidity -// Redistribution calls after node violates protocol -freezeDeposit(nodeAddress, 1000 blocks) - -// While frozen: -// nodeEffectiveStake() returns 0 -// manageStake() reverts with Frozen() +// Called by Redistribution +stakeRegistry.freezeDeposit(node, durationBlocks); +// nodeEffectiveStake(node) == 0 until block.number > freezeUntilBlock ``` ## Integration with Redistribution -The `nodeEffectiveStake()` value is used in the redistribution game to: -1. Weight commit selection during truth consensus -2. Calculate stake density for winner selection -3. Determine eligibility for participation - -## Examples - -### Creating a Stake - -```solidity -// Approve tokens first -ERC20(bzzToken).approve(stakeRegistry, 2000000000000000000); - -// Create stake -StakeRegistry(stakeRegistry).manageStake( - keccak256("my-nonce"), // nonce - 2000000000000000000, // 2 BZZ - 2 // height = 2 (4x capacity) -); -``` - -### Checking Stake Status - -```solidity -uint256 effective = StakeRegistry(stakeRegistry).nodeEffectiveStake(myAddress); -bytes32 overlay = StakeRegistry(stakeRegistry).overlayOfAddress(myAddress); -uint8 height = StakeRegistry(stakeRegistry).heightOfAddress(myAddress); -bool isFrozen = StakeRegistry(stakeRegistry).lastUpdatedBlockNumberOfAddress(myAddress) > block.number; -``` +`Redistribution` reads `overlayOfAddress`, `heightOfAddress`, and `nodeEffectiveStake` (and lookahead variants for eligibility). Commit requires `_stake != 0`. Stake density in winner selection uses the stake recorded at commit time. -### Withdrawing Surplus +Price oracle affects **postage** economics only, not stake effective balance. -```solidity -uint256 surplus = StakeRegistry(stakeRegistry).withdrawableStake(); -if (surplus > 0) { - StakeRegistry(stakeRegistry).withdrawFromStake(); -} -``` - -### Changing Overlay +## Errors (selected) ```solidity -// Change overlay without depositing -StakeRegistry(stakeRegistry).manageStake( - keccak256("new-nonce"), // new nonce - 0, // no additional deposit - 2 // keep same height -); +error BelowMinimumStake(uint256 have, uint256 need); +error NotStaked(); +error AlreadyStaked(); +error FrozenWithdrawal(); +error UpdateQueueFull(uint256 queuedCount, uint256 limit); +error QueueClosed(); +error OnlyRedistributor(); +error InvalidWithdrawalAmount(WithdrawalAmountIssue reason); +error OverlayUnchanged(); +error HeightDecreaseNotAllowed(); ``` -## Error Codes - -```solidity -error TransferFailed(); // Token transfer failed -error Frozen(); // Node is frozen -error Unauthorized(); // Only admin -error OnlyRedistributor(); // Only redistributor role -error OnlyPauser(); // Only pauser role -error BelowMinimumStake(); // First deposit below minimum -error DecreasedCommitment(); // Committed stake cannot decrease -``` - -## Security Considerations - -1. **Minimum Stake**: Prevents dust attacks -2. **Non-Decreasing Commitment**: Prevents gaming the system -3. **Freeze Mechanism**: Temporary penalty without full slash -4. **Pausability**: Emergency stop with migration path -5. **Frozen Check**: Prevents stake modifications during penalty - -## Overlay Calculation - -The `reverse()` function byte-reverses the network ID for the overlay calculation. This is done for endianness consistency between different systems that calculate overlays. - -```solidity -function reverse(uint64 input) internal pure returns (uint64 v) { - v = input; - // swap bytes - v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); - // swap 2-byte long pairs - v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); - // swap 4-byte long pairs - v = (v >> 32) | (v << 32); -} -``` +## Security and integration notes -## Related Contracts +1. **Preview vs storage**: View functions include matured queue state; Bee must use the same semantics as `commit`/`reveal` verification. +2. **Freeze**: Participation ban, not confiscation; first `freezeDeposit` may execute a due queued withdrawal. +3. **Slash**: Available on-chain but not wired from `Redistribution` yet; enabling slash should address ordering and post-slash queue invariants separately. +4. **Pause**: Stops new queue items from users; `applyUpdates` can still run. +5. **Minimum stake**: Enforced at deposit, height increase, and partial withdraw scheduling — not re-checked on every queued apply path (relevant if slash is enabled). -- **Token**: ERC20 token used for staking -- **PriceOracle**: Provides current price for calculations -- **Redistribution**: Uses effective stake for game participation +## Related contracts +- **Token**: ERC20 BZZ (`bzzToken`) +- **Redistribution**: Commit/reveal game; holds `REDISTRIBUTOR_ROLE` for penalties