diff --git a/README.md b/README.md index a53378e5..af8ced2c 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,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/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..f18c3c4c 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,14 +28,21 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); - const argStaking = [token.address, swarmNetworkID, priceOracle.address]; + const redistribution = await get('Redistribution'); + const argStaking = [ + token.address, + redistribution.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; log('Verifying...'); await verify(staking.address, argStaking); log('----------------------------------------------------'); // Verify redistribution - const redistribution = await get('Redistribution'); const argRedistribution = [staking.address, postageStamp.address, priceOracle.address]; log('Verifying...'); 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..9d5f4964 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,14 +35,21 @@ const func: DeployFunction = async function ({ deployments, network }) { // Verify staking const staking = await get('StakeRegistry'); - const argStaking = [token.address, swarmNetworkID, priceOracle.address]; + const redistribution = await get('Redistribution'); + const argStaking = [ + token.address, + redistribution.address, + swarmNetworkID, + config.stakeWaitBase || 2, + config.stakeWaitOverlayChange || 2, + config.stakeWaitWithdrawal || 2, + ]; log('Staking'); await verify(staking.address, argStaking); log('----------------------------------------------------'); // Verify redistribution - const redistribution = await get('Redistribution'); const argRedistribution = [staking.address, postageStamp.address, priceOracle.address]; log('Redistribution'); 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 diff --git a/helper-hardhat-config.ts b/helper-hardhat-config.ts index ee7ef0bb..ac60e778 100644 --- a/helper-hardhat-config.ts +++ b/helper-hardhat-config.ts @@ -2,34 +2,70 @@ export interface networkConfigItem { blockConfirmations?: number; swarmNetworkId?: number; multisig?: string; + stakeWaitBase?: number; + stakeWaitOverlayChange?: number; + stakeWaitWithdrawal?: number; } 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, 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: WITHDRAWAL_WAIT_ROUNDS, }, testnet: { blockConfirmations: 6, swarmNetworkId: 10, multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, tenderly: { blockConfirmations: 1, swarmNetworkId: 1, multisig: '0xb1C7F17Ed88189Abf269Bf68A3B2Ed83C5276aAe', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, mainnet: { blockConfirmations: 6, swarmNetworkId: 1, multisig: '0xD5C070FEb5EA883063c183eDFF10BA6836cf9816', + stakeWaitBase: 2, + stakeWaitOverlayChange: 2, + stakeWaitWithdrawal: WITHDRAWAL_WAIT_ROUNDS, }, }; diff --git a/src/PriceOracle.sol b/src/PriceOracle.sol index 5675f4f9..74a3ecc1 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 ------------------------------ /** @@ -99,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 @@ -113,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; } @@ -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 e168aa05..262cb1e5 100644 --- a/src/Redistribution.sol +++ b/src/Redistribution.sol @@ -5,28 +5,27 @@ 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 { - function adjustPrice(uint16 redundancy) external returns (bool); + function adjustPrice(uint16 _redundancy) external returns (bool); } 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); - function overlayOfAddress(address _owner) external view returns (bytes32); + function overlayOfAddressLookahead(address _owner, uint64 _lookahead) external view returns (bytes32); + function heightOfAddress(address _owner) external view returns (uint8); + function heightOfAddressLookahead(address _owner, uint64 _lookahead) external view returns (uint8); + function nodeEffectiveStake(address _owner) external view returns (uint256); + + function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) external view returns (uint256); } /** @@ -153,7 +152,8 @@ 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; + uint256 private constant PHASE_LENGTH = Constants.PHASE_LENGTH; // Maximum value of the keccack256 hash. bytes32 private constant MAX_H = 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff; @@ -200,11 +200,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 */ @@ -230,7 +225,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 @@ -264,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); } @@ -293,17 +287,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(); } @@ -316,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(); } @@ -428,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(); @@ -448,47 +437,41 @@ 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]); - // 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()); @@ -580,61 +563,61 @@ contract Redistribution is AccessControl, Pausable { currentClaimRound = cr; } - function inclusionFunction(ChunkInclusionProof calldata entryProof, uint256 indexInRC) internal { - uint256 randomChunkSegmentIndex = uint256(seed) % 128; + 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); } } @@ -791,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 ------------------------------ @@ -816,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; @@ -824,26 +807,31 @@ 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. */ function isParticipatingInUpcomingRound(address _owner, uint8 _depth) public view returns (bool) { - uint256 _lastUpdate = Stakes.lastUpdatedBlockNumberOfAddress(_owner); - uint8 _depthResponsibility = _depth - Stakes.heightOfAddress(_owner); - if (currentPhaseReveal()) { revert WrongPhase(); } - if (_lastUpdate == 0) { + uint64 lookahead = currentPhaseClaim() ? 1 : 0; + uint256 _stake = Stakes.nodeEffectiveStakeLookahead(_owner, lookahead); + + if (_stake == 0) { revert NotStaked(); } - if (_lastUpdate >= block.number - 2 * ROUND_LENGTH) { - revert MustStake2Rounds(); - } + uint8 _depthResponsibility = _depth - Stakes.heightOfAddressLookahead(_owner, lookahead); - return inProximity(Stakes.overlayOfAddress(_owner), currentRoundAnchor(), _depthResponsibility); + return + inProximity( + Stakes.overlayOfAddressLookahead(_owner, lookahead), + currentRoundAnchor(), + _depthResponsibility + ); } // ----------------------------- Reveal ------------------------------ @@ -887,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)); } /** @@ -903,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; @@ -930,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; @@ -1038,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 ae127717..93777e5c 100644 --- a/src/Staking.sol +++ b/src/Staking.sol @@ -3,10 +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"; - -interface IPriceOracle { - function currentPrice() external view returns (uint32); -} +import "./Util/Constants.sol"; /** * @title Staking contract for the Swarm storage incentives @@ -14,248 +11,564 @@ interface IPriceOracle { * @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 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 { // ----------------------------- State variables ------------------------------ + uint256 public constant ROUND_LENGTH = Constants.ROUND_LENGTH; + /// @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; + + // ----------------------------- Type declarations ------------------------------ + + enum UpdateKind { + CreateDeposit, + AddTokens, + IncreaseHeight, + ChangeOverlay, + WithdrawTokens, + ExitStake + } + + /// @dev Why `withdraw` was rejected before anything was queued. + enum WithdrawalAmountIssue { + /// Amount is zero; `withdraw` only accepts positive pulls (see `exit()` for full unwind). + Zero, + /// Amount is greater than the previewed stake balance. + ExceedsBalance + } + + /// @dev Committed stake is indicated by `overlay != bytes32(0)` (see `_hasCommittedStake`). 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 lastUpdatedBlockNumber; - // Node indicating its increased reserve + uint256 balance; uint8 height; } - // Associate every stake id with node address data. - mapping(address => Stake) public stakes; + struct ScheduledUpdate { + UpdateKind kind; + uint64 effectiveFromRound; + bytes32 nonce; + uint256 amount; + uint8 height; + } - // Role allowed to freeze and slash entries - bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); + struct UpdateQueue { + ScheduledUpdate[] items; + uint64 head; + bool closed; + } - // Swarm network ID - uint64 NetworkId; + /// @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 stake deletion. + uint256 freezeUntilBlock; + UpdateQueue queue; + } - // The miniumum stake allowed to be staked using the Staking contract. - uint64 private constant MIN_STAKE = 100000000000000000; + mapping(address => Account) private _accounts; - // Address of the staked ERC20 token - address public immutable bzzToken; + bytes32 public constant REDISTRIBUTOR_ROLE = keccak256("REDISTRIBUTOR_ROLE"); - // The address of the linked PriceOracle contract. - IPriceOracle public OracleContract; + uint64 public networkId; + address public immutable bzzToken; + uint64 public immutable WAIT_BASE; + uint64 public immutable WAIT_OVERLAY_CHANGE; + uint64 public immutable WAIT_WITHDRAWAL; // ----------------------------- Events ------------------------------ - /** - * @dev Emitted when a stake is created or updated by `owner` of the `overlay`. - */ - event StakeUpdated( + event DepositCreated( address indexed owner, - uint256 committedStake, - uint256 potentialStake, + uint64 registeredFromRound, + uint256 amount, bytes32 overlay, - uint256 lastUpdatedBlock, 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); + /// @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 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 ------------------------------ + + /// @notice ERC20 `transfer` / `transferFrom` returned false for `bzzToken`. + error TransferFailed(); + /// @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). + 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 `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); + /// @notice An exit is scheduled; no further mutations allowed until processed or migrated. + error QueueClosed(); + /// @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); + /// @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, + uint64 _networkId, + uint64 _waitBase, + uint64 _waitOverlayChange, + uint64 _waitWithdrawal + ) { + if (_waitOverlayChange < _waitBase || _waitWithdrawal < _waitBase) { + revert InvalidWaitConfiguration(_waitBase, _waitOverlayChange, _waitWithdrawal); + } + networkId = _networkId; + bzzToken = _bzzToken; + WAIT_BASE = _waitBase; + WAIT_OVERLAY_CHANGE = _waitOverlayChange; + WAIT_WITHDRAWAL = _waitWithdrawal; + _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 // + //////////////////////////////////////// /** - * @dev Emitted when a stake for address `slashed` is slashed by `amount`. + * @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. + * @return effectiveFromRound Round when the queued update becomes effective (matches event). */ - event StakeSlashed(address slashed, bytes32 overlay, uint256 amount); + function createDeposit( + bytes32 _setNonce, + uint256 _amount, + uint8 _height + ) 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); + if (_amount < minStake) revert BelowMinimumStake(_amount, minStake); + + bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); + _pullTokens(_amount); + + effectiveFromRound = _enqueueUpdate( + msg.sender, + UpdateKind.CreateDeposit, + WAIT_BASE, + _setNonce, + _amount, + _height + ); + + emit DepositCreated(msg.sender, effectiveFromRound, _amount, newOverlay, _height); + } /** - * @dev Emitted when a stake for address `frozen` is frozen for `time` blocks. + * @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). */ - event StakeFrozen(address frozen, bytes32 overlay, uint256 time); + function addTokens(uint256 _amount) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { + Stake memory plannedStake = _previewStake(msg.sender, true); + _requirePreviewStaked(plannedStake); + + _pullTokens(_amount); + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.AddTokens, WAIT_BASE, 0, _amount, 0); + + emit TokensAdded(msg.sender, effectiveFromRound, _amount); + } /** - * @dev Emitted when a address changes overlay it uses + * @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 `OverlayChanged`). + * @dev Reverts with `OverlayUnchanged` if the derived overlay equals the current one (no sentinel return value). */ - event OverlayChanged(address owner, bytes32 overlay); + function changeOverlay(bytes32 _setNonce) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { + Stake memory plannedStake = _previewStake(msg.sender, true); + _requirePreviewStaked(plannedStake); + + bytes32 newOverlay = _deriveOverlay(msg.sender, _setNonce); + if (newOverlay == plannedStake.overlay) revert OverlayUnchanged(); + + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ChangeOverlay, WAIT_OVERLAY_CHANGE, _setNonce, 0, 0); + + emit OverlayChanged(msg.sender, effectiveFromRound, newOverlay); + } /** - * @dev Emitted when a stake for address is withdrawn + * @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. */ - event StakeWithdrawn(address node, uint256 amount); + function increaseHeight(uint8 _height) external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { + Stake memory plannedStake = _previewStake(msg.sender, true); + _requirePreviewStaked(plannedStake); + if (_height < plannedStake.height) revert HeightDecreaseNotAllowed(); + if (_height == plannedStake.height) return 0; + 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); + } - // ----------------------------- Errors ------------------------------ + /** + * @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`. 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 whenQueueOpen returns (uint64 effectiveFromRound) { + if (_amount == 0) revert InvalidWithdrawalAmount(WithdrawalAmountIssue.Zero); - 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 + Stake memory plannedStake = _previewStake(msg.sender, true); + _requirePreviewStaked(plannedStake); + if (_amount > plannedStake.balance) { + revert InvalidWithdrawalAmount(WithdrawalAmountIssue.ExceedsBalance); + } + uint256 minAfterWithdraw = _minimumStakeForHeight(plannedStake.height); + uint256 balanceAfter = plannedStake.balance - _amount; + if (balanceAfter < minAfterWithdraw) revert BelowMinimumStake(balanceAfter, minAfterWithdraw); - // ----------------------------- CONSTRUCTOR ------------------------------ + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.WithdrawTokens, WAIT_WITHDRAWAL, 0, _amount, 0); + emit WithdrawalQueued(msg.sender, effectiveFromRound, _amount); + } /** - * @param _bzzToken Address of the staked ERC20 token - * @param _NetworkId Swarm network ID + * @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`). */ - constructor(address _bzzToken, uint64 _NetworkId, address _oracleContract) { - NetworkId = _NetworkId; - bzzToken = _bzzToken; - OracleContract = IPriceOracle(_oracleContract); - _setupRole(DEFAULT_ADMIN_ROLE, msg.sender); + function exit() external whenNotPaused whenQueueOpen returns (uint64 effectiveFromRound) { + Stake memory plannedStake = _previewStake(msg.sender, true); + _requirePreviewStaked(plannedStake); + + effectiveFromRound = _enqueueUpdate(msg.sender, UpdateKind.ExitStake, WAIT_WITHDRAWAL, 0, 0, 0); + _accounts[msg.sender].queue.closed = true; + emit WithdrawalQueued(msg.sender, effectiveFromRound, plannedStake.balance); } - //////////////////////////////////////// - // STATE SETTING // - //////////////////////////////////////// + /** + * @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); + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; + if ( + head < queue.items.length && + queue.items[head].effectiveFromRound <= currentRound() && + _queuedWithdrawalExecutionFrozen(_owner, queue.items[head].kind, 0) + ) { + revert FrozenWithdrawal(); + } + } /** - * @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 + * @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 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)); + function migrateStake() external whenPaused { + _applyReadyUpdates(msg.sender); + + Account storage account = _accounts[msg.sender]; + uint256 payout = account.stake.balance; + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; - // First time adding stake, check the minimum is added, take into account height - if (_addAmount < MIN_STAKE * 2 ** _height && _stakingSet == 0) { - revert BelowMinimumStake(); + 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; + } + + unchecked { + ++i; + } } - 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; + _clearStakeAndQueue(msg.sender); - // Only update stake values if _addAmount is greater than 0 - if (_addAmount > 0) { - updatedPotentialStake = stakes[msg.sender].potentialStake + _addAmount; + emit StakeMigrated(msg.sender, payout); - // Calculate new committed stake - uint256 newCommittedStake = updatedPotentialStake / (OracleContract.currentPrice() * 2 ** _height); + if (payout > 0) { + if (!ERC20(bzzToken).transfer(msg.sender, payout)) revert TransferFailed(); + } + } - // Never allow commitment to decrease - if (newCommittedStake < previousCommittedStake) { - revert DecreasedCommitment(); + /** + * @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 and stake deletion. + */ + function freezeDeposit(address _owner, uint256 _time) external whenNotPaused onlyRedistributor { + uint256 until = block.number + _time; + + // No stake and no queue: only record account-level penalty. + if (!_hasCommittedStake(_owner) && _queueLength(_owner) == 0) { + if (_accounts[_owner].freezeUntilBlock < until) { + _accounts[_owner].freezeUntilBlock = until; + emit AccountFreezeExtended(_owner, _accounts[_owner].freezeUntilBlock); } - - updatedCommittedStake = newCommittedStake; + return; } - stakes[msg.sender] = Stake({ - overlay: _newOverlay, - committedStake: updatedCommittedStake, - potentialStake: updatedPotentialStake, - lastUpdatedBlockNumber: block.number, - height: _height - }); - - // 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( - msg.sender, - updatedCommittedStake, - updatedPotentialStake, - _newOverlay, - block.number, - _height - ); + // 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); + + if (_accounts[_owner].freezeUntilBlock < until) { + _accounts[_owner].freezeUntilBlock = until; } - // Emit overlay change event - if (_previousOverlay != _newOverlay) { - emit OverlayChanged(msg.sender, _newOverlay); + if (_hasCommittedStake(_owner)) { + emit StakeFrozen(_owner, _accounts[_owner].stake.overlay, _time); } } /** - * @dev Withdraw node stake surplus + * @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 withdrawFromStake() external { - uint256 _potentialStake = stakes[msg.sender].potentialStake; - uint256 _surplusStake = _potentialStake - - calculateEffectiveStake(stakes[msg.sender].committedStake, _potentialStake, stakes[msg.sender].height); - - if (_surplusStake > 0) { - stakes[msg.sender].potentialStake -= _surplusStake; - if (!ERC20(bzzToken).transfer(msg.sender, _surplusStake)) revert TransferFailed(); - emit StakeWithdrawn(msg.sender, _surplusStake); + function slashDeposit(address _owner, uint256 _amount) external whenNotPaused onlyRedistributor { + if (_amount == 0) revert InvalidAmount(); + + _applyReadyUpdates(_owner); + + Stake storage stake = _accounts[_owner].stake; + bytes32 previousOverlay = stake.overlay; + + if (previousOverlay != bytes32(0)) { + if (stake.balance > _amount) { + stake.balance -= _amount; + _reconcileQueuedWithdrawals(_owner); + _syncHeightToBalance(stake); + } else if (_queueLength(_owner) > 0) { + stake.balance = 0; + _reconcileQueuedWithdrawals(_owner); + } else { + _clearStake(_owner); + } + emit StakeSlashed(_owner, previousOverlay, _amount); } } /** - * @dev Migrate stake only when the staking contract is paused, - * can only be called by the owner of the stake + * @notice Pauses staking mutations. */ - 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]; - } + function pause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _pause(); } /** - * @dev Freeze an existing stake, can only be called by the redistributor - * @param _owner the addres selected - * @param _time penalty length in blocknumbers + * @notice Unpauses staking mutations. */ - function freezeDeposit(address _owner, uint256 _time) external { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + + /** + * @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 { + delete _accounts[_owner].queue; + } + + function _clearStakeAndQueue(address _owner) internal { + _clearStake(_owner); + _clearQueue(_owner); + } - if (stakes[_owner].lastUpdatedBlockNumber != 0) { - stakes[_owner].lastUpdatedBlockNumber = block.number + _time; - emit StakeFrozen(_owner, stakes[_owner].overlay, _time); + function _applyReadyUpdates(address _owner) internal { + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; + uint64 roundNumber = currentRound(); + + 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; `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.items.length) { + _clearQueue(_owner); + } else { + queue.head = uint64(head); } } /** - * @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 + * @notice Applies a single queued update to storage. + * @dev Three paths: partial withdrawal (transfer + balance), full exit (delete stake + transfer), + * or stake mutation via `_simulateUpdate` (deposit, add, height, overlay). */ - function slashDeposit(address _owner, uint256 _amount) external { - if (!hasRole(REDISTRIBUTOR_ROLE, msg.sender)) revert OnlyRedistributor(); + 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 = _accounts[_owner].stake; + 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 (stakes[_owner].lastUpdatedBlockNumber != 0) { - if (stakes[_owner].potentialStake > _amount) { - stakes[_owner].potentialStake -= _amount; - stakes[_owner].lastUpdatedBlockNumber = block.number; - } else { - delete stakes[_owner]; + // Path 2: full exit — delete stake and return all remaining BZZ. + if (_scheduled.kind == UpdateKind.ExitStake) { + uint256 balance = _accounts[_owner].stake.balance; + _clearStake(_owner); + if (balance > 0) { + if (!ERC20(bzzToken).transfer(_owner, balance)) revert TransferFailed(); + emit Withdrawal(_owner, currentRound(), balance); } + return; } - emit StakeSlashed(_owner, stakes[_owner].overlay, _amount); + + // Path 3: stake mutations — CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay (no token transfer). + Stake memory s = _accounts[_owner].stake; + s = _simulateUpdate(_owner, s, _scheduled); + _accounts[_owner].stake = s; } - function changeNetworkId(uint64 _NetworkId) external { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert Unauthorized(); - NetworkId = _NetworkId; + /** + * @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; + + _accounts[_owner].queue.items.push( + ScheduledUpdate({ + kind: _kind, + effectiveFromRound: effectiveFromRound, + nonce: _nonce, + amount: _amount, + height: _height + }) + ); + } + /** + * @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(); } /** - * @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` + * @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 pause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); - _pause(); + function _reconcileQueuedWithdrawals(address _owner) internal { + UpdateQueue storage queue = _accounts[_owner].queue; + uint256 head = queue.head; + Stake memory preview = _accounts[_owner].stake; + + 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) { + scheduled.amount = preview.balance; + } + } + + preview = _simulateUpdate(_owner, preview, scheduled); + + unchecked { + ++i; + } + } } /** - * @dev Unpause the contract, can only be called by the pauser when paused + * @notice Lowers height so `balance` satisfies `_minimumStakeForHeight(height)` when possible, happens in slashing */ - function unPause() public { - if (!hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) revert OnlyPauser(); - _unpause(); + 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; } //////////////////////////////////////// @@ -263,93 +576,293 @@ contract StakeRegistry is AccessControl, Pausable { //////////////////////////////////////// /** - * @dev Checks to see if `address` is frozen. - * @param _owner owner of staked address - * - * Returns a boolean value indicating whether the operation succeeded. + * @notice Returns the end block of the protocol freeze for an account. */ - function addressNotFrozen(address _owner) internal view returns (bool) { - return stakes[_owner].lastUpdatedBlockNumber < block.number; + function freezeUntilBlock(address _owner) external view returns (uint256) { + return _accounts[_owner].freezeUntilBlock; } /** - * @dev Returns the current `effectiveStake` of `address`. previously usable stake - * @param _owner _owner of node + * @notice Returns the currently visible stake state for an owner. + */ + function stakes(address _owner) public view returns (Stake memory) { + return _previewStake(_owner, false); + } + + /** + * @notice Returns the currently effective stake balance for an owner. */ function nodeEffectiveStake(address _owner) public view returns (uint256) { - return - addressNotFrozen(_owner) - ? calculateEffectiveStake( - stakes[_owner].committedStake, - stakes[_owner].potentialStake, - stakes[_owner].height - ) - : 0; + if (!_addressNotFrozen(_owner)) return 0; + + Stake memory preview = _previewStake(_owner, false); + return _hasCommittedStake(preview) ? preview.balance : 0; } /** - * @dev Check the amount that is possible to withdraw as surplus + * @notice Returns the currently effective overlay for an owner. */ - function withdrawableStake() public view returns (uint256) { - uint256 _potentialStake = stakes[msg.sender].potentialStake; - return - _potentialStake - - calculateEffectiveStake(stakes[msg.sender].committedStake, _potentialStake, stakes[msg.sender].height); + function overlayOfAddress(address _owner) public view returns (bytes32) { + Stake memory preview = _previewStake(_owner, false); + return _hasCommittedStake(preview) ? preview.overlay : bytes32(0); } /** - * @dev Returns the `lastUpdatedBlockNumber` of `address`. + * @notice Returns the currently effective height for an owner. */ - function lastUpdatedBlockNumberOfAddress(address _owner) public view returns (uint256) { - return stakes[_owner].lastUpdatedBlockNumber; + function heightOfAddress(address _owner) public view returns (uint8) { + Stake memory preview = _previewStake(_owner, false); + return _hasCommittedStake(preview) ? preview.height : 0; } /** - * @dev Returns the currently used overlay of the address. - * @param _owner address of node + * @notice Returns the effective stake that would be active after the given round lookahead. */ - function overlayOfAddress(address _owner) public view returns (bytes32) { - return stakes[_owner].overlay; + function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) public view returns (uint256) { + if (!_addressNotFrozenLookahead(_owner, _lookahead)) return 0; + + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); + return _hasCommittedStake(preview) ? preview.balance : 0; } /** - * @dev Returns the currently height of the address. - * @param _owner address of node + * @notice Returns the overlay that would be active after the given round lookahead. */ - function heightOfAddress(address _owner) public view returns (uint8) { - return stakes[_owner].height; + function overlayOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (bytes32) { + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); + return _hasCommittedStake(preview) ? preview.overlay : bytes32(0); } - 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(); + /** + * @notice Returns the height that would be active after the given round lookahead. + */ + function heightOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (uint8) { + Stake memory preview = _previewStakeLookahead(_owner, _lookahead); + return _hasCommittedStake(preview) ? 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); + } - // Return the minimum value between committedStakeBzz and potentialStakeBalance - if (committedStakeBzz < potentialStakeBalance) { - return committedStakeBzz; + /** + * @dev True when `_accounts[_owner].freezeUntilBlock < block.number` (current block is past the penalty window). + */ + function _addressNotFrozen(address _owner) internal view returns (bool) { + return _accounts[_owner].freezeUntilBlock < block.number; + } + + /** + * @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 _queuedWithdrawalExecutionFrozen( + address _owner, + UpdateKind _kind, + uint64 _lookaheadRounds + ) internal view returns (bool) { + if (_kind != UpdateKind.WithdrawTokens && _kind != UpdateKind.ExitStake) { + return false; + } + return !_addressNotFrozenLookahead(_owner, _lookaheadRounds); + } + + /** + * @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) { + Account storage account = _accounts[_owner]; + preview = account.stake; + + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; + + if (_includeFutureUpdates) { + for (uint256 i = head; i < queue.items.length; ) { + preview = _simulateUpdate(_owner, preview, queue.items[i]); + unchecked { + ++i; + } + } } else { - return potentialStakeBalance; + 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); + + unchecked { + ++i; + } + } } } /** - * @dev Please both Endians 🥚. - * @param input Eth address used for overlay calculation. + * @notice Previews stake state as it would look after the given round lookahead. */ - function reverse(uint64 input) internal pure returns (uint64 v) { - v = input; + function _previewStakeLookahead(address _owner, uint64 _lookahead) internal view returns (Stake memory preview) { + Account storage account = _accounts[_owner]; + preview = account.stake; + + UpdateQueue storage queue = account.queue; + uint256 head = queue.head; + uint64 targetRound = currentRound() + _lookahead; + + for (uint256 i = head; i < queue.items.length; ) { + ScheduledUpdate storage scheduled = queue.items[i]; + if ( + scheduled.effectiveFromRound > targetRound || + _queuedWithdrawalExecutionFrozen(_owner, scheduled.kind, _lookahead) + ) { + break; + } - // swap bytes - v = ((v & 0xFF00FF00FF00FF00) >> 8) | ((v & 0x00FF00FF00FF00FF) << 8); + preview = _simulateUpdate(_owner, preview, scheduled); - // swap 2-byte long pairs - v = ((v & 0xFFFF0000FFFF0000) >> 16) | ((v & 0x0000FFFF0000FFFF) << 16); + unchecked { + ++i; + } + } + } + + /** + * @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 _simulateUpdate( + 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; + _preview.height = _scheduled.height; + return _preview; + } + + if (_scheduled.kind == UpdateKind.AddTokens) { + _preview.balance += _scheduled.amount; + return _preview; + } - // swap 4-byte long pairs + if (_scheduled.kind == UpdateKind.IncreaseHeight) { + if (_hasCommittedStake(_preview) && _scheduled.height > _preview.height) { + _preview.height = _scheduled.height; + } + return _preview; + } + + if (_scheduled.kind == UpdateKind.ChangeOverlay) { + if (_hasCommittedStake(_preview)) { + _preview.overlay = _deriveOverlay(_owner, _scheduled.nonce); + } + return _preview; + } + + if (_scheduled.kind == UpdateKind.WithdrawTokens) { + if (_hasCommittedStake(_preview)) { + if (_scheduled.amount >= _preview.balance) { + _preview.balance = 0; + } else { + _preview.balance -= _scheduled.amount; + } + } + return _preview; + } + + if (_scheduled.kind == UpdateKind.ExitStake) { + delete _preview; + } + + return _preview; + } + + /** + * @notice Returns the effective round of the last queued update. + */ + function _lastScheduledRound(address _owner) internal view returns (uint64) { + ScheduledUpdate[] storage items = _accounts[_owner].queue.items; + if (_queueLength(_owner) == 0) { + return 0; + } + return items[items.length - 1].effectiveFromRound; + } + + /** + * @notice Returns the number of pending queued updates. + */ + function _queueLength(address _owner) internal view returns (uint256) { + UpdateQueue storage queue = _accounts[_owner].queue; + return queue.items.length - queue.head; + } + + /** + * @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 _lookaheadRounds) internal view returns (bool) { + if (_lookaheadRounds == 0) { + return _addressNotFrozen(_owner); + } + + return + _accounts[_owner].freezeUntilBlock < (uint256(currentRound()) + uint256(_lookaheadRounds)) * ROUND_LENGTH; + } + + /** + * @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); + } + + /** + * @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)); + } + + 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`. + */ + function _hasCommittedStake(address _owner) internal view returns (bool) { + return _accounts[_owner].stake.overlay != bytes32(0); + } + + /// @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); + } + + /** + * @notice Reverses byte order for network id encoding in overlay derivation. + */ + 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/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 new file mode 100644 index 00000000..f4f2f6fb --- /dev/null +++ b/src/Util/Constants.sol @@ -0,0 +1,25 @@ +// 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; + + /// @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/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..e8127e66 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"; @@ -83,6 +84,8 @@ contract EchidnaPostageStampPotMock is IPostageStamp { } contract RedistributionClaimStub is Redistribution { + event WithdrawFailed(address indexed winner); + constructor( address staking, address postageContract, @@ -130,7 +133,8 @@ 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; + uint256 internal constant PHASE_LENGTH = Constants.PHASE_LENGTH; TestToken internal immutable token; EchidnaStakeRegistryMock internal immutable stakeMock; @@ -217,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 0631e744..f34d46e1 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.PHASE_LENGTH - 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/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..7139c13a --- /dev/null +++ b/src/echidna/EchidnaStakingHarness.sol @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: BSD-3-Clause +pragma solidity ^0.8.19; + +import "../TestToken.sol"; +import "../Staking.sol"; +import "../Util/Constants.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 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 = Constants.MIN_STAKE; + 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; + + 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); + 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); + 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_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_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..66d30672 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 { @@ -26,12 +27,12 @@ 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 +117,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 +135,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 +151,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( @@ -223,15 +224,13 @@ 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.PHASE_LENGTH - 1) return; 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)); diff --git a/test/PostageStamp.test.ts b/test/PostageStamp.test.ts index 4f05706b..80a4d171 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..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: { @@ -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/Redistribution.test.ts b/test/Redistribution.test.ts index 443e461c..267d73d2 100644 --- a/test/Redistribution.test.ts +++ b/test/Redistribution.test.ts @@ -15,6 +15,8 @@ import { getWalletOfFdpPlayQueen, WITNESS_COUNT, skippedRoundsIncrease, + ROUND_LENGTH, + PHASE_LENGTH, } from './util/tools'; import { proximity } from './util/tools'; import { node5_proof1, node5_soc_proof1 } from './claim-proofs'; @@ -35,8 +37,8 @@ import { randomBytes } from 'crypto'; import { constructPostageStamp } from './util/postage'; const { read, execute } = deployments; -const phaseLength = 38; -const roundLength = 152; +const phaseLength = PHASE_LENGTH; +const roundLength = ROUND_LENGTH; const increaseRate = [1049417, 1049206, 1048996, 1048786, 1048576, 1048366, 1048156, 1047946, 1047736]; @@ -59,13 +61,11 @@ 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; -//fake -const overlay_f = '0xf4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f415'; const depth_f = '0x0000000000000000000000000000000000000000000000000000000000000007'; const reveal_nonce_f = '0xf4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f4153f415'; @@ -83,8 +83,9 @@ const height_1 = 0; let node_2: string; const overlay_2 = '0xa40db58e368ea6856a24c0264ebd73b049f3dc1c2347b1babc901d3e09842dec'; const stakeAmount_2 = '100000000000000000'; -const effectiveStakeAmount_2 = '99999999999984000'; -const effectiveStakeAmount_2_n_2 = '100000000000000000'; +const effectiveStakeAmount_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'; @@ -112,29 +114,12 @@ 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; 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 @@ -165,15 +150,12 @@ 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 = { commit: { notOwner: 'NotMatchingOwner()', notStaked: 'NotStaked()', - mustStake2Rounds: 'MustStake2Rounds()', alreadyCommitted: 'AlreadyCommitted()', }, reveal: { @@ -186,6 +168,7 @@ const errors = { claim: { noReveals: 'NoReveals()', alreadyClaimed: 'AlreadyClaimed()', + postageWithdrawFailed: 'OnlyRedistributor()', randomCheckFailed: 'RandomElementCheckFailed()', outOfDepth: 'OutOfDepth()', reserveCheckFailed: 'ReserveCheckFailed()', @@ -204,7 +187,7 @@ const errors = { noBalance: 'ERC20: insufficient allowance', noZeroAddress: 'owner cannot be the zero address', onlyOwner: 'Unauthorized()', - belowMinimum: 'BelowMinimumStake()', + belowMinimum: 'BelowMinimumStake', }, general: { onlyPauser: 'OnlyPauser()', @@ -244,7 +227,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 ); @@ -253,26 +235,26 @@ 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; 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 ); }); 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; 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 ); }); @@ -280,7 +262,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 ); }); @@ -288,13 +270,13 @@ 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; 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 ); }); }); @@ -347,32 +329,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 @@ -473,6 +456,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 () { @@ -530,7 +528,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); @@ -584,7 +584,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; @@ -783,7 +785,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)); }); }); @@ -1166,7 +1168,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) @@ -1178,7 +1180,7 @@ describe('Redistribution', function () { proofParams.proof1.socProof![0] = await getSocProofAttachment( proofParams.proof1.socProof![0].chunkAddr, - randomBytes(32), + Uint8Array.from(randomBytes(32)), depth ); @@ -1194,7 +1196,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) @@ -1228,7 +1230,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; @@ -1243,7 +1245,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) @@ -1255,7 +1257,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) @@ -1267,7 +1269,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) @@ -1277,7 +1279,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) @@ -1287,7 +1289,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) @@ -1297,7 +1299,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) @@ -1419,7 +1421,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); @@ -1427,10 +1429,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; @@ -1453,16 +1461,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)); @@ -1489,6 +1497,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 10eb3f12..07305996 100644 --- a/test/Staking.test.ts +++ b/test/Staking.test.ts @@ -1,70 +1,71 @@ import { expect } from './util/chai'; import { ethers, deployments, getNamedAccounts } from 'hardhat'; -import { Contract } from 'ethers'; -import { mineNBlocks, getBlockNumber } from './util/tools'; +import { BigNumber, Contract, ContractTransaction, Event } from 'ethers'; +import { mineNBlocks, ROUND_LENGTH } 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; +/** Blocks per staking round; overwritten from `StakeRegistry.ROUND_LENGTH()` after fixture load. */ +let roundLength = ROUND_LENGTH; const zeroBytes32 = '0x0000000000000000000000000000000000000000000000000000000000000000'; const freezeTime = 3; const errors = { deposit: { noBalance: 'ERC20: insufficient allowance', - noZeroAddress: 'owner cannot be the zero address', - onlyOwner: 'Unauthorized()', - belowMinimum: 'BelowMinimumStake()', + belowMinimum: 'BelowMinimumStake', + heightDecrease: 'HeightDecreaseNotAllowed()', + }, + withdraw: { + invalidWithdrawalAmountZero: 'InvalidWithdrawalAmount(0)', + invalidWithdrawalAmountExceedsBalance: 'InvalidWithdrawalAmount(1)', + notStaked: 'NotStaked()', }, slash: { noRole: 'OnlyRedistributor()', + invalidAmount: 'InvalidAmount()', }, freeze: { noRole: 'OnlyRedistributor()', - currentlyFrozen: 'Frozen()', }, pause: { - noRole: 'OnlyPauser()', + noRole: 'Unauthorized()', currentlyPaused: 'Pausable: paused', notCurrentlyPaused: 'Pausable: not paused', - onlyPauseCanUnPause: 'OnlyPauser()', + onlyPauseCanUnPause: 'Unauthorized()', }, - commitment: { - decrease: 'DecreasedCommitment()', + general: { + overlayUnchanged: 'OverlayUnchanged()', + frozenWithdrawal: 'FrozenWithdrawal()', + queueFull: 'UpdateQueueFull', + queueClosed: 'QueueClosed()', + invalidWaitConfig: 'InvalidWaitConfiguration', }, }; -let staker_0: string; const overlay_0 = '0xa602fa47b3e8ce39ffc2017ad9069ff95eb58c051b1cfa2b0d86bc44a5433733'; -const stakeAmount_0 = '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 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_0 = '0xb5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33b5555b33'; const nonce_1_n_25 = '0x00000000000000000000000000000000000000000000000000000000000325dd'; -const height_1 = 0; -const height_1_n_1 = 1; - -const zeroStake = '0'; -const zeroAmount = '0'; +const obfuscatedHash_0 = nonce_0; +const stakeAmount_0 = '100000000000000000'; +const doubleStakeAmount_0 = '200000000000000000'; +const tripleStakeAmount_0 = '300000000000000000'; +const withdrawAmount = stakeAmount_0; +const doubleWithdrawAmount = doubleStakeAmount_0; +const slashAmount = '50000000000000000'; +const doubleSlashAmount = doubleStakeAmount_0; +const partialSlashBalance = slashAmount; +const height_0 = 0; +const height_0_n_1 = 1; -// Before the tests, set named accounts and read deployments. before(async function () { const namedAccounts = await getNamedAccounts(); deployer = namedAccounts.deployer; @@ -84,627 +85,911 @@ 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'); +async function advanceRounds(rounds = 2) { + await mineNBlocks(roundLength * rounds); +} - const pauserRole = await read('StakeRegistry', 'DEFAULT_ADMIN_ROLE'); - await execute('StakeRegistry', { from: deployer }, 'grantRole', pauserRole, pauser); - }); +async function advanceToRoundCommitPhase(redistribution: Contract, targetRound: BigNumber) { + while (true) { + const currentRound = await redistribution.currentRound(); + const inCommitPhase = await redistribution.currentPhaseCommit(); + if (currentRound.eq(targetRound) && inCommitPhase) { + return; + } + await mineNBlocks(1); + } +} - it('should deploy StakeRegistry', async function () { - expect(stakeRegistry.address).to.be.properAddress; - }); +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); + await advanceRounds(); + await contract.applyUpdates(owner); +} - it('should set the pauser role', async function () { - const pauserRole = await stakeRegistry.DEFAULT_ADMIN_ROLE(); - expect(await stakeRegistry.hasRole(pauserRole, pauser)).to.be.true; - }); +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; +} - 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; - }); +/** Effective staking round from enqueue events (ABI may expose `registeredFromRound`). */ +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); +} - it('should set the correct token', async function () { - const token = await ethers.getContract('TestToken'); - expect(await stakeRegistry.bzzToken()).to.be.eq(token.address); - }); +/** 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: unknown) { + const err = e as { message?: string; error?: { message?: string } }; + const combined = `${err.message ?? ''}${err.error?.message ?? ''}`; + expect(combined).to.include(substring); + } +} + +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); }); - 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 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); + }); - 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 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(); - it('should deposit stake correctly if funds are available', async function () { - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await mintAndApprove(staker_0, srStaker0.address, 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); - const updatedBlockNumber = (await getBlockNumber()) + 3; + 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); - 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); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_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 token.balanceOf(staker_0)).to.be.eq(0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await srStaker0.overlayOfAddress(staker_0)).to.be.eq(zeroBytes32); - const staked = await sr_staker_0.stakes(staker_0); + await advanceRounds(); - 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); + 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 token.balanceOf(stakeRegistry.address)).to.be.eq(stakeAmount_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_0); - it('should update stake correctly if funds are available', async function () { - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await expect(srStaker1.createDeposit(nonce_0, stakeAmount_0, height_0_n_1)).to.be.revertedWith( + errors.deposit.belowMinimum + ); + }); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - expect(await token.balanceOf(staker_0)).to.be.eq(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); - sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + 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'); - const lastUpdatedBlockNumber = (await getBlockNumber()) + 3; + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0); - 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.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); + expect(await srStaker0.heightOfAddress(staker_0)).to.be.eq(height_0_n_1); + }); - 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); - }); + 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_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); + + await advanceRounds(); + expect(await srStaker1.overlayOfAddress(staker_1)).to.be.eq(overlay_1_n_25); }); - describe('slashing stake', function () { - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); + 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); - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await expect(srStaker0.increaseHeight(height_0)).to.be.revertedWith(errors.deposit.heightDecrease); + }); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + it('should preview queued stake state with lookahead', async function () { + const srStaker1 = await ethers.getContract('StakeRegistry', staker_1); + await activateStake(srStaker1, staker_1, nonce_0, stakeAmount_0, height_0); - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); - await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - }); + await mintAndApprove(staker_1, srStaker1.address, stakeAmount_0); + await srStaker1.addTokens(stakeAmount_0); + await srStaker1.changeOverlay(nonce_1_n_25); + await srStaker1.increaseHeight(height_0_n_1); - 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); - }); + 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_0); - it('should slash staked deposit with redistributor role', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + 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_0_n_1); + }); - await stakeRegistryRedistributor.slashDeposit(staker_0, stakeAmount_0); + 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); - 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); - }); + const priceOracle = await ethers.getContract('PriceOracle', deployer); + await priceOracle.setPrice(24000); + await mineNBlocks(1); - it('should restake slashed deposit', async function () { - const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + }); - await stakeRegistryRedistributor.slashDeposit(staker_0, 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); - const sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await mineNBlocks(1); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + const staked = await srStaker0.stakes(staker_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(staked.balance); + }); - const lastUpdatedBlockNumber = await getBlockNumber(); - const staked = await stakeRegistry.stakes(staker_0); - expect(staked.overlay).to.be.eq(overlay_0); + 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); - expect(staked.potentialStake).to.be.eq(stakeAmount_0); - expect(staked.lastUpdatedBlockNumber).to.be.eq(lastUpdatedBlockNumber); - }); + 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); + + await advanceRounds(); + + 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); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(stakeAmount_0); }); - describe('freezing stake', function () { - let sr_staker_0: Contract; + 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); - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await srStaker0.withdraw(withdrawAmount); + await advanceRounds(); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.freezeDeposit(staker_0, freezeTime); - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); - await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - }); + 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); - 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); - }); + await mineNBlocks(freezeTime + 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 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); - const staked = await stakeRegistryRedistributor.stakes(staker_0); - const updatedBlockNumber = (await getBlockNumber()) + 3; + const withdrawalReceipt = await (await srStaker0.withdraw(withdrawAmount)).wait(); + const withdrawalEvent = withdrawalReceipt.events?.find((event: Event) => event.event === 'WithdrawalQueued'); + const effectiveRound = withdrawalEvent?.args?.effectiveFromRound ?? withdrawalEvent?.args?.[1]; + await advanceToRoundCommitPhase(redistribution, effectiveRound); - expect(staked.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); - }); + const currentRound = await redistribution.currentRound(); + await redistribution.commit(obfuscatedHash_0, currentRound); - 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); + await srStaker0.applyUpdates(staker_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); - const staked = await stakeRegistryRedistributor.stakes(staker_0); - const updatedBlockNumber = (await getBlockNumber()) + 3; + await mineNBlocks(roundLength); - expect(staked.lastUpdatedBlockNumber).to.be.eq(updatedBlockNumber); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(stakeAmount_0); + expect(await token.balanceOf(staker_0)).to.be.eq(withdrawAmount); + }); - 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 - ); + 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); - mineNBlocks(3); - - 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 - ); - }); + const exitReceipt = await (await srStaker0.exit()).wait(); + const exitEvent = exitReceipt.events?.find((event: Event) => event.event === 'WithdrawalQueued'); + 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); + + 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); }); - describe('pause contract', function () { - let sr_staker_0: Contract; + 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); - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry'); + await expect(srStaker0.exit()).to.emit(srStaker0, 'WithdrawalQueued'); - sr_staker_0 = await ethers.getContract('StakeRegistry', staker_0); + await advanceRounds(); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); - await sr_staker_0.manageStake(nonce_0, stakeAmount_0, 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); + }); - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const pauserRole = await stakeRegistryDeployer.DEFAULT_ADMIN_ROLE(); - await stakeRegistryDeployer.grantRole(pauserRole, pauser); - }); + 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); - 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); - }); + await srStaker0.exit(); - it('should pause contract with pauser role', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0)).to.be.revertedWith( + errors.general.queueClosed + ); + }); - 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 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); - it('should not unpause contract without pauser role', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + await srStaker0.exit(); + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); + expect((await srStaker0.stakes(staker_0)).overlay).to.be.eq(zeroBytes32); - const stakeRegistryStaker1 = await ethers.getContract('StakeRegistry', staker_0); - await expect(stakeRegistryStaker1.unPause()).to.be.revertedWith(errors.pause.onlyPauseCanUnPause); - }); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_1_n_25, stakeAmount_0, height_0)).to.emit(srStaker0, 'DepositCreated'); - it('should not allow staking while paused', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + 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); + }); - 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 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); - it('should allow staking once unpaused', async function () { - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + await srStaker0.exit(); + await advanceRounds(); - await mintAndApprove(staker_0, stakeRegistry.address, stakeAmount_0); + 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); - await expect(sr_staker_0.manageStake(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( - errors.pause.currentlyPaused - ); + 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 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 - ); - }); + 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); }); - describe('stake surplus withdrawl and stake migrate', function () { - let sr_staker_0: Contract; - let sr_staker_1: Contract; - let updatedBlockNumber: number; + 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); + }); - 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); + 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); - // 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); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); - updatedBlockNumber = await getBlockNumber(); + const longFreeze = roundLength * 10; + const shortFreeze = 5; + await srRedis.freezeDeposit(staker_0, longFreeze); + const untilAfterLong = await srStaker0.freezeUntilBlock(staker_0); - const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); - const pauserRole = await stakeRegistryDeployer.DEFAULT_ADMIN_ROLE(); - await stakeRegistryDeployer.grantRole(pauserRole, pauser); - }); + await mineNBlocks(3); + await srRedis.freezeDeposit(staker_0, shortFreeze); + const untilAfterShort = await srStaker0.freezeUntilBlock(staker_0); - 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); - }); + expect(untilAfterShort).to.be.eq(untilAfterLong); + }); - it('should allow stake migration while paused', async function () { - const staked_before = await sr_staker_0.stakes(staker_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); + await expect(srStaker0.exit()).to.be.revertedWith(errors.withdraw.notStaked); - 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); + await activateStake(srStaker0, staker_0, nonce_0, stakeAmount_0, height_0); + await expectRevertReasonSubstring(srStaker0.withdraw(stakeAmount_0), errors.deposit.belowMinimum); - const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); - await stakeRegistryPauser.pause(); + const overdraw = BigNumber.from(stakeAmount_0).add(1).toString(); + await expectRevertReasonSubstring( + srStaker0.withdraw(overdraw), + errors.withdraw.invalidWithdrawalAmountExceedsBalance + ); + }); - expect(await token.balanceOf(staker_0)).to.be.eq(zeroAmount); + 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); - await sr_staker_0.migrateStake(); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - const staked_after = await sr_staker_0.stakes(staker_0); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(stakeRegistryRedistributor.freezeDeposit(staker_0, longFreezeTime)) + .to.emit(srStaker0, 'StakeFrozen') + .withArgs(staker_0, overlay_0, longFreezeTime); - expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); - 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 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'); - await stakeRegistryPauser.unPause(); - }); + await advanceRounds(); + await srStaker0.applyUpdates(staker_0); - 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 - ); - }); + 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); - 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); + await mineNBlocks(longFreezeTime + 1); - 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); + 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); + }); - // 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); + 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); - 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(); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - 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); - }); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.freezeDeposit(staker_0, longFreezeTime); - 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 expect(srStaker0.withdraw(withdrawAmount)).to.emit(srStaker0, 'WithdrawalQueued'); + await advanceRounds(); - await priceOracle.setPrice(24000); + 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); - // 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); + await mineNBlocks(longFreezeTime + 1); - 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); + 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); + }); - // Check that balance of wallet is 0 in the begining - expect(await token.balanceOf(staker_0)).to.be.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); - await sr_staker_0.withdrawFromStake(); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); - const tokenBalance = (await token.balanceOf(staker_0)).toString(); - const potentialStakeBalance = staked_after.potentialStake.toString(); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await expect(stakeRegistryRedistributor.slashDeposit(staker_0, '0')).to.be.revertedWith(errors.slash.invalidAmount); - 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); - }); + await expect(stakeRegistryRedistributor.slashDeposit(staker_0, slashAmount)) + .to.emit(srStaker0, 'StakeSlashed') + .withArgs(staker_0, overlay_0, slashAmount); - it('should make stake surplus withdrawal when height increases and then decreases', async function () { - const priceOracle = await ethers.getContract('PriceOracle', deployer); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(partialSlashBalance); - await priceOracle.setPrice(24000); - const price = await priceOracle.currentPrice(); + await stakeRegistryRedistributor.slashDeposit(staker_0, partialSlashBalance); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); + }); - // 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); + 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); - 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); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + const redistributorRole = await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(); + await stakeRegistryDeployer.grantRole(redistributorRole, redistributor); - // 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(); + await srStaker0.withdraw(doubleWithdrawAmount); - // 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); + const stakeRegistryRedistributor = await ethers.getContract('StakeRegistry', redistributor); + await stakeRegistryRedistributor.slashDeposit(staker_0, doubleSlashAmount); - const withdrawbleStakeAfter = await sr_staker_0.withdrawableStake(); - expect(withdrawbleStakeAfter.gt(withdrawbleStakeBefore)).to.be.true; - await sr_staker_0.withdrawFromStake(); + await advanceRounds(); - const potentialStakeBalance = staked_after.potentialStake.toString(); - const tokenBalance = await token.balanceOf(staker_0); - const effectiveStake = (await sr_staker_0.nodeEffectiveStake(staker_0)).toString(); + expect(await srStaker0.nodeEffectiveStake(staker_0)).to.be.eq(0); + expect(await token.balanceOf(staker_0)).to.be.eq(0); - expect(String(potentialStakeBalance - tokenBalance)).to.be.eq(effectiveStake); - expect(tokenBalance).to.not.eq(zeroAmount); - }); + await srStaker0.applyUpdates(staker_0); - 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(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(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); + 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); - // 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); + const stakeRegistryDeployer = await ethers.getContract('StakeRegistry', deployer); + await stakeRegistryDeployer.grantRole(await stakeRegistryDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); - 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(); + await srRedis.slashDeposit(staker_0, slashAmount); - 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); + 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); + }); - // 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'); + 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' + ); + }); - 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 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); - 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); + const srDeployer = await ethers.getContract('StakeRegistry', deployer); + await srDeployer.grantRole(await srDeployer.REDISTRIBUTOR_ROLE(), redistributor); + const srRedis = await ethers.getContract('StakeRegistry', redistributor); - 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 srPauser = await ethers.getContract('StakeRegistry', pauser); + await srPauser.pause(); - // 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 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); + }); - await sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_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); + await srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0); - expect(staked_before.potentialStake).to.be.eq(staked_after.potentialStake); - expect(await token.balanceOf(staker_0)).to.eq(zeroAmount); - }); + await expect(srStaker0.migrateStake()).to.be.revertedWith(errors.pause.notCurrentlyPaused); - 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); + const stakeRegistryPauser = await ethers.getContract('StakeRegistry', pauser); + await stakeRegistryPauser.pause(); + await expect(srStaker0.migrateStake()).to.emit(srStaker0, 'StakeMigrated').withArgs(staker_0, stakeAmount_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); + expect(await token.balanceOf(staker_0)).to.be.eq(stakeAmount_0); + expect((await srStaker0.stakes(staker_0)).balance).to.be.eq(0); + }); - // 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); + 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 sr_staker_0.withdrawFromStake(); - const staked_after = await sr_staker_0.stakes(staker_0); + await stakeRegistryPauser.pause(); + await mintAndApprove(staker_0, srStaker0.address, stakeAmount_0); + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.be.revertedWith( + errors.pause.currentlyPaused + ); - expect(staked_before.potentialStake).to.be.eq(staked_after.potentialStake); - expect(await token.balanceOf(staker_0)).to.eq(zeroAmount); - }); + await stakeRegistryPauser.unpause(); + await expect(srStaker0.createDeposit(nonce_0, stakeAmount_0, height_0)).to.not.be.reverted; + }); - 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); + 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: Event) => 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: Event) => 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: Event) => 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: Event) => e.event === 'HeightIncreased'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + 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 === 'WithdrawalQueued'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + 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 === 'WithdrawalQueued'); + expect(effectiveRoundFromEvent(ev)).to.eq(fromCall); + }); + + 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); + 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 () { + 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: Event) => 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: Event) => 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 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 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 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'); - }); + 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); }); - describe('change overlay hex', function () { - let sr_staker_1: Contract; + 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); - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - sr_staker_1 = await ethers.getContract('StakeRegistry', staker_1); + 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_1, sr_staker_1.address, stakeAmount_1); - await sr_staker_1.manageStake(nonce_1, stakeAmount_1, height_1); - }); + await mintAndApprove(staker_0, srAlt.address, stakeAmount_0); + await srStaker.addTokens(stakeAmount_0); + await srStaker.changeOverlay(nonce_1_n_25); - 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); - }); + const srRedis = srAlt.connect(await getSignerFor(redistributor)); + await srRedis.freezeDeposit(staker_0, roundLength * 25); - 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); - }); + await advanceRounds(2); - 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 srAlt.applyUpdates(staker_0); - 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); - }); + expect((await srAlt.stakes(staker_0)).balance).to.be.eq(doubleStakeAmount_0); + expect(await srAlt.overlayOfAddress(staker_0)).to.be.eq(overlay_0); }); - describe('commitment protection', function () { - beforeEach(async function () { - await deployments.fixture(); - token = await ethers.getContract('TestToken', deployer); - stakeRegistry = await ethers.getContract('StakeRegistry', staker_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)); - // 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); + 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('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); + 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); - // 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 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)); }); }); }); diff --git a/test/Stats.test.ts b/test/Stats.test.ts index 0c551d85..3b1ed3bb 100644 --- a/test/Stats.test.ts +++ b/test/Stats.test.ts @@ -166,7 +166,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[] = []; @@ -257,7 +257,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); diff --git a/test/util/tools.ts b/test/util/tools.ts index c413e240..c3321afd 100644 --- a/test/util/tools.ts +++ b/test/util/tools.ts @@ -8,11 +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.*` 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 & {