diff --git a/contracts/contracts/coordination/InfractionCollector.sol b/contracts/contracts/coordination/InfractionCollector.sol new file mode 100644 index 000000000..1e8a46674 --- /dev/null +++ b/contracts/contracts/coordination/InfractionCollector.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "@openzeppelin-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import "./Coordinator.sol"; +import "../../threshold/ITACoChildApplication.sol"; + +contract InfractionCollector is OwnableUpgradeable { + event InfractionReported( + uint32 indexed ritualId, + address indexed stakingProvider, + InfractionType infractionType + ); + // infraction types + enum InfractionType { + MISSING_TRANSCRIPT + } + Coordinator public immutable coordinator; + // Reference to the TACoChildApplication contract + ITACoChildApplication public immutable tacoChildApplication; + // Mapping to keep track of reported infractions + mapping(uint32 ritualId => mapping(address stakingProvider => mapping(InfractionType => uint256))) + public infractionsForRitual; + + constructor(Coordinator _coordinator) { + require(address(_coordinator) != address(0), "Contracts must be specified"); + coordinator = _coordinator; + tacoChildApplication = coordinator.application(); + _disableInitializers(); + } + + function initialize() external initializer { + __Ownable_init(msg.sender); + } + + function reportMissingTranscript(uint32 ritualId, address[] calldata stakingProviders) public { + // Ritual must have failed + require( + coordinator.getRitualState(ritualId) == Coordinator.RitualState.DKG_TIMEOUT, + "Ritual must have failed" + ); + + for (uint256 i = 0; i < stakingProviders.length; i++) { + // Check if the infraction has already been reported + require( + infractionsForRitual[ritualId][stakingProviders[i]][ + InfractionType.MISSING_TRANSCRIPT + ] == 0, + "Infraction already reported" + ); + Coordinator.Participant memory participant = coordinator.getParticipantFromProvider( + ritualId, + stakingProviders[i] + ); + require(participant.transcript.length == 0, "Transcript is not missing"); + infractionsForRitual[ritualId][stakingProviders[i]][ + InfractionType.MISSING_TRANSCRIPT + ] = 1; + emit InfractionReported( + ritualId, + stakingProviders[i], + InfractionType.MISSING_TRANSCRIPT + ); + } + } +} diff --git a/contracts/contracts/coordination/PenaltyBoard.sol b/contracts/contracts/coordination/PenaltyBoard.sol new file mode 100644 index 000000000..ce28e648d --- /dev/null +++ b/contracts/contracts/coordination/PenaltyBoard.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "./Periods.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. + */ +contract PenaltyBoard is Periods, AccessControl { + bytes32 public constant INFORMER_ROLE = keccak256("INFORMER_ROLE"); + + event PenalizedProvidersSet(uint256 indexed period, address[] providers); + + mapping(uint256 period => address[]) private _penalizedProvidersByPeriod; + + constructor( + uint256 genesisTime, + uint256 periodDuration, + address admin + ) Periods(genesisTime, periodDuration) { + require(admin != address(0), "Admin required"); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + function getPenalizedProvidersForPeriod( + uint256 period + ) external view returns (address[] memory) { + return _penalizedProvidersByPeriod[period]; + } + + /** + * @notice Set the list of penalized staking providers 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). + */ + function setPenalizedProvidersForPeriod( + 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]); + } + + emit PenalizedProvidersSet(period, provs); + } +} diff --git a/contracts/contracts/coordination/Periods.sol b/contracts/contracts/coordination/Periods.sol new file mode 100644 index 000000000..9c002c117 --- /dev/null +++ b/contracts/contracts/coordination/Periods.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.0; + +contract Periods { + uint256 public immutable genesisTime; + uint256 public immutable periodDuration; + + constructor(uint256 _genesisTime, uint256 _periodDuration) { + require(_periodDuration > 0, "Invalid period duration"); + genesisTime = _genesisTime; + periodDuration = _periodDuration; + } + + function getPeriodForTimestamp(uint256 timestamp) public view returns (uint256) { + require(timestamp >= genesisTime, "Timestamp is before genesis"); + return (timestamp - genesisTime) / periodDuration; + } + + function getCurrentPeriod() public view returns (uint256) { + return getPeriodForTimestamp(block.timestamp); + } +} diff --git a/contracts/contracts/testnet/OpenAccessAuthorizer.sol b/contracts/contracts/testnet/OpenAccessAuthorizer.sol index 717133f76..dabda7463 100644 --- a/contracts/contracts/testnet/OpenAccessAuthorizer.sol +++ b/contracts/contracts/testnet/OpenAccessAuthorizer.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: AGPL-3.0-or-later - pragma solidity ^0.8.0; import "../coordination/IEncryptionAuthorizer.sol"; diff --git a/deployment/artifacts/lynx.json b/deployment/artifacts/lynx.json index 4cdca37cd..c0daaf12f 100644 --- a/deployment/artifacts/lynx.json +++ b/deployment/artifacts/lynx.json @@ -6178,6 +6178,245 @@ "block_number": 9101909, "deployer": "0x3B42d26E19FF860bC4dEbB920DD8caA53F93c600" }, + "InfractionCollector": { + "address": "0xad8dADaB38eC94B8fe3c482f7550044201506369", + "abi": [ + { + "type": "constructor", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "_coordinator", + "type": "address", + "components": null, + "internal_type": "contract Coordinator" + }, + { + "name": "_tacoChildApplication", + "type": "address", + "components": null, + "internal_type": "contract ITACoChildApplication" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "event", + "name": "InfractionReported", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32", + "indexed": true + }, + { + "name": "stakingProvider", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + }, + { + "name": "infractionType", + "type": "uint8", + "components": null, + "internal_type": "enum InfractionCollector.InfractionType", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "components": null, + "internal_type": "uint64", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + }, + { + "name": "newOwner", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + } + ], + "anonymous": false + }, + { + "type": "function", + "name": "coordinator", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "contract Coordinator" + } + ] + }, + { + "type": "function", + "name": "infractions", + "stateMutability": "view", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32" + }, + { + "name": "stakingProvider", + "type": "address", + "components": null, + "internal_type": "address" + }, + { + "name": "", + "type": "uint8", + "components": null, + "internal_type": "enum InfractionCollector.InfractionType" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "components": null, + "internal_type": "bool" + } + ] + }, + { + "type": "function", + "name": "owner", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "function", + "name": "renounceOwnership", + "stateMutability": "nonpayable", + "inputs": [], + "outputs": [] + }, + { + "type": "function", + "name": "reportMissingTranscript", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32" + }, + { + "name": "stakingProviders", + "type": "address[]", + "components": null, + "internal_type": "address[]" + } + ], + "outputs": [] + }, + { + "type": "function", + "name": "tacoChildApplication", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "contract ITACoChildApplication" + } + ] + }, + { + "type": "function", + "name": "transferOwnership", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "components": null, + "internal_type": "address" + } + ], + "outputs": [] + } + ], + "tx_hash": "0x36762db270b4706dfe829502e5b6469f783acb4bc74e9d21908ecdc88f150629", + "block_number": 10661923, + "deployer": "0x3B42d26E19FF860bC4dEbB920DD8caA53F93c600" + }, "LynxRitualToken": { "address": "0x064Be2a9740e565729BC0d47bC616c5bb8Cc87B9", "abi": [ diff --git a/deployment/artifacts/mainnet-infraction.json b/deployment/artifacts/mainnet-infraction.json new file mode 100644 index 000000000..626ed142c --- /dev/null +++ b/deployment/artifacts/mainnet-infraction.json @@ -0,0 +1,225 @@ +{ + "137": { + "InfractionCollector": { + "address": "0x63d2A01f006D553a2348386355f8FC3028CDf3bB", + "abi": [ + { + "type": "constructor", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "_coordinator", + "type": "address", + "internalType": "contract Coordinator" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "event", + "name": "InfractionReported", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "internalType": "uint32", + "indexed": true + }, + { + "name": "stakingProvider", + "type": "address", + "internalType": "address", + "indexed": true + }, + { + "name": "infractionType", + "type": "uint8", + "internalType": "enum InfractionCollector.InfractionType", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "internalType": "uint64", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "internalType": "address", + "indexed": true + }, + { + "name": "newOwner", + "type": "address", + "internalType": "address", + "indexed": true + } + ], + "anonymous": false + }, + { + "type": "function", + "name": "coordinator", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract Coordinator" + } + ] + }, + { + "type": "function", + "name": "infractionsForRitual", + "stateMutability": "view", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "stakingProvider", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "uint8", + "internalType": "enum InfractionCollector.InfractionType" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "type": "function", + "name": "initialize", + "stateMutability": "nonpayable", + "inputs": [], + "outputs": [] + }, + { + "type": "function", + "name": "owner", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "function", + "name": "renounceOwnership", + "stateMutability": "nonpayable", + "inputs": [], + "outputs": [] + }, + { + "type": "function", + "name": "reportMissingTranscript", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "internalType": "uint32" + }, + { + "name": "stakingProviders", + "type": "address[]", + "internalType": "address[]" + } + ], + "outputs": [] + }, + { + "type": "function", + "name": "tacoChildApplication", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract ITACoChildApplication" + } + ] + }, + { + "type": "function", + "name": "transferOwnership", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [] + } + ], + "tx_hash": "0x74b0443db26d2806253eef1c6e7b29daa67501ca935c22c04e9ded2956864804", + "block_number": 61059570, + "deployer": "0x220a8442C0f4436971CbBa565bB6888C3EDa891b" + } + } +} \ No newline at end of file diff --git a/deployment/artifacts/tapir.json b/deployment/artifacts/tapir.json index 9e57f8bb5..328f1aa25 100644 --- a/deployment/artifacts/tapir.json +++ b/deployment/artifacts/tapir.json @@ -6085,6 +6085,245 @@ "block_number": 5393004, "deployer": "0x3B42d26E19FF860bC4dEbB920DD8caA53F93c600" }, + "InfractionCollector": { + "address": "0xb6400F55857716A3Ff863e6bE867F01F23C71793", + "abi": [ + { + "type": "constructor", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "_coordinator", + "type": "address", + "components": null, + "internal_type": "contract Coordinator" + }, + { + "name": "_tacoChildApplication", + "type": "address", + "components": null, + "internal_type": "contract ITACoChildApplication" + } + ] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "event", + "name": "InfractionReported", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32", + "indexed": true + }, + { + "name": "stakingProvider", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + }, + { + "name": "infractionType", + "type": "uint8", + "components": null, + "internal_type": "enum InfractionCollector.InfractionType", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Initialized", + "inputs": [ + { + "name": "version", + "type": "uint64", + "components": null, + "internal_type": "uint64", + "indexed": false + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + }, + { + "name": "newOwner", + "type": "address", + "components": null, + "internal_type": "address", + "indexed": true + } + ], + "anonymous": false + }, + { + "type": "function", + "name": "coordinator", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "contract Coordinator" + } + ] + }, + { + "type": "function", + "name": "infractions", + "stateMutability": "view", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32" + }, + { + "name": "stakingProvider", + "type": "address", + "components": null, + "internal_type": "address" + }, + { + "name": "", + "type": "uint8", + "components": null, + "internal_type": "enum InfractionCollector.InfractionType" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "components": null, + "internal_type": "bool" + } + ] + }, + { + "type": "function", + "name": "owner", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "address" + } + ] + }, + { + "type": "function", + "name": "renounceOwnership", + "stateMutability": "nonpayable", + "inputs": [], + "outputs": [] + }, + { + "type": "function", + "name": "reportMissingTranscript", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "ritualId", + "type": "uint32", + "components": null, + "internal_type": "uint32" + }, + { + "name": "stakingProviders", + "type": "address[]", + "components": null, + "internal_type": "address[]" + } + ], + "outputs": [] + }, + { + "type": "function", + "name": "tacoChildApplication", + "stateMutability": "view", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "components": null, + "internal_type": "contract ITACoChildApplication" + } + ] + }, + { + "type": "function", + "name": "transferOwnership", + "stateMutability": "nonpayable", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "components": null, + "internal_type": "address" + } + ], + "outputs": [] + } + ], + "tx_hash": "0x7c11685e52dca884556e225541211e3d747da2bd796fb88c75fdeed3910ba488", + "block_number": 10667701, + "deployer": "0x3B42d26E19FF860bC4dEbB920DD8caA53F93c600" + }, "MockPolygonChild": { "address": "0x469fBc4737f4d502d46B393E9a625EF754672644", "abi": [ diff --git a/deployment/constructor_params/lynx/infraction.yml b/deployment/constructor_params/lynx/infraction.yml new file mode 100644 index 000000000..3b0ee3dd0 --- /dev/null +++ b/deployment/constructor_params/lynx/infraction.yml @@ -0,0 +1,17 @@ +deployment: + name: infraction + chain_id: 80002 + +artifacts: + dir: ./deployment/artifacts/ + filename: infraction.json + +constants: + # See deployment/artifacts/lynx.json + COORDINATOR_PROXY: "0xE9e94499bB0f67b9DBD75506ec1735486DE57770" + +contracts: + - InfractionCollector: + proxy: + constructor: + _coordinator: $COORDINATOR_PROXY diff --git a/deployment/constructor_params/mainnet/infraction.yml b/deployment/constructor_params/mainnet/infraction.yml new file mode 100644 index 000000000..771a7d018 --- /dev/null +++ b/deployment/constructor_params/mainnet/infraction.yml @@ -0,0 +1,24 @@ +deployment: + name: infraction + chain_id: 137 + +artifacts: + dir: ./deployment/artifacts/ + filename: mainnet-infraction.json + +constants: + # See deployment/artifacts/mainnet.json + COORDINATOR_PROXY: "0xE74259e3dafe30bAA8700238e324b47aC98FE755" + # Threshold Network - References: + # - https://docs.threshold.network/resources/contract-addresses/mainnet/threshold-dao + # - https://github.com/keep-network/tbtc-v2/issues/594 + THRESHOLD_COUNCIL_ON_POLYGON: "0x9F6e831c8F8939DC0C830C6e492e7cEf4f9C2F5f" + +contracts: + - InfractionCollector: + proxy: + constructor: + initialOwner: $THRESHOLD_COUNCIL_ON_POLYGON # Upgrades owner + _data: $encode:initialize + constructor: + _coordinator: $COORDINATOR_PROXY diff --git a/deployment/constructor_params/tapir/infraction.yml b/deployment/constructor_params/tapir/infraction.yml new file mode 100644 index 000000000..f0e589e1a --- /dev/null +++ b/deployment/constructor_params/tapir/infraction.yml @@ -0,0 +1,17 @@ +deployment: + name: infraction + chain_id: 80002 + +artifacts: + dir: ./deployment/artifacts/ + filename: tapir.json + +constants: + # See deployment/artifacts/tapir.json + COORDINATOR_PROXY: "0xE690b6bCC0616Dc5294fF84ff4e00335cA52C388" + +contracts: + - InfractionCollector: + proxy: + constructor: + _coordinator: $COORDINATOR_PROXY diff --git a/scripts/lynx/deploy_infraction.py b/scripts/lynx/deploy_infraction.py new file mode 100644 index 000000000..1ab64c8b2 --- /dev/null +++ b/scripts/lynx/deploy_infraction.py @@ -0,0 +1,25 @@ +#!/usr/bin/python3 + +from ape import project + +from deployment.constants import ( + CONSTRUCTOR_PARAMS_DIR, ARTIFACTS_DIR, +) +from deployment.params import Deployer +from deployment.registry import merge_registries + +VERIFY = False +CONSTRUCTOR_PARAMS_FILEPATH = CONSTRUCTOR_PARAMS_DIR / "lynx" / "infraction.yml" +LYNX_REGISTRY = ARTIFACTS_DIR / "lynx.json" + + +def main(): + deployer = Deployer.from_yaml(filepath=CONSTRUCTOR_PARAMS_FILEPATH, verify=VERIFY) + infraction = deployer.deploy(project.InfractionCollector) + deployments = [infraction] + deployer.finalize(deployments=deployments) + merge_registries( + registry_1_filepath=LYNX_REGISTRY, + registry_2_filepath=deployer.registry_filepath, + output_filepath=LYNX_REGISTRY, + ) diff --git a/scripts/mainnet/deploy_infraction.py b/scripts/mainnet/deploy_infraction.py new file mode 100644 index 000000000..777ab5aa8 --- /dev/null +++ b/scripts/mainnet/deploy_infraction.py @@ -0,0 +1,19 @@ +#!/usr/bin/python3 + +from ape import project + +from deployment.constants import ( + CONSTRUCTOR_PARAMS_DIR, +) +from deployment.params import Deployer + +VERIFY = False +CONSTRUCTOR_PARAMS_FILEPATH = CONSTRUCTOR_PARAMS_DIR / "mainnet" / "infraction.yml" + + +def main(): + deployer = Deployer.from_yaml(filepath=CONSTRUCTOR_PARAMS_FILEPATH, verify=VERIFY) + infraction = deployer.deploy(project.InfractionCollector) + deployments = [infraction] + deployer.finalize(deployments=deployments) + diff --git a/scripts/tapir/deploy_infraction.py b/scripts/tapir/deploy_infraction.py new file mode 100644 index 000000000..ec8944e70 --- /dev/null +++ b/scripts/tapir/deploy_infraction.py @@ -0,0 +1,20 @@ +#!/usr/bin/python3 + +from ape import project + +from deployment.constants import ( + CONSTRUCTOR_PARAMS_DIR, ARTIFACTS_DIR, +) +from deployment.params import Deployer + +VERIFY = False +CONSTRUCTOR_PARAMS_FILEPATH = CONSTRUCTOR_PARAMS_DIR / "tapir" / "infraction.yml" +TAPIR_REGISTRY = ARTIFACTS_DIR / "tapir.json" + + +def main(): + deployer = Deployer.from_yaml(filepath=CONSTRUCTOR_PARAMS_FILEPATH, verify=VERIFY) + infraction = deployer.deploy(project.InfractionCollector) + deployments = [infraction] + deployer.finalize(deployments=deployments) + diff --git a/tests/test_infraction.py b/tests/test_infraction.py new file mode 100644 index 000000000..8e5e69835 --- /dev/null +++ b/tests/test_infraction.py @@ -0,0 +1,276 @@ +import os +from enum import IntEnum + +import ape +import pytest + +from tests.conftest import generate_transcript + +TIMEOUT = 1000 +MAX_DKG_SIZE = 31 +FEE_RATE = 42 +ERC20_SUPPLY = 10**24 +DURATION = 48 * 60 * 60 +ONE_DAY = 24 * 60 * 60 +# Period duration for PenaltyBoard: long enough that ritual timeout stays in period 0 +PENALTY_BOARD_PERIOD_DURATION = 7 * ONE_DAY + +RITUAL_ID = 0 + +infraction_types = IntEnum( + "InfractionType", + [ + "MISSING_TRANSCRIPT", + ], + start=0, +) + + +# This formula returns an approximated size +# To have a representative size, create transcripts with `nucypher-core` +def transcript_size(shares, threshold): + return int(424 + 240 * (shares / 2) + 50 * (threshold)) + + +def gen_public_key(): + return (os.urandom(32), os.urandom(32), os.urandom(32)) + + +def access_control_error_message(address, role=None): + role = role or b"\x00" * 32 + return f"account={address}, neededRole={role}" + + +@pytest.fixture(scope="module") +def nodes(accounts): + return sorted(accounts[:MAX_DKG_SIZE], key=lambda x: x.address.lower()) + + +@pytest.fixture(scope="module") +def initiator(accounts): + initiator_index = MAX_DKG_SIZE + 1 + assert len(accounts) >= initiator_index + return accounts[initiator_index] + + +@pytest.fixture(scope="module") +def deployer(accounts): + deployer_index = MAX_DKG_SIZE + 2 + assert len(accounts) >= deployer_index + return accounts[deployer_index] + + +@pytest.fixture(scope="module") +def treasury(accounts): + treasury_index = MAX_DKG_SIZE + 3 + assert len(accounts) >= treasury_index + return accounts[treasury_index] + + +@pytest.fixture(scope="module") +def informer(accounts): + informer_index = MAX_DKG_SIZE + 4 + assert len(accounts) >= informer_index + return accounts[informer_index] + + +@pytest.fixture() +def application(project, deployer, nodes): + contract = project.ChildApplicationForCoordinatorMock.deploy(sender=deployer) + for n in nodes: + contract.updateOperator(n, n, sender=deployer) + contract.updateAuthorization(n, 42, sender=deployer) + return contract + + +@pytest.fixture() +def erc20(project, initiator): + token = project.TestToken.deploy(ERC20_SUPPLY, sender=initiator) + return token + + +@pytest.fixture() +def coordinator(project, deployer, application, oz_dependency): + admin = deployer + contract = project.Coordinator.deploy( + application.address, + TIMEOUT, + 0, + sender=deployer, + ) + + encoded_initializer_function = contract.initialize.encode_input(MAX_DKG_SIZE, admin) + proxy = oz_dependency.TransparentUpgradeableProxy.deploy( + contract.address, + deployer, + encoded_initializer_function, + sender=deployer, + ) + proxy_contract = project.Coordinator.at(proxy.address) + return proxy_contract + + +@pytest.fixture() +def global_allow_list(project, deployer, coordinator): + contract = project.GlobalAllowList.deploy(coordinator.address, sender=deployer) + return contract + + +@pytest.fixture() +def fee_model(project, deployer, coordinator, erc20, treasury): + contract = project.FlatRateFeeModel.deploy( + coordinator.address, erc20.address, FEE_RATE, sender=deployer + ) + coordinator.grantRole(coordinator.FEE_MODEL_MANAGER_ROLE(), treasury, sender=deployer) + coordinator.approveFeeModel(contract.address, sender=treasury) + return contract + + +@pytest.fixture +def infraction_collector(project, deployer, coordinator): + contract = project.InfractionCollector.deploy(coordinator.address, sender=deployer) + return contract + + +@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 + contract = project.PenaltyBoard.deploy( + genesis_time, + PENALTY_BOARD_PERIOD_DURATION, + deployer.address, + sender=deployer, + ) + contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) + return contract + + +def test_no_infractions( + erc20, nodes, initiator, global_allow_list, infraction_collector, coordinator, fee_model +): + cost = fee_model.getRitualCost(len(nodes), DURATION) + for node in nodes: + public_key = gen_public_key() + coordinator.setProviderPublicKey(public_key, sender=node) + erc20.approve(fee_model.address, cost, sender=initiator) + 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) + + with ape.reverts("Ritual must have failed"): + infraction_collector.reportMissingTranscript(0, nodes, sender=initiator) + + +def test_partial_infractions( + erc20, nodes, initiator, global_allow_list, infraction_collector, coordinator, chain, fee_model +): + cost = fee_model.getRitualCost(len(nodes), DURATION) + for node in nodes: + public_key = gen_public_key() + coordinator.setProviderPublicKey(public_key, sender=node) + erc20.approve(fee_model.address, cost, sender=initiator) + coordinator.initiateRitual( + fee_model, nodes, initiator, DURATION, global_allow_list.address, sender=initiator + ) + # post transcript for half of nodes + size = len(nodes) + threshold = coordinator.getThresholdForRitualSize(size) + transcript = generate_transcript(size, threshold) + for node in nodes[: len(nodes) // 2]: + coordinator.publishTranscript(RITUAL_ID, transcript, sender=node) + chain.pending_timestamp += TIMEOUT * 2 + infraction_collector.reportMissingTranscript( + RITUAL_ID, nodes[len(nodes) // 2 :], sender=initiator + ) + # first half of nodes should be fine, second half should be infracted + for node in nodes[: len(nodes) // 2]: + assert not infraction_collector.infractionsForRitual( + RITUAL_ID, node, infraction_types.MISSING_TRANSCRIPT + ) + for node in nodes[len(nodes) // 2 :]: + assert infraction_collector.infractionsForRitual( + RITUAL_ID, node, infraction_types.MISSING_TRANSCRIPT + ) + + +def test_report_infractions( + erc20, nodes, initiator, global_allow_list, infraction_collector, coordinator, chain, fee_model +): + cost = fee_model.getRitualCost(len(nodes), DURATION) + for node in nodes: + public_key = gen_public_key() + coordinator.setProviderPublicKey(public_key, sender=node) + erc20.approve(fee_model.address, cost, sender=initiator) + coordinator.initiateRitual( + fee_model, nodes, initiator, DURATION, global_allow_list.address, sender=initiator + ) + chain.pending_timestamp += TIMEOUT * 2 + infraction_collector.reportMissingTranscript(RITUAL_ID, nodes, sender=initiator) + for node in nodes: + assert infraction_collector.infractionsForRitual( + RITUAL_ID, node, infraction_types.MISSING_TRANSCRIPT + ) + + +def test_cant_report_infractions_twice( + erc20, nodes, initiator, global_allow_list, infraction_collector, coordinator, chain, fee_model +): + cost = fee_model.getRitualCost(len(nodes), DURATION) + for node in nodes: + public_key = gen_public_key() + coordinator.setProviderPublicKey(public_key, sender=node) + erc20.approve(fee_model.address, cost, sender=initiator) + coordinator.initiateRitual( + fee_model, nodes, initiator, DURATION, global_allow_list.address, sender=initiator + ) + chain.pending_timestamp += TIMEOUT * 2 + infraction_collector.reportMissingTranscript(RITUAL_ID, nodes, sender=initiator) + + with ape.reverts("Infraction already reported"): + infraction_collector.reportMissingTranscript(RITUAL_ID, nodes, sender=initiator) + + +def test_infraction_collector_and_penalty_board_together( + erc20, + nodes, + initiator, + global_allow_list, + infraction_collector, + coordinator, + chain, + fee_model, + penalty_board, + informer, +): + """Use InfractionCollector (ritual-level infractions) and PenaltyBoard (period-level penalties) together. + No on-chain link: informer sets penalized providers on PenaltyBoard from the same addresses that were + reported to InfractionCollector.""" + cost = fee_model.getRitualCost(len(nodes), DURATION) + for node in nodes: + public_key = gen_public_key() + coordinator.setProviderPublicKey(public_key, sender=node) + erc20.approve(fee_model.address, cost, sender=initiator) + coordinator.initiateRitual( + fee_model, nodes, initiator, DURATION, global_allow_list.address, sender=initiator + ) + # No transcripts published; ritual times out + chain.pending_timestamp += TIMEOUT * 2 + failing_providers = [n.address for n in nodes] + infraction_collector.reportMissingTranscript(RITUAL_ID, nodes, sender=initiator) + + # 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 diff --git a/tests/test_penalty_board.py b/tests/test_penalty_board.py new file mode 100644 index 000000000..0564c30fa --- /dev/null +++ b/tests/test_penalty_board.py @@ -0,0 +1,130 @@ +"""Tests for PenaltyBoard in isolation (period-oriented penalized providers).""" + +import ape +import pytest + +PERIOD_DURATION = 3600 # 1 hour + + +@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 other_account(accounts): + return accounts[2] + + +@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 + contract = project.PenaltyBoard.deploy( + genesis_time, + PERIOD_DURATION, + deployer.address, + sender=deployer, + ) + contract.grantRole(contract.INFORMER_ROLE(), informer.address, sender=deployer) + return contract + + +def test_constructor_admin_required(project, deployer, chain): + genesis_time = chain.pending_timestamp + with ape.reverts("Admin required"): + project.PenaltyBoard.deploy( + genesis_time, + PERIOD_DURATION, + "0x0000000000000000000000000000000000000000", + sender=deployer, + ) + + +def test_only_informer_can_set_penalized_providers( + penalty_board, deployer, informer, other_account +): + """Only an account with INFORMER_ROLE can call setPenalizedProvidersForPeriod.""" + current = penalty_board.getCurrentPeriod() + provs = [other_account.address] + + with ape.reverts(): + penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=deployer) + + penalty_board.setPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board.getPenalizedProvidersForPeriod(current) == provs + + +def test_period_must_be_current_or_previous( + penalty_board, chain, informer, other_account +): + """setPenalizedProvidersForPeriod 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.setPenalizedProvidersForPeriod(provs, current, sender=informer) + assert penalty_board.getPenalizedProvidersForPeriod(current) == provs + + # 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 + + # Period 2 is invalid (not current or previous). + with ape.reverts("Invalid period"): + penalty_board.setPenalizedProvidersForPeriod(provs, 2, sender=informer) + + +def test_getter_returns_set_list( + penalty_board, informer, other_account, accounts +): + """getPenalizedProvidersForPeriod returns the list set for that period.""" + 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 + + +def test_setting_again_replaces_list( + penalty_board, informer, other_account, accounts +): + """Calling setPenalizedProvidersForPeriod again for the same period replaces the list.""" + current = penalty_board.getCurrentPeriod() + + first = [accounts[5].address, accounts[6].address] + penalty_board.setPenalizedProvidersForPeriod(first, current, sender=informer) + assert penalty_board.getPenalizedProvidersForPeriod(current) == first + + second = [other_account.address] + penalty_board.setPenalizedProvidersForPeriod(second, current, sender=informer) + assert penalty_board.getPenalizedProvidersForPeriod(current) == second + + +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 + + with ape.reverts("Invalid period"): + penalty_board.setPenalizedProvidersForPeriod(provs, 1, sender=informer) diff --git a/tests/test_periods.py b/tests/test_periods.py new file mode 100644 index 000000000..a4c7465f1 --- /dev/null +++ b/tests/test_periods.py @@ -0,0 +1,70 @@ +import ape +import pytest + +PERIOD_DURATION = 3600 # 1 hour + + +@pytest.fixture(scope="module") +def deployer(accounts): + return accounts[0] + +@pytest.fixture(scope="module") +def periods_deployment(chain, deployer, project): + genesis_time = chain.pending_timestamp + 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 + return genesis_time + + +@pytest.fixture(scope="module") +def periods(project, deployer, genesis): + """Periods contract with fixed genesis and period duration.""" + contract = project.Periods.deploy(genesis, PERIOD_DURATION, sender=deployer) + return contract + + +def test_constructor_invalid_period_duration(project, deployer, chain): + with ape.reverts("Invalid period duration"): + project.Periods.deploy(chain.pending_timestamp, 0, sender=deployer) + + +def test_immutables(periods, genesis): + assert periods.genesisTime() == genesis + assert periods.periodDuration() == PERIOD_DURATION + + +def test_get_period_for_timestamp_before_genesis(periods, genesis): + with ape.reverts("Timestamp is before genesis"): + periods.getPeriodForTimestamp(genesis - 1) + + +def test_get_period_for_timestamp_at_genesis(periods, genesis): + assert periods.getPeriodForTimestamp(genesis) == 0 + + +def test_get_period_for_timestamp_after_periods(periods, genesis): + duration = periods.periodDuration() + assert periods.getPeriodForTimestamp(genesis + duration - 1) == 0 + assert periods.getPeriodForTimestamp(genesis + duration) == 1 + assert periods.getPeriodForTimestamp(genesis + 2 * duration) == 2 + assert periods.getPeriodForTimestamp(genesis + 5 * duration + 1) == 5 + + +def test_get_current_period(periods, chain, genesis): + """getCurrentPeriod uses block.timestamp; we set chain time to align with genesis then advance.""" + duration = periods.periodDuration() + + # TODO: eth-tester limitations prevent us from traveling back in time. + # A proper test would imply a mock Periods contract that allows us to set the timestamp directly, rather than relying on chain time manipulation. + # For now, not worth it. + # chain.pending_timestamp = genesis + # assert periods.getCurrentPeriod() == 0 + + chain.pending_timestamp += duration + assert periods.getCurrentPeriod() == 1 + chain.pending_timestamp += 2 * duration + assert periods.getCurrentPeriod() == 3