diff --git a/contracts/contracts/coordination/InfractionCollector.sol b/contracts/contracts/coordination/InfractionCollector.sol new file mode 100644 index 000000000..6eeb5181a --- /dev/null +++ b/contracts/contracts/coordination/InfractionCollector.sol @@ -0,0 +1,70 @@ +// 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 + ) external { + // 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/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 7d1f65d6c..7f34590dd 100644 --- a/deployment/artifacts/lynx.json +++ b/deployment/artifacts/lynx.json @@ -5476,6 +5476,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/tapir.json b/deployment/artifacts/tapir.json index af416637d..110c80cde 100644 --- a/deployment/artifacts/tapir.json +++ b/deployment/artifacts/tapir.json @@ -4288,6 +4288,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": "0x970b5f6A299813cA9DC45Be8446929b6513903f9", "abi": [ @@ -5866,4 +6105,4 @@ "deployer": "0x3B42d26E19FF860bC4dEbB920DD8caA53F93c600" } } -} +} \ No newline at end of file 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/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/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..f69721a46 --- /dev/null +++ b/tests/test_infraction.py @@ -0,0 +1,205 @@ +import os +from enum import IntEnum + +import ape +import pytest + +TIMEOUT = 1000 +MAX_DKG_SIZE = 31 +FEE_RATE = 42 +ERC20_SUPPLY = 10**24 +DURATION = 48 * 60 * 60 +ONE_DAY = 24 * 60 * 60 + +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() +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, + sender=deployer, + ) + + encoded_initializer_function = contract.initialize.encode_input(TIMEOUT, 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.TREASURY_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 + + +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 + ) + transcript = os.urandom(transcript_size(len(nodes), len(nodes))) + for node in nodes: + coordinator.postTranscript(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 + ) + transcript = os.urandom(transcript_size(len(nodes), len(nodes))) + # post transcript for half of nodes + for node in nodes[: len(nodes) // 2]: + coordinator.postTranscript(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)