diff --git a/contracts/contracts/TACoApplication.sol b/contracts/contracts/TACoApplication.sol index 6144babed..9be4f4c5a 100644 --- a/contracts/contracts/TACoApplication.sol +++ b/contracts/contracts/TACoApplication.sol @@ -122,6 +122,7 @@ contract TACoApplication is ITACoChildToRoot, OwnableUpgradeable { uint256 public immutable minOperatorSeconds; IERC20 public immutable token; + // PenaltyBoard public immutable penaltyBoard; ITACoRootToChild public childApplication; address private _adjudicator; diff --git a/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol b/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol new file mode 100644 index 000000000..644c812a2 --- /dev/null +++ b/contracts/contracts/coordination/ITACoApplicationForPenaltyBoard.sol @@ -0,0 +1,33 @@ +// 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); +} diff --git a/contracts/contracts/coordination/PenaltyBoard.sol b/contracts/contracts/coordination/PenaltyBoard.sol index ce28e648d..10eb995c4 100644 --- a/contracts/contracts/coordination/PenaltyBoard.sol +++ b/contracts/contracts/coordination/PenaltyBoard.sol @@ -3,53 +3,272 @@ 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; + + /** + * @notice Staker-centric penalty storage: list of period indices this staker was penalized in + * (monotonic append; periods are kept as uint256). + */ + mapping(address staker => uint256[]) public penalizedPeriodsByStaker; + /// Number of periods a penalty affects (penalty in period k affects k..k+PENALTY_WINDOW_PERIODS inclusive). + uint256 private constant PENALTY_WINDOW_PERIODS = 3; + + // Accrued compensation state (lazy accrual via _computeAccruedSinceLast). + // _lastAccruedPeriodPlusOne: 0 = never accrued (start from period 0); else start next accrual at this period. + mapping(address staker => uint256) private _accruedBalance; + mapping(address staker => uint256) private _lastAccruedPeriodPlusOne; + + /** + * @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. + */ constructor( uint256 genesisTime, uint256 periodDuration, - address admin + address admin, + address _tacoApplication, + address _token, + uint256 _fixedCompensationPerPeriod, + uint256 _fixedCompensationPerPeriod2Penalties, + uint256 _fixedCompensationPerPeriod3Penalties, + address _fundHolder ) 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; } - function getPenalizedProvidersForPeriod( - uint256 period - ) external view returns (address[] memory) { - return _penalizedProvidersByPeriod[period]; + /** + * @notice Returns the full list of period indices a staker was penalized in. + */ + function getPenalizedPeriodsByStaker(address staker) external view returns (uint256[] memory) { + return penalizedPeriodsByStaker[staker]; } /** - * @notice Set the list of penalized staking providers for a period. + * @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"); - delete _penalizedProvidersByPeriod[period]; for (uint256 i = 0; i < provs.length; i++) { - _penalizedProvidersByPeriod[period].push(provs[i]); + penalizedPeriodsByStaker[provs[i]].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; + } + + int256 current = getCurrentPaymentPeriod(); + uint256 delta = current >= 0 + ? _computeAccruedSinceLast(stakingProvider, uint256(current)) + : 0; + return _accruedBalance[stakingProvider] + 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"); + } + + int256 current = getCurrentPaymentPeriod(); + + uint256 delta = current >= 0 + ? _computeAccruedSinceLast(stakingProvider, uint256(current)) + : 0; + uint256 amount = _accruedBalance[stakingProvider] + delta; + require(amount > 0, "Nothing to withdraw"); + + _accruedBalance[stakingProvider] = 0; + _lastAccruedPeriodPlusOne[stakingProvider] = uint256(current) + 1; + + address beneficiary = beneficiaryAddress; + + compensationToken.safeTransferFrom(fundHolder, beneficiary, amount); + } + + 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.isStakeless(stakingProvider)) { + return 0; + } + + uint256 startPeriod = _lastAccruedPeriodPlusOne[stakingProvider]; // 0 = never accrued → start at 0 + + 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 = penalizedPeriodsByStaker[stakingProvider]; + 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..c4c2aad30 100644 --- a/contracts/contracts/coordination/Periods.sol +++ b/contracts/contracts/coordination/Periods.sol @@ -20,4 +20,8 @@ contract Periods { function getCurrentPeriod() public view returns (uint256) { return getPeriodForTimestamp(block.timestamp); } + + function getCurrentPaymentPeriod() public view returns (int256) { + return int256(getCurrentPeriod()) - 2; + } } diff --git a/contracts/test/MockTACoForPenaltyBoard.sol b/contracts/test/MockTACoForPenaltyBoard.sol new file mode 100644 index 000000000..86c3f5b37 --- /dev/null +++ b/contracts/test/MockTACoForPenaltyBoard.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "../contracts/coordination/ITACoApplicationForPenaltyBoard.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; + } + + mapping(address stakingProvider => StakerRoles) private _roles; + + function setRoles( + address stakingProvider, + address owner, + address payable beneficiary, + bool isStakeless + ) external { + _roles[stakingProvider] = StakerRoles({ + owner: owner, + beneficiary: beneficiary, + isStakeless: isStakeless + }); + } + + 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; + } +} diff --git a/tests/test_infraction.py b/tests/test_infraction.py index 8e5e69835..44d5fb652 100644 --- a/tests/test_infraction.py +++ b/tests/test_infraction.py @@ -135,12 +135,19 @@ 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.""" + Period duration is one week so ritual timeout still lands in period 0. No compensation.""" genesis_time = chain.pending_timestamp + zero = "0x0000000000000000000000000000000000000000" contract = project.PenaltyBoard.deploy( genesis_time, PENALTY_BOARD_PERIOD_DURATION, deployer.address, + zero, + zero, + 0, + 0, + 0, + zero, sender=deployer, ) contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) @@ -158,11 +165,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 +277,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..7716f201b 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,18 @@ 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.""" + """PenaltyBoard with genesis at current chain time and INFORMER_ROLE granted to informer (no compensation).""" genesis_time = chain.pending_timestamp contract = project.PenaltyBoard.deploy( genesis_time, PERIOD_DURATION, deployer.address, + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + 0, + 0, + ZERO_ADDRESS, sender=deployer, ) contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) @@ -41,7 +48,13 @@ def test_constructor_admin_required(project, deployer, chain): project.PenaltyBoard.deploy( genesis_time, PERIOD_DURATION, - "0x0000000000000000000000000000000000000000", + ZERO_ADDRESS, + ZERO_ADDRESS, + ZERO_ADDRESS, + 0, + 0, + 0, + ZERO_ADDRESS, sender=deployer, ) @@ -49,82 +62,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. chain.pending_timestamp += PERIOD_DURATION assert penalty_board.getCurrentPeriod() == 1 - 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, 0, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, 1, sender=informer) + periods = penalty_board.getPenalizedPeriodsByStaker(other_account.address) + assert periods == [current, 0, 1] # Period 2 is invalid (not current or previous). with ape.reverts("Invalid period"): - penalty_board.setPenalizedProvidersForPeriod(provs, 2, sender=informer) + penalty_board.addPenalizedProvidersForPeriod(provs, 2, 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..c243ac456 --- /dev/null +++ b/tests/test_penalty_board_compensation.py @@ -0,0 +1,366 @@ +import ape +import pytest + +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 + + +@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 + 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, + 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) + 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, + sender=deployer, + ) + return staking_provider + + +@pytest.fixture +def registered_stakeless(mock_taco_app, stakeless_provider, owner, beneficiary, deployer): + """Register stakeless_provider in mock TACo: owner=owner, beneficiary=beneficiary, stakeless.""" + mock_taco_app.setRoles( + stakeless_provider.address, + owner.address, + beneficiary.address, + True, # stakeless + 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_withdraw_reverts_nothing_to_withdraw_for_stakeless( + penalty_board_comp, registered_stakeless, chain, beneficiary +): + """Withdraw reverts with 'Nothing to withdraw' for stakeless staker (no accrual).""" + chain.pending_timestamp += 2 * PERIOD_DURATION + with ape.reverts("Nothing to withdraw"): + penalty_board_comp.withdraw(registered_stakeless.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 0, 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() == 0 + 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() == 1 + balance = penalty_board_comp.getAccruedBalance(registered_staker.address) + assert balance == 0 + + # After advancing 1 more period (2 total), we should accrue for period 0 + chain.pending_timestamp += PERIOD_DURATION + assert penalty_board_comp.getCurrentPeriod() == 2 + balance = penalty_board_comp.getAccruedBalance(registered_staker.address) + assert balance == FULL_COMPENSATION + + +def test_accrual_with_no_penalties_gives_positive_balance( + 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_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(): + 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 (when implemented) + 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" + + +#TODO: Test that stakers that are released or unstaked, stop accruing compensation + +# --- Edge cases --- + + +def test_first_withdrawal_accrues_from_period_zero( + penalty_board_comp, + token, + beneficiary, + staking_provider, + registered_staker, + chain, +): + """First withdrawal (sentinel: never accrued) 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,), (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 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 + first_period = 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_providers": [1, 2, 3, 4], + "expected": [2, 1, 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_providers": [0, 4], + "expected": [6, 0, 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_providers": [3, 4, 5], + "expected": [4, 1, 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, +): + """A penalty in period k affects k..k+PENALTY_WINDOW_PERIODS; only unaffected periods get full compensation.""" + penalized_providers = periods["penalized_providers"] + expected = periods["expected"] + num_periods = periods["num_periods"] + intermediate_withdraws = periods["intermediate_withdraws"] + + before = token.balanceOf(beneficiary.address) + + penalty_index = 0 + withdraw_index = 0 + accrued_balance = 0 + 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_providers) + and penalized_providers[penalty_index] == current_period + ): + penalty_board_comp.addPenalizedProvidersForPeriod( + [staking_provider.address], current_period, sender=informer + ) + penalty_index += 1 + + chain.pending_timestamp += PERIOD_DURATION + + chain.pending_timestamp += 2 * PERIOD_DURATION + + expected_compensation = ( + expected[0] * FULL_COMPENSATION + + expected[1] * REDUCED_COMPENSATION_1 + + expected[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