diff --git a/contracts/contracts/TACoApplication.sol b/contracts/contracts/TACoApplication.sol index 837330911..32c1c42f9 100644 --- a/contracts/contracts/TACoApplication.sol +++ b/contracts/contracts/TACoApplication.sol @@ -9,6 +9,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; import "./coordination/ITACoRootToChild.sol"; import "./coordination/ITACoChildToRoot.sol"; +import "./coordination/PenaltyBoard.sol"; /** * @title TACo Application @@ -142,6 +143,7 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { // mapping(address => bool) public stakingProviderReleased; // mapping(address => bool) public allowList; + PenaltyBoard public penaltyBoard; /** * @notice Constructor sets address of token contract and parameters for staking @@ -185,6 +187,14 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { childApplication = _childApplication; } + /** + * @notice Set contract for compensation + */ + function setPenaltyBoard(PenaltyBoard _penaltyBoard) external onlyOwner { + require(address(_penaltyBoard).code.length > 0, "PenaltyBoard must be contract"); + penaltyBoard = _penaltyBoard; + } + //------------------------Staking------------------------------ function initializeStake( @@ -439,6 +449,7 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { info.operatorStartTimestamp = uint64(block.timestamp); emit OperatorBonded(_stakingProvider, _operator, previousOperator, block.timestamp); + penaltyBoard.computeRewards(_stakingProvider); info.operatorConfirmed = false; childApplication.updateOperator(_stakingProvider, _operator); } @@ -460,6 +471,7 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { StakingProviderInfo storage info = stakingProviderInfo[stakingProvider]; if (!info.operatorConfirmed) { + penaltyBoard.enableRewards(stakingProvider); info.operatorConfirmed = true; emit OperatorConfirmed(stakingProvider, _operator); } @@ -471,6 +483,7 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { * @notice Resets operator confirmation */ function _releaseOperator(address _stakingProvider) internal { + penaltyBoard.computeRewards(_stakingProvider); StakingProviderInfo storage info = stakingProviderInfo[_stakingProvider]; _stakingProviderFromOperator[info.operator] = address(0); info.operator = address(0); @@ -555,4 +568,9 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { emit StakelessProviderAdded(_stakingProvider); token.safeTransfer(info.owner, authorized); } + + function isEligibleForReward(address _stakingProvider) external view returns (bool) { + StakingProviderInfo storage info = stakingProviderInfo[_stakingProvider]; + return !info.stakeless && info.operatorConfirmed; + } } diff --git a/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol b/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol new file mode 100644 index 000000000..8f1027fc8 --- /dev/null +++ b/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +/** + * @title ITACoApplicationForPenaltyBoard + * @notice Minimal view interface that PenaltyBoard (with compensation) needs from TACoApplication. + * Used for withdrawal auth (owner, beneficiary), payout address (beneficiary), and stakeless check. + */ +interface ITACoApplicationForPenaltyBoard { + /** + * @notice Returns beneficiary for a staking provider (tokens are sent here on withdraw). + * Assumed never zero for a registered staking provider. + */ + function getBeneficiary( + address stakingProvider + ) external view returns (address payable beneficiary); + + /** + * @notice Returns owner and beneficiary for a staking provider. + * Withdraw(stakingProvider) may be called by stakingProvider, owner, or beneficiary. + * Matches TACoApplication.rolesOf. + */ + function rolesOf( + address stakingProvider + ) external view returns (address owner, address beneficiary); + + /** + * @notice Returns true if the staking provider is stakeless (compensation = 0). + * If not present on TACoApplication, implementation may return false. + */ + function isStakeless(address stakingProvider) external view returns (bool); + + /** + * @notice Returns true if staking provider eligible for reward. + */ + function isEligibleForReward(address _stakingProvider) external view returns (bool); +} diff --git a/contracts/contracts/coordination/PenaltyBoard.sol b/contracts/contracts/coordination/PenaltyBoard.sol index ce28e648d..27f991b03 100644 --- a/contracts/contracts/coordination/PenaltyBoard.sol +++ b/contracts/contracts/coordination/PenaltyBoard.sol @@ -3,53 +3,307 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "./Periods.sol"; +import "./ITACoApplicationForPenaltyBoard.sol"; /** * @title PenaltyBoard * @notice Records which staking providers are penalized per period (period-oriented summary of infractions). * Independent of InfractionCollector; may live on a different chain. An informer (trusted) sets * the list of penalized providers for a given period. + * With compensation enabled (7-arg constructor): also maintains staker-centric penalty list, + * accrued compensation balance, and withdraw to beneficiary. + * @dev Gas: getAccruedBalance/withdraw cost scales with (number of periods in accrual window) and + * (number of penalties in range). _getPenalizedPeriodsInRange can be optimized with binary search (monotonic array). */ contract PenaltyBoard is Periods, AccessControl { + using SafeERC20 for IERC20; + bytes32 public constant INFORMER_ROLE = keccak256("INFORMER_ROLE"); event PenalizedProvidersSet(uint256 indexed period, address[] providers); - mapping(uint256 period => address[]) private _penalizedProvidersByPeriod; + // Compensation (optional: when tacoApplication != address(0)) + ITACoApplicationForPenaltyBoard public immutable tacoApplication; + IERC20 public immutable compensationToken; + address public immutable fundHolder; + uint256 public immutable fixedCompensationPerPeriod; + uint256 public immutable fixedCompensationPerPeriod2Penalties; + uint256 public immutable fixedCompensationPerPeriod3Penalties; + uint256 public immutable startPeriodForRewards; + + /// Number of periods a penalty affects (penalty in period k affects k..k+PENALTY_WINDOW_PERIODS inclusive). + uint256 private constant PENALTY_WINDOW_PERIODS = 3; + struct Staker { + uint256 accruedBalance; + // Accrued compensation state (lazy accrual via _computeAccruedSinceLast). + // _lastAccruedPeriodPlusOne: 0 = never accrued (start from period 0); else start next accrual at this period. + uint256 lastAccruedPeriodPlusOne; + // Staker-centric penalty storage: list of period indices this staker was penalized in + // (monotonic append; periods are kept as uint256). + uint256[] penalizedPeriods; + } + mapping(address staker => Staker stakerStruct) public stakers; + + /** + * @param genesisTime Start of period 0. + * @param periodDuration Duration of one period in seconds. + * @param admin Default admin. + * @param _tacoApplication TACo (or mock) for beneficiary/owner/stakeless. Pass address(0) to disable compensation. + * @param _token Compensation token. Pass address(0) when compensation disabled. + * @param _fixedCompensationPerPeriod Fixed amount per period (0 when disabled). + * @param _fundHolder Holder of tokens for payouts. Pass address(0) when compensation disabled. + * @param _startPeriodForRewards First period when rewards will be calculated. + */ constructor( uint256 genesisTime, uint256 periodDuration, - address admin + address admin, + address _tacoApplication, + address _token, + uint256 _fixedCompensationPerPeriod, + uint256 _fixedCompensationPerPeriod2Penalties, + uint256 _fixedCompensationPerPeriod3Penalties, + address _fundHolder, + uint256 _startPeriodForRewards ) Periods(genesisTime, periodDuration) { require(admin != address(0), "Admin required"); _grantRole(DEFAULT_ADMIN_ROLE, admin); + tacoApplication = ITACoApplicationForPenaltyBoard(_tacoApplication); + compensationToken = IERC20(_token); + fundHolder = _fundHolder; + fixedCompensationPerPeriod = _fixedCompensationPerPeriod; + fixedCompensationPerPeriod2Penalties = _fixedCompensationPerPeriod2Penalties; + fixedCompensationPerPeriod3Penalties = _fixedCompensationPerPeriod3Penalties; + startPeriodForRewards = _startPeriodForRewards; } - function getPenalizedProvidersForPeriod( - uint256 period - ) external view returns (address[] memory) { - return _penalizedProvidersByPeriod[period]; + modifier onlyTACoApplication() { + require(msg.sender == address(tacoApplication), "Not TACo app"); + _; } /** - * @notice Set the list of penalized staking providers for a period. + * @notice Returns the full list of period indices a staker was penalized in. + */ + function getPenalizedPeriodsByStaker(address staker) external view returns (uint256[] memory) { + return stakers[staker].penalizedPeriods; + } + +// TODO: addPenalizedProvidersForThisPeriod + /** + * @notice Add the list of staking providers as penalized for a period. * @param provs Staking provider addresses to record as penalized for the period. * @param period Period index (must be current or previous period). + * @dev Note that passing an empty list doesn't clear penalties for that period; this is only additive. */ - function setPenalizedProvidersForPeriod( + function addPenalizedProvidersForPeriod( address[] calldata provs, uint256 period ) external onlyRole(INFORMER_ROLE) { uint256 current = getCurrentPeriod(); - require(period == current || (current > 0 && period == current - 1), "Invalid period"); + require(period == current || period == current - 1, "Invalid period"); - delete _penalizedProvidersByPeriod[period]; for (uint256 i = 0; i < provs.length; i++) { - _penalizedProvidersByPeriod[period].push(provs[i]); + uint256 len = stakers[provs[i]].penalizedPeriods.length; + // Enforce monotonic append to staker's penalty list (no reordering or duplicates allowed). + if (len > 0) { + require(stakers[provs[i]].penalizedPeriods[len - 1] < period, "Periods must be added in order"); + } + stakers[provs[i]].penalizedPeriods.push(period); } emit PenalizedProvidersSet(period, provs); } + + /** + * @notice Accrued compensation balance for staker up to the current period. + * Does not modify state; accrual is persisted on withdraw. + */ + function getAccruedBalance(address stakingProvider) external view returns (uint256) { + if ( + fixedCompensationPerPeriod == 0 || + address(compensationToken) == address(0) || + address(tacoApplication) == address(0) + ) { + return 0; + } + + uint256 current = getCurrentPaymentPeriod(); + uint256 delta = _computeAccruedSinceLast(stakingProvider, current); + return stakers[stakingProvider].accruedBalance + delta; + } + + /** + * @notice Withdraw accrued compensation for stakingProvider; tokens sent to beneficiary. + */ + function withdraw(address stakingProvider) external { + require(stakingProvider != address(0), "Staking provider required"); + require( + fixedCompensationPerPeriod > 0 && + address(compensationToken) != address(0) && + address(tacoApplication) != address(0) && + fundHolder != address(0), + "Compensation disabled" + ); + + (address owner, address beneficiaryAddress) = tacoApplication.rolesOf(stakingProvider); + require( + msg.sender == stakingProvider || + msg.sender == owner || + msg.sender == beneficiaryAddress, + "Unauthorized" + ); + + // No accrual for stakeless providers. + if (tacoApplication.isStakeless(stakingProvider)) { + revert("Nothing to withdraw"); + } + + uint256 current = getCurrentPaymentPeriod(); + + Staker storage staker = stakers[stakingProvider]; + uint256 delta = _computeAccruedSinceLast(stakingProvider, current); + uint256 amount = staker.accruedBalance + delta; + require(amount > 0, "Nothing to withdraw"); + + staker.accruedBalance = 0; + staker.lastAccruedPeriodPlusOne = current + 1; + + address beneficiary = beneficiaryAddress; + + compensationToken.safeTransferFrom(fundHolder, beneficiary, amount); + } + + /** + * @notice Compute and save rewards for the specified staker + * @dev Can be used anytime. It has to be called before turning off reward + */ + function computeRewards(address stakingProvider) external onlyTACoApplication { + uint256 current = getCurrentPaymentPeriod(); + + Staker storage staker = stakers[stakingProvider]; + uint256 delta = _computeAccruedSinceLast(stakingProvider, current); + staker.accruedBalance += delta; + staker.lastAccruedPeriodPlusOne = current + 1; + } + + /** + * @notice Enable rewards before turning on + */ + function enableRewards(address stakingProvider) external onlyTACoApplication { + stakers[stakingProvider].lastAccruedPeriodPlusOne = getCurrentPeriod(); + } + + function _computeAccruedSinceLast( + address stakingProvider, + uint256 currentPeriod + ) internal view returns (uint256) { + if ( + fixedCompensationPerPeriod == 0 || + address(compensationToken) == address(0) || + address(tacoApplication) == address(0) + ) { + return 0; + } + + if (!tacoApplication.isEligibleForReward(stakingProvider)) { + return 0; + } + + uint256 startPeriod = stakers[stakingProvider].lastAccruedPeriodPlusOne; // 0 = never accrued → start at 0 + if (startPeriod < startPeriodForRewards) { + startPeriod = startPeriodForRewards; + } + + if (startPeriod > currentPeriod) { + return 0; + } + + uint256 fromPenalties = startPeriod > PENALTY_WINDOW_PERIODS + ? startPeriod - PENALTY_WINDOW_PERIODS + : 0; + + uint256[] memory penaltiesInRange = _getPenalizedPeriodsInRange( + stakingProvider, + fromPenalties, + currentPeriod + ); + + // No penalties or only one penalty affecting this window: full periods accrue. + if (penaltiesInRange.length <= 1) { + uint256 numPeriods = currentPeriod - startPeriod + 1; + return numPeriods * fixedCompensationPerPeriod; + } + + // TODO: Consider potential optimization for length > 1: + // - Check if all periods with penalties are spaced out by at least PENALTY_WINDOW_PERIODS. If so, we can skip the loop and return the full compensation + + // Naive approach where we check ALL periods in the range. + // - For each period, we check how many penalties are in the window that ends at the current period. + // - This check involves iterating over the penaltiesInRange array, which is sorted. + uint256 accrued = 0; + for (uint256 p = startPeriod; p <= currentPeriod; p++) { + uint256 windowWidth = p >= PENALTY_WINDOW_PERIODS ? PENALTY_WINDOW_PERIODS : p; + uint256 penaltyHorizon = p - windowWidth; + + uint256 numPenaltiesInWindow = 0; + for (uint256 i = 0; i < penaltiesInRange.length; i++) { + if (penaltiesInRange[i] < penaltyHorizon) { + continue; + } else if (penaltiesInRange[i] >= penaltyHorizon && penaltiesInRange[i] <= p) { + numPenaltiesInWindow++; + } else { + break; + } + } + + if (numPenaltiesInWindow <= 1) { + accrued += fixedCompensationPerPeriod; + } else if (numPenaltiesInWindow == 2) { + accrued += fixedCompensationPerPeriod2Penalties; + } else if (numPenaltiesInWindow == 3) { + accrued += fixedCompensationPerPeriod3Penalties; + } + } + + return accrued; + } + + /// @dev Optimize later: array is monotonic; use binary search for fromPeriod/toPeriod to get + /// the inclusive index range [i, j], then copy in one pass instead of count + copy. + function _getPenalizedPeriodsInRange( + address stakingProvider, + uint256 fromPeriod, + uint256 toPeriod + ) internal view returns (uint256[] memory) { + uint256[] storage all = stakers[stakingProvider].penalizedPeriods; + uint256 len = all.length; + if (len == 0 || fromPeriod > toPeriod) { + return new uint256[](0); + } + + uint256 count; + for (uint256 i = 0; i < len; i++) { + uint256 p = all[i]; + if (p >= fromPeriod && p <= toPeriod) { + count++; + } + } + + uint256[] memory result = new uint256[](count); + uint256 idx; + for (uint256 i = 0; i < len; i++) { + uint256 p = all[i]; + if (p >= fromPeriod && p <= toPeriod) { + result[idx] = p; + idx++; + } + } + + return result; + } } diff --git a/contracts/contracts/coordination/Periods.sol b/contracts/contracts/coordination/Periods.sol index 9c002c117..86a1ac03d 100644 --- a/contracts/contracts/coordination/Periods.sol +++ b/contracts/contracts/coordination/Periods.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; contract Periods { + uint256 public constant REWARD_DELAY_PERIODS = 2; + uint256 public immutable genesisTime; uint256 public immutable periodDuration; @@ -10,6 +12,7 @@ contract Periods { require(_periodDuration > 0, "Invalid period duration"); genesisTime = _genesisTime; periodDuration = _periodDuration; + require(getCurrentPeriod() >= REWARD_DELAY_PERIODS, "genesisTime must be in the past"); } function getPeriodForTimestamp(uint256 timestamp) public view returns (uint256) { @@ -20,4 +23,8 @@ contract Periods { function getCurrentPeriod() public view returns (uint256) { return getPeriodForTimestamp(block.timestamp); } + + function getCurrentPaymentPeriod() public view returns (uint256) { + return getCurrentPeriod() - REWARD_DELAY_PERIODS; + } } diff --git a/contracts/test/MockTACoForPenaltyBoard.sol b/contracts/test/MockTACoForPenaltyBoard.sol new file mode 100644 index 000000000..597578da5 --- /dev/null +++ b/contracts/test/MockTACoForPenaltyBoard.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "../contracts/coordination/ITACoApplicationForPenaltyBoard.sol"; +import "../contracts/coordination/PenaltyBoard.sol"; + +/** + * @notice Mock TACoApplication for PenaltyBoard compensation tests. + * Allows tests to set owner, beneficiary, and isStakeless per staking provider. + */ +contract MockTACoForPenaltyBoard is ITACoApplicationForPenaltyBoard { + struct StakerRoles { + address owner; + address payable beneficiary; + bool isStakeless; + bool eligibleForReward; + } + + mapping(address stakingProvider => StakerRoles) private _roles; + + PenaltyBoard public penaltyBoard; + + function setPenaltyBoard(PenaltyBoard _penaltyBoard) external { + penaltyBoard = _penaltyBoard; + } + + function setRoles( + address stakingProvider, + address owner, + address payable beneficiary, + bool isStakeless, + bool eligibleForReward + ) external { + _roles[stakingProvider] = StakerRoles({ + owner: owner, + beneficiary: beneficiary, + isStakeless: isStakeless, + eligibleForReward: eligibleForReward + }); + } + + function getBeneficiary(address stakingProvider) external view returns (address payable) { + return _roles[stakingProvider].beneficiary; + } + + function rolesOf( + address stakingProvider + ) external view returns (address owner, address beneficiary) { + StakerRoles storage r = _roles[stakingProvider]; + return (r.owner, r.beneficiary); + } + + function isStakeless(address stakingProvider) external view returns (bool) { + return _roles[stakingProvider].isStakeless; + } + + function isEligibleForReward(address stakingProvider) external view returns (bool) { + return _roles[stakingProvider].eligibleForReward; + } + + function computeRewards(address stakingProvider) external { + penaltyBoard.computeRewards(stakingProvider); + } + + function enableRewards(address stakingProvider) external { + penaltyBoard.enableRewards(stakingProvider); + } +} diff --git a/contracts/test/TACoApplicationTestSet.sol b/contracts/test/TACoApplicationTestSet.sol index 532280314..97423a9a2 100644 --- a/contracts/test/TACoApplicationTestSet.sol +++ b/contracts/test/TACoApplicationTestSet.sol @@ -60,3 +60,15 @@ contract ChildApplicationForTACoApplicationMock { } } } + +contract PenaltyBoardForTACoApplicationMock { + mapping(address stakingProvider => bool isRewardEnabled) public isRewardEnabled; + + function computeRewards(address stakingProvider) external { + isRewardEnabled[stakingProvider] = false; + } + + function enableRewards(address stakingProvider) external { + isRewardEnabled[stakingProvider] = true; + } +} diff --git a/tests/application/conftest.py b/tests/application/conftest.py index b69165eb3..07ccde8be 100644 --- a/tests/application/conftest.py +++ b/tests/application/conftest.py @@ -81,3 +81,10 @@ def child_application(project, creator, taco_application): ) taco_application.setChildApplication(contract.address, sender=creator) return contract + + +@pytest.fixture() +def penalty_board(project, creator, taco_application): + contract = project.PenaltyBoardForTACoApplicationMock.deploy(sender=creator) + taco_application.setPenaltyBoard(contract.address, sender=creator) + return contract diff --git a/tests/application/test_authorization.py b/tests/application/test_authorization.py index 9713a8e71..6179c3d7d 100644 --- a/tests/application/test_authorization.py +++ b/tests/application/test_authorization.py @@ -25,7 +25,7 @@ MIN_AUTHORIZATION = Web3.to_wei(40_000, "ether") -def test_initialize_stake(accounts, token, taco_application, child_application): +def test_initialize_stake(accounts, token, taco_application, child_application, penalty_board): """ Tests for authorization method: initializeStake """ @@ -58,6 +58,8 @@ def test_initialize_stake(accounts, token, taco_application, child_application): assert taco_application.isAuthorized(staking_provider) assert token.balanceOf(taco_application.address) == value assert token.balanceOf(owner) == 0 + assert not taco_application.isEligibleForReward(staking_provider) + assert not penalty_board.isRewardEnabled(staking_provider) # Check that all events are emitted events = [event for event in tx.events if event.event_name == "Staked"] @@ -122,7 +124,7 @@ def test_request_unstake_callers(accounts, taco_application, token, child_applic assert taco_application.eligibleStake(staking_provider_3) == 0 -def test_request_unstake(accounts, taco_application, child_application, token): +def test_request_unstake(accounts, taco_application, child_application, token, penalty_board): """ Tests for authorization method: requestUnstake """ @@ -183,7 +185,7 @@ def test_request_unstake(accounts, taco_application, child_application, token): assert child_application.stakingProviderReleased(staking_provider_2) -def test_release(accounts, token, taco_application, child_application): +def test_release(accounts, token, taco_application, child_application, penalty_board): """ Tests for authorization method: release+approveUnstake """ @@ -271,7 +273,7 @@ def test_release(accounts, token, taco_application, child_application): assert token.balanceOf(staking_provider_3) == 0 -def test_child_sync(accounts, taco_application, child_application, token, chain): +def test_child_sync(accounts, taco_application, child_application, token, penalty_board): """ Tests for x-chain method: manualChildSynchronization """ @@ -324,7 +326,7 @@ def test_child_sync(accounts, taco_application, child_application, token, chain) ] -def test_add_stakeless_provider(accounts, taco_application, child_application, chain): +def test_add_stakeless_provider(accounts, taco_application, child_application, penalty_board): """ Tests for authorization method: addStakelessProvider """ @@ -349,6 +351,8 @@ def test_add_stakeless_provider(accounts, taco_application, child_application, c assert taco_application.authorizedStake(staking_provider) == value assert child_application.stakingProviderInfo(staking_provider) == (value, 0) assert taco_application.isAuthorized(staking_provider) + assert not taco_application.isEligibleForReward(staking_provider) + assert not penalty_board.isRewardEnabled(staking_provider) # Check that all events are emitted events = [event for event in tx.events if event.event_name == "StakelessProviderAdded"] @@ -359,6 +363,8 @@ def test_add_stakeless_provider(accounts, taco_application, child_application, c taco_application.bondOperator(staking_provider, owner, sender=staking_provider) child_application.confirmOperatorAddress(staking_provider, sender=owner) + assert not taco_application.isEligibleForReward(staking_provider) + assert not penalty_board.isRewardEnabled(staking_provider) with ape.reverts("A provider can't be an operator for another provider"): taco_application.addStakelessProvider(owner, owner, sender=creator) diff --git a/tests/application/test_operator.py b/tests/application/test_operator.py index d141c1591..1292d9ead 100644 --- a/tests/application/test_operator.py +++ b/tests/application/test_operator.py @@ -24,7 +24,7 @@ MIN_OPERATOR_SECONDS = 24 * 60 * 60 -def test_bond_operator(accounts, token, taco_application, child_application, chain): +def test_bond_operator(accounts, token, taco_application, child_application, penalty_board, chain): ( creator, staking_provider_1, @@ -97,6 +97,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviders(0) == staking_provider_3 assert child_application.stakingProviderToOperator(staking_provider_3) == operator1 assert child_application.operatorToStakingProvider(operator1) == staking_provider_3 + assert not taco_application.isEligibleForReward(staking_provider_3) + assert not penalty_board.isRewardEnabled(staking_provider_3) events = [event for event in tx.events if event.event_name == "OperatorBonded"] assert events == [ @@ -118,6 +120,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.isOperatorConfirmed(operator1) assert child_application.stakingProviderToOperator(staking_provider_3) == operator1 assert child_application.operatorToStakingProvider(operator1) == staking_provider_3 + assert taco_application.isEligibleForReward(staking_provider_3) + assert penalty_board.isRewardEnabled(staking_provider_3) # After confirmation operator is becoming active all_locked, staking_providers = taco_application.getActiveStakingProviders(0, 0) @@ -157,6 +161,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviders(0) == staking_provider_3 assert child_application.stakingProviderToOperator(staking_provider_3) == ZERO_ADDRESS assert child_application.operatorToStakingProvider(operator1) == ZERO_ADDRESS + assert not taco_application.isEligibleForReward(staking_provider_3) + assert not penalty_board.isRewardEnabled(staking_provider_3) # Resetting operator removes from active list before next confirmation all_locked, staking_providers = taco_application.getActiveStakingProviders(0, 0) @@ -184,6 +190,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviders(0) == staking_provider_3 assert child_application.stakingProviderToOperator(staking_provider_3) == operator2 assert child_application.operatorToStakingProvider(operator2) == staking_provider_3 + assert not taco_application.isEligibleForReward(staking_provider_3) + assert not penalty_board.isRewardEnabled(staking_provider_3) events = [event for event in tx.events if event.event_name == "OperatorBonded"] assert events == [ @@ -205,6 +213,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviderInfo(staking_provider_3)[CONFIRMATION_SLOT] assert child_application.stakingProviderToOperator(staking_provider_3) == operator2 assert child_application.operatorToStakingProvider(operator2) == staking_provider_3 + assert taco_application.isEligibleForReward(staking_provider_3) + assert penalty_board.isRewardEnabled(staking_provider_3) # Another staking provider can bond a free operator tx = taco_application.bondOperator(staking_provider_4, operator1, sender=staking_provider_4) @@ -217,6 +227,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviders(1) == staking_provider_4 assert child_application.stakingProviderToOperator(staking_provider_4) == operator1 assert child_application.operatorToStakingProvider(operator1) == staking_provider_4 + assert not taco_application.isEligibleForReward(staking_provider_4) + assert not penalty_board.isRewardEnabled(staking_provider_4) events = [event for event in tx.events if event.event_name == "OperatorBonded"] assert events == [ @@ -238,6 +250,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.stakingProviderInfo(staking_provider_4)[CONFIRMATION_SLOT] assert child_application.stakingProviderToOperator(staking_provider_4) == operator1 assert child_application.operatorToStakingProvider(operator1) == staking_provider_4 + assert taco_application.isEligibleForReward(staking_provider_4) + assert penalty_board.isRewardEnabled(staking_provider_4) chain.pending_timestamp += min_operator_seconds tx = taco_application.bondOperator(staking_provider_4, operator3, sender=staking_provider_4) @@ -253,6 +267,8 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert child_application.stakingProviderToOperator(staking_provider_4) == operator3 assert child_application.operatorToStakingProvider(operator1) == ZERO_ADDRESS assert child_application.operatorToStakingProvider(operator3) == staking_provider_4 + assert not taco_application.isEligibleForReward(staking_provider_4) + assert not penalty_board.isRewardEnabled(staking_provider_4) # Resetting operator removes from active list before next confirmation all_locked, staking_providers = taco_application.getActiveStakingProviders(1, 0) @@ -313,7 +329,9 @@ def test_bond_operator(accounts, token, taco_application, child_application, cha assert taco_application.operatorToStakingProvider(operator2) == ZERO_ADDRESS -def test_confirm_address(accounts, token, taco_application, child_application, chain): +def test_confirm_address( + accounts, token, taco_application, child_application, penalty_board, chain +): creator, staking_provider, operator, *everyone_else = accounts[0:] min_operator_seconds = MIN_OPERATOR_SECONDS @@ -323,6 +341,8 @@ def test_confirm_address(accounts, token, taco_application, child_application, c # Skips confirmation if operator is not associated with staking provider child_application.confirmOperatorAddress(staking_provider, sender=staking_provider) assert not taco_application.isOperatorConfirmed(staking_provider) + assert not taco_application.isEligibleForReward(staking_provider) + assert not penalty_board.isRewardEnabled(staking_provider) token.transfer(staking_provider, MIN_AUTHORIZATION, sender=creator) token.approve(taco_application.address, MIN_AUTHORIZATION, sender=staking_provider) @@ -333,9 +353,13 @@ def test_confirm_address(accounts, token, taco_application, child_application, c # Bond operator and make confirmation chain.pending_timestamp += min_operator_seconds taco_application.bondOperator(staking_provider, operator, sender=staking_provider) + assert not taco_application.isEligibleForReward(staking_provider) + assert not penalty_board.isRewardEnabled(staking_provider) tx = child_application.confirmOperatorAddress(operator, sender=operator) assert taco_application.isOperatorConfirmed(operator) assert taco_application.stakingProviderInfo(staking_provider)[CONFIRMATION_SLOT] + assert taco_application.isEligibleForReward(staking_provider) + assert penalty_board.isRewardEnabled(staking_provider) events = [event for event in tx.events if event.event_name == "OperatorConfirmed"] assert events == [ @@ -346,3 +370,5 @@ def test_confirm_address(accounts, token, taco_application, child_application, c child_application.confirmOperatorAddress(operator, sender=operator) assert taco_application.isOperatorConfirmed(operator) assert taco_application.stakingProviderInfo(staking_provider)[CONFIRMATION_SLOT] + assert taco_application.isEligibleForReward(staking_provider) + assert penalty_board.isRewardEnabled(staking_provider) diff --git a/tests/test_infraction.py b/tests/test_infraction.py index 8e5e69835..c44321cec 100644 --- a/tests/test_infraction.py +++ b/tests/test_infraction.py @@ -135,12 +135,20 @@ def infraction_collector(project, deployer, coordinator): @pytest.fixture def penalty_board(project, deployer, informer, chain): """PenaltyBoard with genesis at current chain time and INFORMER_ROLE granted to informer. - Period duration is one week so ritual timeout still lands in period 0.""" - genesis_time = chain.pending_timestamp + Period duration is one week so ritual timeout still lands in period 0. No compensation.""" + genesis_time = chain.pending_timestamp - 2 * PENALTY_BOARD_PERIOD_DURATION + zero = "0x0000000000000000000000000000000000000000" contract = project.PenaltyBoard.deploy( genesis_time, PENALTY_BOARD_PERIOD_DURATION, deployer.address, + zero, + zero, + 0, + 0, + 0, + zero, + 0, sender=deployer, ) contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) @@ -158,11 +166,11 @@ def test_no_infractions( coordinator.initiateRitual( fee_model, nodes, initiator, DURATION, global_allow_list.address, sender=initiator ) - + size = len(nodes) threshold = coordinator.getThresholdForRitualSize(size) transcript = generate_transcript(size, threshold) - + for node in nodes: coordinator.publishTranscript(0, transcript, sender=node) @@ -270,7 +278,7 @@ def test_infraction_collector_and_penalty_board_together( # Same period still (period duration is 1 week; we advanced ~2000s). Informer records # penalized providers for this period on PenaltyBoard. current_period = penalty_board.getCurrentPeriod() - penalty_board.setPenalizedProvidersForPeriod( - failing_providers, current_period, sender=informer - ) - assert penalty_board.getPenalizedProvidersForPeriod(current_period) == failing_providers + penalty_board.addPenalizedProvidersForPeriod(failing_providers, current_period, sender=informer) + for provider in failing_providers: + periods = penalty_board.getPenalizedPeriodsByStaker(provider) + assert periods == [current_period] diff --git a/tests/test_penalty_board.py b/tests/test_penalty_board.py index 0564c30fa..51cbdc1de 100644 --- a/tests/test_penalty_board.py +++ b/tests/test_penalty_board.py @@ -2,6 +2,7 @@ import ape import pytest +from ape.utils import ZERO_ADDRESS PERIOD_DURATION = 3600 # 1 hour @@ -23,12 +24,19 @@ def other_account(accounts): @pytest.fixture def penalty_board(project, deployer, informer, chain): - """PenaltyBoard with genesis at current chain time and INFORMER_ROLE granted to informer.""" - genesis_time = chain.pending_timestamp + """PenaltyBoard with genesis at current chain time and INFORMER_ROLE granted to informer (no compensation).""" + genesis_time = chain.pending_timestamp - 2 * PERIOD_DURATION contract = project.PenaltyBoard.deploy( genesis_time, PERIOD_DURATION, deployer.address, + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + 0, + 0, + ZERO_ADDRESS, + 0, sender=deployer, ) contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) @@ -36,12 +44,19 @@ def penalty_board(project, deployer, informer, chain): def test_constructor_admin_required(project, deployer, chain): - genesis_time = chain.pending_timestamp + genesis_time = chain.pending_timestamp - 2 * PERIOD_DURATION with ape.reverts("Admin required"): project.PenaltyBoard.deploy( genesis_time, PERIOD_DURATION, - "0x0000000000000000000000000000000000000000", + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + 0, + 0, + ZERO_ADDRESS, + 0, sender=deployer, ) @@ -49,82 +64,80 @@ def test_constructor_admin_required(project, deployer, chain): def test_only_informer_can_set_penalized_providers( penalty_board, deployer, informer, other_account ): - """Only an account with INFORMER_ROLE can call setPenalizedProvidersForPeriod.""" + """Only an account with INFORMER_ROLE can call addPenalizedProvidersForPeriod.""" current = penalty_board.getCurrentPeriod() provs = [other_account.address] with ape.reverts(): - penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=deployer) + penalty_board.addPenalizedProvidersForPeriod(provs, current, sender=deployer) - penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(current) == provs + penalty_board.addPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board.getPenalizedPeriodsByStaker(other_account.address) == [current] -def test_period_must_be_current_or_previous( - penalty_board, chain, informer, other_account -): - """setPenalizedProvidersForPeriod accepts only current or previous period.""" +def test_period_must_be_current_or_previous(penalty_board, chain, informer, other_account): + """addPenalizedProvidersForPeriod accepts only current or previous period.""" provs = [other_account.address] current = penalty_board.getCurrentPeriod() # At deploy, genesis = chain.pending_timestamp so current is 0. Period 1 is invalid. with ape.reverts("Invalid period"): - penalty_board.setPenalizedProvidersForPeriod(provs, current + 1, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, current + 1, sender=informer) - penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(current) == provs + penalty_board.addPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board.getPenalizedPeriodsByStaker(other_account.address) == [current] - # Advance into period 1; then both 0 and 1 are valid. + # Advance into period 1; then both 2 and 3 are valid. chain.pending_timestamp += PERIOD_DURATION - assert penalty_board.getCurrentPeriod() == 1 + assert penalty_board.getCurrentPeriod() == 3 - penalty_board.setPenalizedProvidersForPeriod(provs, 0, sender=informer) - penalty_board.setPenalizedProvidersForPeriod(provs, 1, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(0) == provs - assert penalty_board.getPenalizedProvidersForPeriod(1) == provs + penalty_board.addPenalizedProvidersForPeriod(provs, 2, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, 3, sender=informer) + periods = penalty_board.getPenalizedPeriodsByStaker(other_account.address) + assert periods == [current, 2, 3] - # Period 2 is invalid (not current or previous). + # Period 4 is invalid (not current or previous). with ape.reverts("Invalid period"): - penalty_board.setPenalizedProvidersForPeriod(provs, 2, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, 4, sender=informer) -def test_getter_returns_set_list( +def test_staker_penalty_list_updated_for_all_providers( penalty_board, informer, other_account, accounts ): - """getPenalizedProvidersForPeriod returns the list set for that period.""" + """penalizedPeriodsByStaker records the period for each provider in provs.""" current = penalty_board.getCurrentPeriod() provs = [accounts[3].address, accounts[4].address, other_account.address] - penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(current) == provs + penalty_board.addPenalizedProvidersForPeriod(provs, current, sender=informer) + for addr in provs: + assert penalty_board.getPenalizedPeriodsByStaker(addr) == [current] -def test_setting_again_replaces_list( - penalty_board, informer, other_account, accounts -): - """Calling setPenalizedProvidersForPeriod again for the same period replaces the list.""" +def test_setting_again_appends_for_new_providers(penalty_board, informer, other_account, accounts): + """Calling addPenalizedProvidersForPeriod again for the same period appends penalties for new providers.""" current = penalty_board.getCurrentPeriod() first = [accounts[5].address, accounts[6].address] - penalty_board.setPenalizedProvidersForPeriod(first, current, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(current) == first + penalty_board.addPenalizedProvidersForPeriod(first, current, sender=informer) + for addr in first: + assert penalty_board.getPenalizedPeriodsByStaker(addr) == [current] second = [other_account.address] - penalty_board.setPenalizedProvidersForPeriod(second, current, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(current) == second + penalty_board.addPenalizedProvidersForPeriod(second, current, sender=informer) + for addr in first: + assert penalty_board.getPenalizedPeriodsByStaker(addr) == [current] + assert penalty_board.getPenalizedPeriodsByStaker(other_account.address) == [current] -def test_period_zero_only_when_current_is_zero( - penalty_board, informer, other_account -): +def test_period_zero_only_when_current_is_zero(penalty_board, informer, other_account): """When current period is 0, only period 0 is allowed (no underflow on previous).""" current = penalty_board.getCurrentPeriod() if current != 0: pytest.skip("chain already past period 0") provs = [other_account.address] - penalty_board.setPenalizedProvidersForPeriod(provs, 0, sender=informer) - assert penalty_board.getPenalizedProvidersForPeriod(0) == provs + penalty_board.addPenalizedProvidersForPeriod(provs, 0, sender=informer) + assert penalty_board.getPenalizedPeriodsByStaker(other_account.address) == [0] with ape.reverts("Invalid period"): - penalty_board.setPenalizedProvidersForPeriod(provs, 1, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, 1, sender=informer) diff --git a/tests/test_penalty_board_compensation.py b/tests/test_penalty_board_compensation.py new file mode 100644 index 000000000..4ceec4e4d --- /dev/null +++ b/tests/test_penalty_board_compensation.py @@ -0,0 +1,567 @@ +import ape +import pytest + +from ape.utils import ZERO_ADDRESS + + +PERIOD_DURATION = 3600 +FULL_COMPENSATION = 1000 +REDUCED_COMPENSATION_1 = 600 +REDUCED_COMPENSATION_2 = 100 +TOKEN_SUPPLY = 1_000_000 * 10**18 +MAX_UINT256 = 2**256 - 1 +REWARD_DELAY_PERIODS = 2 + + +@pytest.fixture(scope="module") +def deployer(accounts): + return accounts[0] + + +@pytest.fixture(scope="module") +def informer(accounts): + return accounts[1] + + +@pytest.fixture(scope="module") +def fund_holder(accounts): + return accounts[2] + + +@pytest.fixture(scope="module") +def staking_provider(accounts): + return accounts[3] + + +@pytest.fixture(scope="module") +def beneficiary(accounts): + return accounts[4] + + +@pytest.fixture(scope="module") +def owner(accounts): + return accounts[5] + + +@pytest.fixture(scope="module") +def other_account(accounts): + return accounts[6] + + +@pytest.fixture(scope="module") +def stakeless_provider(accounts): + return accounts[7] + + +@pytest.fixture(scope="module") +def mock_taco_app(project, deployer): + return project.MockTACoForPenaltyBoard.deploy(sender=deployer) + + +@pytest.fixture(scope="module") +def token(project, deployer): + return project.TestToken.deploy(TOKEN_SUPPLY, sender=deployer) + + +@pytest.fixture +def penalty_board_comp(project, deployer, informer, chain, mock_taco_app, token, fund_holder): + """PenaltyBoard with compensation enabled (7-arg constructor).""" + genesis_time = chain.pending_timestamp - 2 * PERIOD_DURATION + contract = project.PenaltyBoard.deploy( + genesis_time, + PERIOD_DURATION, + deployer.address, + mock_taco_app.address, + token.address, + FULL_COMPENSATION, + REDUCED_COMPENSATION_1, + REDUCED_COMPENSATION_2, + fund_holder.address, + REWARD_DELAY_PERIODS, + sender=deployer, + ) + contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) + token.transfer(fund_holder.address, 100 * FULL_COMPENSATION, sender=deployer) + token.approve(contract.address, MAX_UINT256, sender=fund_holder) + mock_taco_app.setPenaltyBoard(contract.address, sender=deployer) + return contract + + +@pytest.fixture +def registered_staker(mock_taco_app, staking_provider, owner, beneficiary, deployer): + """Register staking_provider in mock TACo: owner=owner, beneficiary=beneficiary, not stakeless.""" + mock_taco_app.setRoles( + staking_provider.address, + owner.address, + beneficiary.address, + False, # not stakeless + True, # rewards enabled + sender=deployer, + ) + return staking_provider + + +@pytest.fixture +def registered_stakeless(mock_taco_app, stakeless_provider, owner, deployer): + """Register stakeless_provider in mock TACo: owner=owner, stakeless.""" + mock_taco_app.setRoles( + stakeless_provider.address, + owner.address, + ZERO_ADDRESS, # beneficiary is zero address since stakeless staker should not be able to accrue rewards + True, # stakeless + False, # rewards disabled since stakeless staker should not be able to accrue rewards + sender=deployer, + ) + return stakeless_provider + + +@pytest.fixture +def registered_no_reward(mock_taco_app, stakeless_provider, owner, beneficiary, deployer): + """Register provider in mock TACo: owner=owner, beneficiary=beneficiary, stakeless.""" + mock_taco_app.setRoles( + stakeless_provider.address, + owner.address, + beneficiary.address, + False, # not stakeless + False, # rewards disabled since this staker should not be able to accrue rewards + sender=deployer, + ) + return stakeless_provider + + +def test_penalized_periods_by_staker_updated( + penalty_board_comp, informer, staking_provider, other_account +): + """addPenalizedProvidersForPeriod(provs, period) causes penalizedPeriodsByStaker[prov] to include period.""" + current = penalty_board_comp.getCurrentPeriod() + provs = [staking_provider.address] + penalty_board_comp.addPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board_comp.getPenalizedPeriodsByStaker(staking_provider.address) == [current] + + provs = [other_account.address] + penalty_board_comp.addPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board_comp.getPenalizedPeriodsByStaker(staking_provider.address) == [current] + + +def test_penalized_periods_monotonic_append(penalty_board_comp, informer, staking_provider, chain): + """Calling addPenalizedProvidersForPeriod for different periods appends to each staker's list (monotonic).""" + current = penalty_board_comp.getCurrentPeriod() + provs = [staking_provider.address] + penalty_board_comp.addPenalizedProvidersForPeriod(provs, current, sender=informer) + chain.pending_timestamp += PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == current + 1 + penalty_board_comp.addPenalizedProvidersForPeriod(provs, current + 1, sender=informer) + assert penalty_board_comp.getPenalizedPeriodsByStaker(staking_provider.address) == [ + current, + current + 1, + ] + + +def test_penalized_periods_disallow_unordered_append(penalty_board_comp, informer, staking_provider, chain): + """Calling addPenalizedProvidersForPeriod for different periods must be in order; reverts if period is not > last period for that staker.""" + first_period = penalty_board_comp.getCurrentPeriod() + provs = [staking_provider.address] + + chain.pending_timestamp += PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == first_period + 1 + + penalty_board_comp.addPenalizedProvidersForPeriod(provs, first_period + 1, sender=informer) + with ape.reverts("Periods must be added in order"): + penalty_board_comp.addPenalizedProvidersForPeriod(provs, first_period, sender=informer) + + +def test_withdraw_reverts_unauthorized_for_stakeless( + penalty_board_comp, registered_stakeless, chain, beneficiary, deployer +): + """Withdraw reverts with 'Unauthorized' for stakeless staker even if beneficiary or owner tries to call withdraw.""" + with ape.reverts("Unauthorized"): + penalty_board_comp.withdraw(registered_stakeless.address, sender=beneficiary) + with ape.reverts("Unauthorized"): + penalty_board_comp.withdraw(registered_stakeless.address, sender=deployer) + + +def test_withdraw_reverts_nothing_to_withdraw_for_stakeless( + penalty_board_comp, registered_stakeless, chain, beneficiary, deployer, owner +): + """Withdraw reverts with 'Nothing to withdraw' for stakeless staker even if beneficiary or owner tries to call withdraw.""" + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(registered_stakeless.address, sender=registered_stakeless) + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(registered_stakeless.address, sender=owner) + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(registered_stakeless.address, sender=owner) + + +def test_withdraw_reverts_nothing_to_withdraw_for_no_reward( + penalty_board_comp, registered_no_reward, chain, beneficiary +): + """Withdraw reverts with 'Nothing to withdraw' for staker with no rewards.""" + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(registered_no_reward.address, sender=beneficiary) + + +def test_get_accrued_balance_reflects_periods_without_prior_withdraw( + penalty_board_comp, registered_staker, chain +): + """getAccruedBalance returns accrued amount for periods 0..current when staker has not yet withdrawn.""" + # At deploy we are in period 2, so there's nothing to accrue yet (accrual lag is 2 periods) + balance = penalty_board_comp.getAccruedBalance(registered_staker.address) + assert penalty_board_comp.getCurrentPeriod() == 2 + assert balance == 0 + + # After advancing 1 period, there's still nothing to accrue (accrual lag is 2 periods) + chain.pending_timestamp += PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == 3 + balance = penalty_board_comp.getAccruedBalance(registered_staker.address) + assert balance == 0 + + # After advancing 1 more period (2 total), we should accrue for period 2 + chain.pending_timestamp += PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == 4 + balance = penalty_board_comp.getAccruedBalance(registered_staker.address) + assert balance == FULL_COMPENSATION + + +def test_accrual_with_no_penalties_gives_full_compensation( + penalty_board_comp, chain, staking_provider, registered_staker +): + """After advancing periods with no penalties, staker should have positive accrued balance""" + # Advance 2 periods so there is something to accrue + chain.pending_timestamp += 2 * PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert ( + balance == FULL_COMPENSATION + ), "Incorrect accrual: getAccruedBalance should be > 0 after 2 periods with no penalties" + + +def test_accrual_with_one_penalty_gives_full_compensation( + penalty_board_comp, chain, staking_provider, registered_staker, informer +): + """After advancing periods with 1 penalty, staker should still have full accrued balance since penalty only reduces for more than 1 penalty in a period""" + penalized_providers = [staking_provider.address] + current = penalty_board_comp.getCurrentPeriod() + penalty_board_comp.addPenalizedProvidersForPeriod(penalized_providers, current, sender=informer) + # Advance 2 periods so there is something to accrue + chain.pending_timestamp += 2 * PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert ( + balance == FULL_COMPENSATION + ), "Incorrect accrual: getAccruedBalance should be > 0 after 2 periods with only 1 penalties" + + +def test_accrual_with_two_penalties_gives_reduced_compensation( + penalty_board_comp, chain, staking_provider, registered_staker, informer +): + """After advancing periods with 2 penalties, staker should have reduced accrued balance since penalty reduces for more than 1 penalty in a period""" + penalized_providers = [staking_provider.address] + current = penalty_board_comp.getCurrentPeriod() + penalty_board_comp.addPenalizedProvidersForPeriod(penalized_providers, current, sender=informer) + # Advance 1 period and add another penalty in the same period + chain.pending_timestamp += PERIOD_DURATION + penalty_board_comp.addPenalizedProvidersForPeriod(penalized_providers, current + 1, sender=informer) + # Advance 1 more period so there is something to accrue + chain.pending_timestamp += PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert ( + balance == FULL_COMPENSATION + ), "Incorrect accrual: getAccruedBalance should be FULL_COMPENSATION after 2 periods with only 1 penalties" + + chain.pending_timestamp += PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert ( + balance == FULL_COMPENSATION + REDUCED_COMPENSATION_1 + ), "Incorrect accrual: getAccruedBalance should be > 0 after 2 periods with only 1 penalties" + + + +def test_unauthorized_caller_cant_withdraw( + penalty_board_comp, registered_staker, other_account, staking_provider +): + """Only owner, staking provider, or beneficiary may call withdraw; others revert""" + with ape.reverts("Unauthorized"): + penalty_board_comp.withdraw(staking_provider.address, sender=other_account) + + +@pytest.mark.parametrize("caller", ["owner", "staking_provider", "beneficiary"]) +def test_withdraw_sends_tokens_to_beneficiary( + penalty_board_comp, + token, + fund_holder, + beneficiary, + staking_provider, + registered_staker, + chain, + caller, + request, +): + """Withdraw sends tokens to beneficiary, not to msg.sender""" + # We parametrize over the 3 authorized caller types to confirm that tokens are sent to beneficiary regardless of which authorized caller calls withdraw + caller = request.getfixturevalue(caller) + # Advance so there is accrual + chain.pending_timestamp += 3 * PERIOD_DURATION + before = token.balanceOf(beneficiary.address) + # Authorized caller calls withdraw for staking_provider + penalty_board_comp.withdraw(staking_provider.address, sender=caller) + after = token.balanceOf(beneficiary.address) + assert after > before, "Withdraw should send tokens to beneficiary" + + +def test_stakeless_staker_gets_zero_compensation(penalty_board_comp, registered_stakeless, chain): + """Stakeless staker accrues 0""" + chain.pending_timestamp += 10 * PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(registered_stakeless.address) + assert balance == 0, "Stakeless staker should have 0 accrued compensation" + + +def test_no_reward_staker_gets_zero_compensation(penalty_board_comp, registered_no_reward, chain): + """No-reward staker accrues 0""" + chain.pending_timestamp += 10 * PERIOD_DURATION + balance = penalty_board_comp.getAccruedBalance(registered_no_reward.address) + assert balance == 0, "No-reward staker should have 0 accrued compensation" + + +# --- Edge cases --- + + +def test_first_withdrawal_accrues_from_period_zero( + penalty_board_comp, + token, + beneficiary, + staking_provider, + registered_staker, + chain, +): + """First withdrawal accrues from period 0 to current; full amount to beneficiary.""" + num_periods = 4 + chain.pending_timestamp += (num_periods + 2) * PERIOD_DURATION + # Periods 0..4 → 5 periods + expected = (num_periods + 1) * FULL_COMPENSATION + + before = token.balanceOf(beneficiary.address) + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + after = token.balanceOf(beneficiary.address) + assert after - before == expected + + +def test_withdraw_reverts_when_nothing_left_after_prior_withdraw( + penalty_board_comp, + beneficiary, + staking_provider, + registered_staker, + chain, +): + """After a full withdraw, second withdraw without time advance reverts with Nothing to withdraw.""" + chain.pending_timestamp += 2 * PERIOD_DURATION + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + + +@pytest.mark.parametrize("cadence", [(12,), (4, 5, 6, 7, 8, 9, 10, 11, 12), (1, 7, 12)]) +def test_many_periods_full_accrual_regardless_of_withdraw_cadence( + penalty_board_comp, token, beneficiary, staking_provider, registered_staker, chain, cadence +): + """Many periods with no penalties: full accrual (numPeriods * fixed per period).""" + n = 10 + last_period = n + 2 + + # chain.pending_timestamp += 2 * PERIOD_DURATION + # Periods 0..n → n+1 periods + expected = (n - 1) * FULL_COMPENSATION + initial_balance = token.balanceOf(beneficiary.address) + for withdraw_period in cadence: + current_period = penalty_board_comp.getCurrentPeriod() + offset = withdraw_period - current_period + if offset > 0: + chain.pending_timestamp += offset * PERIOD_DURATION + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + + assert penalty_board_comp.getCurrentPeriod() == last_period + final_balance = token.balanceOf(beneficiary.address) + assert final_balance - initial_balance == expected + + +@pytest.mark.parametrize( + "periods", + [ + # T F F F F T -> 0 1 2 3 4 3 -> F F R1 R2 0 R2 + { + "penalized_periods": [1, 2, 3, 4], + "expected_full": 2, + "expected_reduced_1": 1, + "expected_reduced_2": 2, + "num_periods": 5, + "intermediate_withdraws": [], + }, + # F T T T F T -> 1 1 1 1 1 1 -> F F F F F F + { + "penalized_periods": [0, 4], + "expected_full": 6, + "expected_reduced_1": 0, + "expected_reduced_2": 0, + "num_periods": 5, + "intermediate_withdraws": [2, 3, 4], + }, + # T T T F F F T -> 0 0 0 1 2 3 3 -> F F F F R1 R2 R2 + { + "penalized_periods": [3, 4, 5], + "expected_full": 4, + "expected_reduced_1": 1, + "expected_reduced_2": 2, + "num_periods": 6, + "intermediate_withdraws": [2, 3, 4, 5], + }, + ], +) +def test_penalty_in_range_reduces_compensation( + penalty_board_comp, + token, + beneficiary, + staking_provider, + registered_staker, + chain, + informer, + periods, +): + """ + This test showcases several features of the compensation contract: + - Penalties in a period reduce compensation for the window between affected period and the next PENALTY_WINDOW_PERIODS periods, but not for other periods + - On each period, accrued amount depends on the penalty composition of the window + - Only periods with 0 or 1 penalties in the window get full compensation; 2 penalties get a reduced compensation, 3 penalties a further reduced compensation, and 4 penalties no compensation. + - Intermediate withdraws don't affect the overal accrued and witdrawn amount since accrued amount is always based on the full history of penalties and withdraw just takes the current accrued balance at the time of withdraw + """ + penalized_periods = periods["penalized_periods"] + expected_full = periods["expected_full"] + expected_reduced_1 = periods["expected_reduced_1"] + expected_reduced_2 = periods["expected_reduced_2"] + num_periods = periods["num_periods"] + intermediate_withdraws = periods["intermediate_withdraws"] + + before = token.balanceOf(beneficiary.address) + + penalty_index = 0 + withdraw_index = 0 + accrued_balance = 0 + first_period = 2 + for current_period in range(num_periods): + if ( + withdraw_index < len(intermediate_withdraws) + and intermediate_withdraws[withdraw_index] == current_period + ): + accrued_balance += penalty_board_comp.getAccruedBalance(staking_provider.address) + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + withdraw_index += 1 + if ( + penalty_index < len(penalized_periods) + and penalized_periods[penalty_index] == current_period + ): + penalty_board_comp.addPenalizedProvidersForPeriod( + [staking_provider.address], current_period + first_period, sender=informer + ) + penalty_index += 1 + + chain.pending_timestamp += PERIOD_DURATION + + chain.pending_timestamp += 2 * PERIOD_DURATION + + expected_compensation = ( + expected_full * FULL_COMPENSATION + + expected_reduced_1 * REDUCED_COMPENSATION_1 + + expected_reduced_2 * REDUCED_COMPENSATION_2 + ) + + accrued_balance += penalty_board_comp.getAccruedBalance(staking_provider.address) + assert accrued_balance == expected_compensation + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + after = token.balanceOf(beneficiary.address) + assert after - before == expected_compensation + + +def test_turning_on_and_off_reward( + penalty_board_comp, + staking_provider, + owner, + beneficiary, + deployer, + token, + registered_no_reward, + mock_taco_app, + chain, +): + """Turning on and off reward accrual should affect compensation: staker should accrue when reward is on, and not accrue when reward is off.""" + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert balance == 0 + + assert penalty_board_comp.getCurrentPeriod() == 2 + + # Period 2, off -> period 3, on + chain.pending_timestamp += PERIOD_DURATION + mock_taco_app.enableRewards(staking_provider.address, sender=deployer) + mock_taco_app.setRoles( + staking_provider.address, + owner.address, + beneficiary.address, + False, # Not stakeless + True, # rewards enabled since this staker should accrue rewards for now + sender=deployer, + ) + + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert balance == 0 + + # Period 3, on -> Period 5, off + chain.pending_timestamp += 2 * PERIOD_DURATION + mock_taco_app.computeRewards(staking_provider.address, sender=deployer) + mock_taco_app.setRoles( + staking_provider.address, + owner.address, + beneficiary.address, + False, + False, # rewards disabled since this staker should not accrue rewards now + sender=deployer, + ) + + assert penalty_board_comp.getCurrentPeriod() == 5 + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert balance == FULL_COMPENSATION + + # Period 5, off -> Period 6, on + chain.pending_timestamp += PERIOD_DURATION + mock_taco_app.enableRewards(staking_provider.address, sender=deployer) + mock_taco_app.setRoles( + staking_provider.address, + owner.address, + beneficiary.address, + False, + True, # rewards enabled since this staker should accrue rewards now + sender=deployer, + ) + + assert penalty_board_comp.getCurrentPeriod() == 6 + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert balance == FULL_COMPENSATION + + # Period 6, on -> Period 10, off + chain.pending_timestamp += 4 * PERIOD_DURATION + mock_taco_app.computeRewards(staking_provider.address, sender=deployer) + mock_taco_app.setRoles( + staking_provider.address, + owner.address, + beneficiary.address, + False, + False, # rewards disabled again + sender=deployer, + ) + + expected = 4 * FULL_COMPENSATION + assert penalty_board_comp.getCurrentPeriod() == 10 + balance = penalty_board_comp.getAccruedBalance(staking_provider.address) + assert balance == expected + + # Period 10, off -> off going forward + chain.pending_timestamp += 4 * PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == 14 + before = token.balanceOf(beneficiary.address) + penalty_board_comp.withdraw(staking_provider.address, sender=beneficiary) + after = token.balanceOf(beneficiary.address) + assert after - before == expected diff --git a/tests/test_periods.py b/tests/test_periods.py index a4c7465f1..b90208e30 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -8,12 +8,14 @@ def deployer(accounts): return accounts[0] + @pytest.fixture(scope="module") def periods_deployment(chain, deployer, project): - genesis_time = chain.pending_timestamp + genesis_time = chain.pending_timestamp - 2 * PERIOD_DURATION contract = project.Periods.deploy(genesis_time, PERIOD_DURATION, sender=deployer) return genesis_time, contract + @pytest.fixture(scope="module") def genesis(periods_deployment): genesis_time, _ = periods_deployment @@ -65,6 +67,6 @@ def test_get_current_period(periods, chain, genesis): # assert periods.getCurrentPeriod() == 0 chain.pending_timestamp += duration - assert periods.getCurrentPeriod() == 1 - chain.pending_timestamp += 2 * duration assert periods.getCurrentPeriod() == 3 + chain.pending_timestamp += 2 * duration + assert periods.getCurrentPeriod() == 5 diff --git a/tests/test_signing_coordinator.py b/tests/test_signing_coordinator.py index e23bdef1e..32edc87ed 100644 --- a/tests/test_signing_coordinator.py +++ b/tests/test_signing_coordinator.py @@ -72,6 +72,9 @@ def application(project, oz_dependency, deployer, token, nodes): ) taco_application.setChildApplication(child_application.address, sender=deployer) + penalty_board = project.PenaltyBoardForTACoApplicationMock.deploy(sender=deployer) + taco_application.setPenaltyBoard(penalty_board.address, sender=deployer) + # setup stakes / nodes for n in nodes: token.transfer(n, min_auth, sender=deployer)