diff --git a/abi/MigratorV6Epochs9to12Rewards.json b/abi/MigratorV6Epochs9to12Rewards.json new file mode 100644 index 00000000..eb1d9d7c --- /dev/null +++ b/abi/MigratorV6Epochs9to12Rewards.json @@ -0,0 +1,457 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "hubAddress", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + } + ], + "name": "ProfileDoesntExist", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "msg", + "type": "string" + } + ], + "name": "UnauthorizedAccess", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddressHub", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + }, + { + "indexed": true, + "internalType": "address", + "name": "delegator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint96", + "name": "amount", + "type": "uint96" + } + ], + "name": "DelegatorRewardAmountSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + }, + { + "indexed": false, + "internalType": "uint96", + "name": "amount", + "type": "uint96" + } + ], + "name": "OperatorRewardAmountSet", + "type": "event" + }, + { + "inputs": [], + "name": "END_EPOCH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "START_EPOCH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "askContract", + "outputs": [ + { + "internalType": "contract Ask", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "chronos", + "outputs": [ + { + "internalType": "contract Chronos", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "", + "type": "uint72" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "claimedDelegatorReward", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "", + "type": "uint72" + } + ], + "name": "claimedOperatorReward", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "", + "type": "uint72" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "delegatorRewardAmount", + "outputs": [ + { + "internalType": "uint96", + "name": "", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "delegatorsInfo", + "outputs": [ + { + "internalType": "contract DelegatorsInfo", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "hub", + "outputs": [ + { + "internalType": "contract Hub", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + }, + { + "internalType": "address", + "name": "delegator", + "type": "address" + } + ], + "name": "migrateDelegatorReward", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + } + ], + "name": "migrateOperatorReward", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "", + "type": "uint72" + } + ], + "name": "operatorRewardAmount", + "outputs": [ + { + "internalType": "uint96", + "name": "", + "type": "uint96" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "parametersStorage", + "outputs": [ + { + "internalType": "contract ParametersStorage", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "profileStorage", + "outputs": [ + { + "internalType": "contract ProfileStorage", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + }, + { + "internalType": "address", + "name": "delegator", + "type": "address" + }, + { + "internalType": "uint96", + "name": "amount", + "type": "uint96" + } + ], + "name": "setDelegatorRewardAmount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "identityId", + "type": "uint72" + }, + { + "internalType": "uint96", + "name": "amount", + "type": "uint96" + } + ], + "name": "setOperatorRewardAmount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bool", + "name": "_status", + "type": "bool" + } + ], + "name": "setStatus", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "shardingTable", + "outputs": [ + { + "internalType": "contract ShardingTable", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "shardingTableStorage", + "outputs": [ + { + "internalType": "contract ShardingTableStorage", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "staking", + "outputs": [ + { + "internalType": "contract Staking", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "stakingStorage", + "outputs": [ + { + "internalType": "contract StakingStorage", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "status", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "pure", + "type": "function" + } +] diff --git a/contracts/migrations/MigratorV6Epochs9to12Rewards.sol b/contracts/migrations/MigratorV6Epochs9to12Rewards.sol new file mode 100644 index 00000000..f2247431 --- /dev/null +++ b/contracts/migrations/MigratorV6Epochs9to12Rewards.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import {StakingStorage} from "../storage/StakingStorage.sol"; +import {ShardingTableStorage} from "../storage/ShardingTableStorage.sol"; +import {ParametersStorage} from "../storage/ParametersStorage.sol"; +import {Ask} from "../Ask.sol"; +import {ShardingTable} from "../ShardingTable.sol"; +import {DelegatorsInfo} from "../storage/DelegatorsInfo.sol"; +import {ContractStatus} from "../abstract/ContractStatus.sol"; +import {INamed} from "../interfaces/INamed.sol"; +import {IVersioned} from "../interfaces/IVersioned.sol"; +import {Chronos} from "../storage/Chronos.sol"; +import {Staking} from "../Staking.sol"; +import {HubLib} from "../libraries/HubLib.sol"; +import {ICustodian} from "../interfaces/ICustodian.sol"; +import {ProfileStorage} from "../storage/ProfileStorage.sol"; +import {ProfileLib} from "../libraries/ProfileLib.sol"; + +contract MigratorV6Epochs9to12Rewards is INamed, IVersioned, ContractStatus { + string private constant _NAME = "MigratorV6Epochs9to12Rewards"; + string private constant _VERSION = "1.0.0"; + uint256 public constant START_EPOCH = 9; + uint256 public constant END_EPOCH = 12; + + mapping(uint72 => mapping(address => uint96)) public delegatorRewardAmount; + mapping(uint72 => mapping(address => bool)) public claimedDelegatorReward; + + mapping(uint72 => uint96) public operatorRewardAmount; + mapping(uint72 => bool) public claimedOperatorReward; + + StakingStorage public stakingStorage; + ShardingTableStorage public shardingTableStorage; + ShardingTable public shardingTable; + ParametersStorage public parametersStorage; + Ask public askContract; + DelegatorsInfo public delegatorsInfo; + Chronos public chronos; + Staking public staking; + ProfileStorage public profileStorage; + + // @dev Only transactions by HubController owner or one of the owners of the MultiSig Wallet + modifier onlyOwnerOrMultiSigOwner() { + _checkOwnerOrMultiSigOwner(); + _; + } + + modifier profileExists(uint72 identityId) { + _checkProfileExists(identityId); + _; + } + + // solhint-disable-next-line no-empty-blocks + constructor(address hubAddress) ContractStatus(hubAddress) {} + + function initialize() external onlyHub { + stakingStorage = StakingStorage(hub.getContractAddress("StakingStorage")); + shardingTableStorage = ShardingTableStorage(hub.getContractAddress("ShardingTableStorage")); + shardingTable = ShardingTable(hub.getContractAddress("ShardingTable")); + parametersStorage = ParametersStorage(hub.getContractAddress("ParametersStorage")); + askContract = Ask(hub.getContractAddress("Ask")); + delegatorsInfo = DelegatorsInfo(hub.getContractAddress("DelegatorsInfo")); + chronos = Chronos(hub.getContractAddress("Chronos")); + staking = Staking(hub.getContractAddress("Staking")); + profileStorage = ProfileStorage(hub.getContractAddress("ProfileStorage")); + } + + function name() external pure override returns (string memory) { + return _NAME; + } + + function version() external pure override returns (string memory) { + return _VERSION; + } + + event DelegatorRewardAmountSet(uint72 indexed identityId, address indexed delegator, uint96 amount); + event OperatorRewardAmountSet(uint72 indexed identityId, uint96 amount); + + function setDelegatorRewardAmount( + uint72 identityId, + address delegator, + uint96 amount + ) external onlyOwnerOrMultiSigOwner profileExists(identityId) { + require(amount > 0, "No reward"); + delegatorRewardAmount[identityId][delegator] = amount; + + emit DelegatorRewardAmountSet(identityId, delegator, amount); + } + + function setOperatorRewardAmount( + uint72 identityId, + uint96 amount + ) external onlyOwnerOrMultiSigOwner profileExists(identityId) { + require(amount > 0, "No reward"); + operatorRewardAmount[identityId] = amount; + + emit OperatorRewardAmountSet(identityId, amount); + } + + /** + * @notice Claims the pre-calculated reward for the caller and immediately + * restakes it for the given node. + * @param identityId The node identifier the caller is delegating to. + * @param delegator The delegator address. + */ + function migrateDelegatorReward(uint72 identityId, address delegator) external profileExists(identityId) { + require(!claimedDelegatorReward[identityId][delegator], "Already claimed delegator reward for this node"); + + uint96 amount = delegatorRewardAmount[identityId][delegator]; + require(amount > 0, "No reward"); + + // Mark reward as processed - avoid reentrancy + claimedDelegatorReward[identityId][delegator] = true; + + // Validate epoch claims for V8 rewards + staking.validateDelegatorEpochClaims(identityId, delegator); + + bytes32 delegatorKey = keccak256(abi.encodePacked(delegator)); + + // Settle pending score changes in V8 system + staking.prepareForStakeChange(chronos.getCurrentEpoch(), identityId, delegatorKey); + + uint96 currentDelegatorStakeBase = stakingStorage.getDelegatorStakeBase(identityId, delegatorKey); + uint96 newDelegatorStakeBase = currentDelegatorStakeBase + amount; + + uint96 totalNodeStakeBefore = stakingStorage.getNodeStake(identityId); + uint96 totalNodeStakeAfter = totalNodeStakeBefore + amount; + + // Update staking balances + stakingStorage.setDelegatorStakeBase(identityId, delegatorKey, newDelegatorStakeBase); + stakingStorage.setNodeStake(identityId, totalNodeStakeAfter); + stakingStorage.increaseTotalStake(amount); + + _addNodeToShardingTable(identityId, totalNodeStakeAfter); + askContract.recalculateActiveSet(); + + _manageDelegatorStatus(identityId, delegator); + } + + /** + * @notice Transfers the operator reward to the operator balance in staking storage. + * @param identityId The node identifier the caller is delegating to. + */ + function migrateOperatorReward(uint72 identityId) external profileExists(identityId) { + require(!claimedOperatorReward[identityId], "Already claimed operator reward for this node"); + + uint96 amount = operatorRewardAmount[identityId]; + require(amount > 0, "No reward"); + + claimedOperatorReward[identityId] = true; + + stakingStorage.increaseOperatorFeeBalance(identityId, amount); + } + + function _manageDelegatorStatus(uint72 identityId, address delegator) internal { + if (!delegatorsInfo.isNodeDelegator(identityId, delegator)) { + delegatorsInfo.addDelegator(identityId, delegator); + } + uint256 lastStakeHeldEpoch = delegatorsInfo.getLastStakeHeldEpoch(identityId, delegator); + if (lastStakeHeldEpoch > 0) { + delegatorsInfo.setLastStakeHeldEpoch(identityId, delegator, 0); + } + } + + function _addNodeToShardingTable(uint72 identityId, uint96 totalNodeStakeAfter) internal { + if (!shardingTableStorage.nodeExists(identityId)) { + if (totalNodeStakeAfter >= parametersStorage.minimumStake()) { + shardingTable.insertNode(identityId); + } + } + } + + function _isMultiSigOwner(address multiSigAddress) internal view returns (bool) { + try ICustodian(multiSigAddress).getOwners() returns (address[] memory multiSigOwners) { + for (uint256 i = 0; i < multiSigOwners.length; i++) { + if (msg.sender == multiSigOwners[i]) { + return true; + } + } // solhint-disable-next-line no-empty-blocks + } catch {} + + return false; + } + + function _checkOwnerOrMultiSigOwner() internal view virtual { + address hubOwner = hub.owner(); + if (msg.sender != hubOwner && !_isMultiSigOwner(hubOwner)) { + revert HubLib.UnauthorizedAccess("Only Hub Owner or Multisig Owner"); + } + } + + /** + * @dev Internal function to validate that a node profile exists + * Used by modifiers and functions to ensure operations target valid nodes + * @param identityId Node identity to check existence for + */ + function _checkProfileExists(uint72 identityId) internal view virtual { + if (!profileStorage.profileExists(identityId)) { + revert ProfileLib.ProfileDoesntExist(identityId); + } + } +} diff --git a/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts b/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts new file mode 100644 index 00000000..ea922725 --- /dev/null +++ b/deploy/034_deploy_migrator_v6_epochs_9to12_rewards.ts @@ -0,0 +1,26 @@ +import { HardhatRuntimeEnvironment } from 'hardhat/types'; +import { DeployFunction } from 'hardhat-deploy/types'; + +const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { + await hre.helpers.deploy({ + newContractName: 'MigratorV6Epochs9to12Rewards', + }); +}; + +export default func; +func.tags = ['MigratorV6Epochs9to12Rewards']; +func.dependencies = [ + 'Hub', + 'StakingStorage', + 'ShardingTableStorage', + 'ShardingTable', + 'ParametersStorage', + 'Ask', + 'DelegatorsInfo', + 'Chronos', + 'Staking', + 'ProfileStorage', + 'IdentityStorage', + 'Token', + 'Profile', +]; diff --git a/deployments/base_sepolia_test_contracts.json b/deployments/base_sepolia_test_contracts.json index 6f11c3c0..c0b9c9ea 100644 --- a/deployments/base_sepolia_test_contracts.json +++ b/deployments/base_sepolia_test_contracts.json @@ -315,6 +315,15 @@ "deploymentBlock": 37268394, "deploymentTimestamp": 1770305077619, "deployed": true + }, + "MigratorV6Epochs9to12Rewards": { + "evmAddress": "0xb59d9CF1c8a028F2D84F4C40AfC25895de47AAC9", + "version": "1.0.0", + "gitBranch": "migrator-v6-epoch-9-to-12", + "gitCommitHash": "82a95af5a4e7f53b9e3fab5f227fccde8c5a6ec3", + "deploymentBlock": 37566046, + "deploymentTimestamp": 1770900381140, + "deployed": true } } } diff --git a/deployments/gnosis_chiado_test_contracts.json b/deployments/gnosis_chiado_test_contracts.json index 1a41aa28..5346e753 100644 --- a/deployments/gnosis_chiado_test_contracts.json +++ b/deployments/gnosis_chiado_test_contracts.json @@ -324,6 +324,15 @@ "deploymentBlock": 19690369, "deploymentTimestamp": 1770306024120, "deployed": true + }, + "MigratorV6Epochs9to12Rewards": { + "evmAddress": "0x05F3502486090F0e5A9F0831298ea775ad6d24B6", + "version": "1.0.0", + "gitBranch": "migrator-v6-epoch-9-to-12", + "gitCommitHash": "82a95af5a4e7f53b9e3fab5f227fccde8c5a6ec3", + "deploymentBlock": 20015829, + "deploymentTimestamp": 1770900422736, + "deployed": true } } } diff --git a/deployments/neuroweb_testnet_contracts.json b/deployments/neuroweb_testnet_contracts.json index 9483e063..fc7387ef 100644 --- a/deployments/neuroweb_testnet_contracts.json +++ b/deployments/neuroweb_testnet_contracts.json @@ -350,6 +350,16 @@ "deploymentBlock": 10492663, "deploymentTimestamp": 1770307855920, "deployed": true + }, + "MigratorV6Epochs9to12Rewards": { + "evmAddress": "0x7388F16BeBC5fc790bC7294E6ee9D6Da5d45Ab79", + "substrateAddress": "5EMjsczjKqNXr9ohkphd1ZwwUdmbjQg9oXewaATnukBKzdE5", + "version": "1.0.0", + "gitBranch": "migrator-v6-epoch-9-to-12", + "gitCommitHash": "82a95af5a4e7f53b9e3fab5f227fccde8c5a6ec3", + "deploymentBlock": 10567578, + "deploymentTimestamp": 1770900734826, + "deployed": true } } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 61eb9e7f..584f4437 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -38,7 +38,7 @@ config.networks = { chainId: 20430, url: rpc('neuroweb_testnet'), gas: 10_000_000, // Gas limit used for deploys - gasPrice: 20, + gasPrice: 1_000_000, accounts: accounts('neuroweb_testnet'), saveDeployments: false, }, diff --git a/test/integration/MigratorV6Epochs9to12Rewards.test.ts b/test/integration/MigratorV6Epochs9to12Rewards.test.ts new file mode 100644 index 00000000..b74d9398 --- /dev/null +++ b/test/integration/MigratorV6Epochs9to12Rewards.test.ts @@ -0,0 +1,195 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import hre, { ethers } from 'hardhat'; + +import { + DelegatorsInfo, + Hub, + MigratorV6TuningPeriodRewards, + Profile, + StakingStorage, +} from '../../typechain'; +import { createProfile } from '../helpers/profile-helpers'; + +const toTRAC = (x: string | number) => ethers.parseUnits(x.toString(), 18); + +type MigratorV6Epochs9to12RewardsLike = MigratorV6TuningPeriodRewards & { + START_EPOCH(): Promise; + END_EPOCH(): Promise; +}; + +type TestContracts = { + hub: Hub; + profile: Profile; + stakingStorage: StakingStorage; + delegatorsInfo: DelegatorsInfo; + migrator: MigratorV6Epochs9to12RewardsLike; +}; + +type TestAccounts = { + owner: SignerWithAddress; + node1: { operational: SignerWithAddress; admin: SignerWithAddress }; + delegator1: SignerWithAddress; + delegator2: SignerWithAddress; +}; + +async function setupTestEnvironment(): Promise<{ + accounts: TestAccounts; + contracts: TestContracts; + node1Id: bigint; +}> { + await hre.deployments.fixture([ + 'MigratorV6Epochs9to12Rewards', + 'Staking', + 'Profile', + 'Token', + 'RandomSampling', + 'EpochStorage', + ]); + + const signers = await hre.ethers.getSigners(); + const accounts: TestAccounts = { + owner: signers[0], + node1: { operational: signers[1], admin: signers[2] }, + delegator1: signers[3], + delegator2: signers[4], + }; + + const contracts: TestContracts = { + hub: await hre.ethers.getContract('Hub'), + profile: await hre.ethers.getContract('Profile'), + stakingStorage: + await hre.ethers.getContract('StakingStorage'), + delegatorsInfo: + await hre.ethers.getContract('DelegatorsInfo'), + migrator: (await hre.ethers.getContract( + 'MigratorV6Epochs9to12Rewards', + )) as unknown as MigratorV6Epochs9to12RewardsLike, + }; + + await contracts.hub.setContractAddress('HubOwner', accounts.owner.address); + + const node1Profile = await createProfile(contracts.profile, accounts.node1); + + return { + accounts, + contracts, + node1Id: BigInt(node1Profile.identityId), + }; +} + +describe('MigratorV6Epochs9to12Rewards Integration Tests', function () { + let accounts: TestAccounts; + let contracts: TestContracts; + let node1Id: bigint; + + beforeEach(async function () { + const setup = await setupTestEnvironment(); + accounts = setup.accounts; + contracts = setup.contracts; + node1Id = setup.node1Id; + }); + + it('exposes expected contract metadata and epoch range', async function () { + expect(await contracts.migrator.name()).to.equal( + 'MigratorV6Epochs9to12Rewards', + ); + expect(await contracts.migrator.version()).to.equal('1.0.0'); + expect(await contracts.migrator.START_EPOCH()).to.equal(9n); + expect(await contracts.migrator.END_EPOCH()).to.equal(12n); + }); + + it('migrates delegator reward into stake balances', async function () { + const rewardAmount = toTRAC(1_000); + const delegator = accounts.delegator1.address; + const delegatorKey = ethers.keccak256( + ethers.solidityPacked(['address'], [delegator]), + ); + + const initialDelegatorStake = + await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + delegatorKey, + ); + const initialNodeStake = + await contracts.stakingStorage.getNodeStake(node1Id); + const initialTotalStake = await contracts.stakingStorage.getTotalStake(); + + await contracts.migrator.setDelegatorRewardAmount( + node1Id, + delegator, + rewardAmount, + ); + await contracts.migrator.migrateDelegatorReward(node1Id, delegator); + + const finalDelegatorStake = + await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + delegatorKey, + ); + const finalNodeStake = await contracts.stakingStorage.getNodeStake(node1Id); + const finalTotalStake = await contracts.stakingStorage.getTotalStake(); + + expect(finalDelegatorStake).to.equal(initialDelegatorStake + rewardAmount); + expect(finalNodeStake).to.equal(initialNodeStake + rewardAmount); + expect(finalTotalStake).to.equal(initialTotalStake + rewardAmount); + expect( + await contracts.migrator.claimedDelegatorReward(node1Id, delegator), + ).to.equal(true); + expect( + await contracts.delegatorsInfo.isNodeDelegator(node1Id, delegator), + ).to.equal(true); + }); + + it('rejects double delegator migration', async function () { + const rewardAmount = toTRAC(1_000); + const delegator = accounts.delegator1.address; + + await contracts.migrator.setDelegatorRewardAmount( + node1Id, + delegator, + rewardAmount, + ); + await contracts.migrator.migrateDelegatorReward(node1Id, delegator); + + await expect( + contracts.migrator.migrateDelegatorReward(node1Id, delegator), + ).to.be.revertedWith('Already claimed delegator reward for this node'); + }); + + it('migrates operator reward into operator fee balance', async function () { + const rewardAmount = toTRAC(2_000); + const initialOperatorBalance = + await contracts.stakingStorage.getOperatorFeeBalance(node1Id); + + await contracts.migrator.setOperatorRewardAmount(node1Id, rewardAmount); + await contracts.migrator.migrateOperatorReward(node1Id); + + const finalOperatorBalance = + await contracts.stakingStorage.getOperatorFeeBalance(node1Id); + expect(finalOperatorBalance).to.equal( + initialOperatorBalance + rewardAmount, + ); + expect(await contracts.migrator.claimedOperatorReward(node1Id)).to.equal( + true, + ); + }); + + it('allows only owner/multisig owner to set reward amounts', async function () { + await expect( + contracts.migrator + .connect(accounts.delegator2) + .setDelegatorRewardAmount( + node1Id, + accounts.delegator1.address, + toTRAC(1_000), + ), + ).to.be.reverted; + + await expect( + contracts.migrator + .connect(accounts.delegator2) + .setOperatorRewardAmount(node1Id, toTRAC(1_000)), + ).to.be.reverted; + }); +});