Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/contracts/TACoApplication.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
239 changes: 229 additions & 10 deletions contracts/contracts/coordination/PenaltyBoard.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 4 additions & 0 deletions contracts/contracts/coordination/Periods.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
47 changes: 47 additions & 0 deletions contracts/test/MockTACoForPenaltyBoard.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading