diff --git a/contracts/KnowledgeCollection.sol b/contracts/KnowledgeCollection.sol index a6546b51..da2e04b9 100644 --- a/contracts/KnowledgeCollection.sol +++ b/contracts/KnowledgeCollection.sol @@ -107,16 +107,21 @@ contract KnowledgeCollection is INamed, IVersioned, ContractStatus, IInitializab merkleRoot, knowledgeAssetsAmount, byteSize, - currentEpoch + 1, - currentEpoch + epochs + 1, + currentEpoch, + currentEpoch + epochs, tokenAmount, isImmutable ); + // Validate that the provided token amount is sufficient + // include current epoch is false and user pays for number of epochs _validateTokenAmount(byteSize, epochs, tokenAmount, true); - es.addTokensToEpochRange(1, currentEpoch, currentEpoch + epochs + 1, tokenAmount); - es.addEpochProducedKnowledgeValue(publisherNodeIdentityId, currentEpoch, tokenAmount); + // Distribute time-weight tokenAmount across current, full, and final fractional epochs + _distributeTokens(tokenAmount, epochs, currentEpoch); + + // Record the knowledge value produced by the publisher in the current epoch + epochStorage.addEpochProducedKnowledgeValue(publisherNodeIdentityId, currentEpoch, tokenAmount); _addTokens(tokenAmount, paymaster); @@ -323,4 +328,55 @@ contract KnowledgeCollection is INamed, IVersioned, ContractStatus, IInitializab } } } + + /** + * @dev Distributes tokens across epochs using time-weighted allocation + * Allocates tokens proportionally based on time remaining in current epoch and distributes + * the remainder across full epochs and final fractional epoch + * @param tokenAmount Total amount of tokens to distribute across epochs + * @param epochs Number of epochs to distribute tokens over + * @param currentEpoch The current epoch number where distribution starts + */ + function _distributeTokens(uint96 tokenAmount, uint256 epochs, uint40 currentEpoch) internal { + require(epochs > 0, "epochs must be > 0"); + + uint256 epochLengthInSeconds = chronos.epochLength(); + uint256 timeRemainingInCurrentEpoch = chronos.timeUntilNextEpoch(); // seconds remaining in current epoch + uint256 baseTokensPerFullEpoch = tokenAmount / epochs; // nominal amount for a full epoch + uint256 currentEpochAllocation = (baseTokensPerFullEpoch * timeRemainingInCurrentEpoch) / epochLengthInSeconds; + uint256 finalEpochAllocation = baseTokensPerFullEpoch - currentEpochAllocation; // goes to the final fractional epoch + uint256 numberOfFullEpochs = epochs - 1; // number of full middle epochs + uint256 totalTokensForFullEpochs = baseTokensPerFullEpoch * numberOfFullEpochs; + + // Add any rounding remainder to the final epoch so total == tokenAmount + uint256 totalAllocated = currentEpochAllocation + totalTokensForFullEpochs + finalEpochAllocation; + if (totalAllocated < tokenAmount) { + finalEpochAllocation += tokenAmount - totalAllocated; + } + + // 1) Current (fractional) epoch + if (currentEpochAllocation > 0) { + epochStorage.addTokensToEpochRange(1, currentEpoch, currentEpoch, uint96(currentEpochAllocation)); + } + + // 2) Full epochs between current and final + if (numberOfFullEpochs > 0 && totalTokensForFullEpochs > 0) { + epochStorage.addTokensToEpochRange( + 1, + currentEpoch + 1, + currentEpoch + uint40(numberOfFullEpochs), + uint96(totalTokensForFullEpochs) + ); + } + + // 3) Final (fractional) epoch + if (finalEpochAllocation > 0) { + epochStorage.addTokensToEpochRange( + 1, + currentEpoch + uint40(epochs), + currentEpoch + uint40(epochs), + uint96(finalEpochAllocation) + ); + } + } } diff --git a/contracts/Staking.sol b/contracts/Staking.sol index b98f732a..9f6aaedc 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -607,10 +607,10 @@ contract Staking is INamed, IVersioned, ContractStatus, IInitializable { } } - if (reward == 0) return; - uint256 rolling = delegatorsInfo.getDelegatorRollingRewards(identityId, delegator); + if (reward == 0 && rolling == 0) return; + // if there are still older epochs pending, accumulate; otherwise restake immediately if ((currentEpoch - 1) - lastClaimedEpoch > 1) { delegatorsInfo.setDelegatorRollingRewards(identityId, delegator, rolling + reward); diff --git a/test/integration/Staking-trash.test.ts b/test/integration/Staking-trash.test.ts new file mode 100644 index 00000000..7de2262b --- /dev/null +++ b/test/integration/Staking-trash.test.ts @@ -0,0 +1,518 @@ +import { randomBytes } from 'crypto'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import hre from 'hardhat'; + +import { + Token, + Profile, + Staking, + StakingStorage, + ParametersStorage, + RandomSamplingStorage, + EpochStorage, + DelegatorsInfo, + ShardingTableStorage, + Chronos, +} from '../../typechain'; + +// ==================================================== +// EMBEDDED FIXTURE – *adjust once, reuse everywhere* +// ==================================================== +/** + * Deploys all contracts via `hre.deployments.fixture()` and returns the common + * handles consumed by the test‑suite. If your repo names differ, tweak here + * once and all tests continue to work. + */ +async function deployStakingFixture() { + // Ensures deploy scripts under /deploy or /deployments are executed once and + // cached by Hardhat. + await hre.deployments.fixture(); + + // Grab generic signers (Hardhat auto‑creates as many as you need). + const accounts: SignerWithAddress[] = await hre.ethers.getSigners(); + + // Core contracts – **rename here** if your artefact names differ. + const Token = await hre.ethers.getContract('Token'); + const Profile = await hre.ethers.getContract('Profile'); + const Staking = await hre.ethers.getContract('Staking'); + const StakingStorage = + await hre.ethers.getContract('StakingStorage'); + const ParametersStorage = + await hre.ethers.getContract('ParametersStorage'); + const RandomSamplingStorage = + await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + const EpochStorage = + await hre.ethers.getContract('EpochStorage'); + const DelegatorsInfo = + await hre.ethers.getContract('DelegatorsInfo'); + const ShardingTableStorage = + await hre.ethers.getContract('ShardingTableStorage'); + const Chronos = await hre.ethers.getContract('Chronos'); + + return { + accounts, + Token, + Profile, + Staking, + StakingStorage, + ParametersStorage, + RandomSamplingStorage, + EpochStorage, + DelegatorsInfo, + ShardingTableStorage, + Chronos, + } as const; +} + +// ---------------------------------------------------- +// Helper types & constants +// ---------------------------------------------------- + +type Fixture = Awaited>; +const SCALE18 = hre.ethers.parseUnits('1', 18); +const fmt = (x: bigint) => hre.ethers.formatUnits(x, 18); + +/** Helper: create a profile with optional operator‑fee */ +const createProfile = async ( + env: { accounts: SignerWithAddress[]; Profile: Profile }, + opts: { operatorFeeBp?: bigint } = {}, +) => { + const { accounts, Profile } = env; + const admin = accounts[0]; + const operational = accounts[1]; + const fee = opts.operatorFeeBp ?? 0n; + const node = '0x' + randomBytes(32).toString('hex'); + const tx = await Profile.connect(operational).createProfile( + admin.address, + [], + `Node-${Math.floor(Math.random() * 10 ** 4)}`, + node, + fee, + ); + const receipt = await tx.wait(); + if (!receipt) throw new Error('Transaction receipt is null'); + return Number(receipt.logs[0].topics[1]); +}; + +// ==================================================== +// MASTER SUITE +// ==================================================== + +describe('🔬 Staking – full behaviour matrix', () => { + // Contracts + let Token: Token; + let Profile: Profile; + let Staking: Staking; + let StakingStorage: StakingStorage; + let RandomSamplingStorage: RandomSamplingStorage; + let EpochStorage: EpochStorage; + let DelegatorsInfo: DelegatorsInfo; + let ParametersStorage: ParametersStorage; + let ShardingTableStorage: ShardingTableStorage; + let Chronos: Chronos; + // Accounts + let A: SignerWithAddress[]; + + // ------------------------------------------------------------------ + // Fixture loader + // ------------------------------------------------------------------ + beforeEach(async () => { + const f: Fixture = await loadFixture(deployStakingFixture); + ({ + accounts: A, + Token, + Profile, + Staking, + StakingStorage, + RandomSamplingStorage, + EpochStorage, + DelegatorsInfo, + ParametersStorage, + ShardingTableStorage, + Chronos, + } = f); + }); + + // -------------------------------------------------- + // SECTION 1 – Reward ordering & rolling rewards + // -------------------------------------------------- + describe('Epoch ordering & rolling‑rewards', () => { + it('reverts if skipping epochs; accumulates / flushes rolling rewards', async () => { + const id = await createProfile({ accounts: A, Profile }); + const deleg = A[2]; + + // Stake once + const stake = hre.ethers.parseEther('100'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(id, stake); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + + // Helper to inject reward for an epoch + const inject = async (ep: bigint, pool: bigint) => { + await RandomSamplingStorage.addToNodeEpochScore(ep, id, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(ep, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + ep, + id, + dKey, + SCALE18, + ); + await EpochStorage.addTokensToEpochRange(1, ep, ep, pool); + }; + + // Produce rewards for 3 consecutive epochs + const e1 = await Chronos.getCurrentEpoch(); + await inject(e1, hre.ethers.parseEther('10')); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + const e2 = await Chronos.getCurrentEpoch(); + await inject(e2, hre.ethers.parseEther('20')); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + const e3 = await Chronos.getCurrentEpoch(); + await inject(e3, hre.ethers.parseEther('30')); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + // 📢 Attempt to claim e2 before e1 → revert + await expect( + Staking.connect(deleg).claimDelegatorRewards(id, e2, deleg.address), + ).to.be.revertedWith('Must claim older epochs first'); + + // Claim e1 – should push to rolling (because >1 epoch gap) + await Staking.connect(deleg).claimDelegatorRewards(id, e1, deleg.address); + const rollAfterE1 = await DelegatorsInfo.getDelegatorRollingRewards( + id, + deleg.address, + ); + expect(rollAfterE1).to.be.gt(0); + + // Claim e2 – still gap >1 → still rolling + await Staking.connect(deleg).claimDelegatorRewards(id, e2, deleg.address); + const rollAfterE2 = await DelegatorsInfo.getDelegatorRollingRewards( + id, + deleg.address, + ); + expect(rollAfterE2).to.be.gt(rollAfterE1); + + // Claim e3 – now gap ≤1 → rolling flushed into stake + const baseBefore = await StakingStorage.getDelegatorStakeBase(id, dKey); + await Staking.connect(deleg).claimDelegatorRewards(id, e3, deleg.address); + const baseAfter = await StakingStorage.getDelegatorStakeBase(id, dKey); + expect( + await DelegatorsInfo.getDelegatorRollingRewards(id, deleg.address), + ).to.equal(0); + expect(baseAfter).to.be.gt(baseBefore); + }); + }); + + // -------------------------------------------------- + // SECTION 2 – Claim guards & batch‑claim + // -------------------------------------------------- + describe('Claim guards & batch‑claim', () => { + it('blocks double‑claiming same epoch', async () => { + const id = await createProfile({ accounts: A, Profile }); + const deleg = A[3]; + const stake = hre.ethers.parseEther('50'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(id, stake); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + + const epoch = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore(epoch, id, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch, + id, + dKey, + SCALE18, + ); + await EpochStorage.addTokensToEpochRange( + 1, + epoch, + epoch, + hre.ethers.parseEther('5'), + ); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + await Staking.connect(deleg).claimDelegatorRewards( + id, + epoch, + deleg.address, + ); + await expect( + Staking.connect(deleg).claimDelegatorRewards(id, epoch, deleg.address), + ).to.be.revertedWith('Already claimed all finalised epochs'); + }); + + it('batch‑claims 10 delegators × 1 epoch', async () => { + const id = await createProfile({ accounts: A, Profile }); + const epoch = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore(epoch, id, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch, SCALE18); + const delegs: SignerWithAddress[] = []; + const addresses: string[] = []; + for (let i = 0; i < 10; i++) { + const d = A[4 + i]; + delegs.push(d); + addresses.push(d.address); + const stake = hre.ethers.parseEther('10'); + await Token.mint(d.address, stake); + await Token.connect(d).approve(await Staking.getAddress(), stake); + await Staking.connect(d).stake(id, stake); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [d.address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch, + id, + dKey, + SCALE18 / 10n, + ); + } + await EpochStorage.addTokensToEpochRange( + 1, + epoch, + epoch, + hre.ethers.parseEther('25'), + ); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + await Staking.batchClaimDelegatorRewards(id, [epoch], addresses); + for (const d of delegs) { + expect( + await DelegatorsInfo.getLastClaimedEpoch(id, d.address), + ).to.equal(epoch); + } + }); + }); + + // -------------------------------------------------- + // SECTION 3 – Operator fee commission + withdrawal life‑cycle + // -------------------------------------------------- + describe('Operator‑fee payout lifecycle', () => { + it('handles request → cancel → finalize correctly', async () => { + const nodeId = await createProfile( + { accounts: A, Profile }, + { operatorFeeBp: 100n /* 1 % */ }, + ); + const deleg = A[5]; + const stake = hre.ethers.parseEther('1000'); + + // Delegator stakes so the node has something to charge fees against + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(nodeId, stake); + + // Simulate one epoch worth of operator‑fee + const epoch = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore(epoch, nodeId, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch, SCALE18); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch, + nodeId, + dKey, + SCALE18, + ); + await EpochStorage.addTokensToEpochRange( + 1, + epoch, + epoch, + hre.ethers.parseEther('100'), + ); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + // Claim delegator rewards to build operator fee balance inside StakingStorage.operatorFeeBalance + await Staking.connect(deleg).claimDelegatorRewards( + nodeId, + epoch, + deleg.address, + ); + const feeBal = await StakingStorage.getOperatorFeeBalance(nodeId); + expect(feeBal).to.be.gt(0); + + // 1️⃣ request withdrawal + await Staking.connect(A[1]).requestOperatorFeeWithdrawal( + nodeId, + feeBal / 2n, + ); + let [amount, , ts] = + await StakingStorage.getOperatorFeeWithdrawalRequest(nodeId); + expect(amount).to.equal(feeBal / 2n); + + // 2️⃣ cancel + await Staking.connect(A[1]).cancelOperatorFeeWithdrawal(nodeId); + [amount] = await StakingStorage.getOperatorFeeWithdrawalRequest(nodeId); + expect(amount).to.equal(0); + + // 3️⃣ re‑request & fast‑forward, then finalize + await Staking.connect(A[1]).requestOperatorFeeWithdrawal( + nodeId, + feeBal / 2n, + ); + [amount, , ts] = + await StakingStorage.getOperatorFeeWithdrawalRequest(nodeId); + await time.increase(ts - BigInt(await time.latest()) + 2n); + const balBefore = await Token.balanceOf(A[1].address); + await Staking.connect(A[1]).finalizeOperatorFeeWithdrawal(nodeId); + const balAfter = await Token.balanceOf(A[1].address); + expect(balAfter - balBefore).to.equal(feeBal / 2n); + [amount] = await StakingStorage.getOperatorFeeWithdrawalRequest(nodeId); + expect(amount).to.equal(0); + }); + }); + + // -------------------------------------------------- + // SECTION 4 – Delegator withdrawal life‑cycle + // -------------------------------------------------- + describe('Delegator withdrawal lifecycle', () => { + it('request → cancel → finalize works and respects delays', async () => { + const nodeId = await createProfile({ accounts: A, Profile }); + const deleg = A[6]; + const stake = hre.ethers.parseEther('150'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(nodeId, stake); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + + // Request withdrawal + const reqAmt = hre.ethers.parseEther('50'); + await Staking.connect(deleg).requestWithdrawal(nodeId, reqAmt); + let [amount, , ts] = await StakingStorage.getDelegatorWithdrawalRequest( + nodeId, + dKey, + ); + expect(amount).to.equal(reqAmt); + + // Cancel + await Staking.connect(deleg).cancelWithdrawal(nodeId); + [amount] = await StakingStorage.getDelegatorWithdrawalRequest( + nodeId, + dKey, + ); + expect(amount).to.equal(0); + + // Re‑request & finalize + await Staking.connect(deleg).requestWithdrawal(nodeId, reqAmt); + [amount, , ts] = await StakingStorage.getDelegatorWithdrawalRequest( + nodeId, + dKey, + ); + await time.increase(ts - BigInt(await time.latest()) + 2n); + const balBefore = await Token.balanceOf(deleg.address); + await Staking.connect(deleg).finalizeWithdrawal(nodeId); + const balAfter = await Token.balanceOf(deleg.address); + expect(balAfter - balBefore).to.equal(reqAmt); + [amount] = await StakingStorage.getDelegatorWithdrawalRequest( + nodeId, + dKey, + ); + expect(amount).to.equal(0); + }); + }); + + // -------------------------------------------------- + // SECTION 5 – Redelegation guard (must claim first) + // -------------------------------------------------- + it('blocks redelegation while pending rewards exist', async () => { + const nodeA = await createProfile({ accounts: A, Profile }); + const nodeB = await createProfile({ accounts: A, Profile }); + const deleg = A[12]; + const stake = hre.ethers.parseEther('80'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(nodeA, stake); + + // earn reward in epoch‑E + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + const epoch = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore(epoch, nodeA, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch, + nodeA, + dKey, + SCALE18, + ); + await EpochStorage.addTokensToEpochRange( + 1, + epoch, + epoch, + hre.ethers.parseEther('7'), + ); + + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + await expect( + Staking.connect(deleg).redelegate(nodeA, nodeB, stake / 2n), + ).to.be.revertedWith('Must claim rewards for all finalised epochs first'); + }); + + // -------------------------------------------------- + // SECTION 6 – Sharding table insert on restake + // -------------------------------------------------- + it('adds node to sharding table when restake crosses minimum', async () => { + const nodeId = await createProfile({ accounts: A, Profile }); + const minStake = await ParametersStorage.minimumStake(); + const justBelow = minStake - hre.ethers.parseEther('1'); + await Token.mint(A[0].address, justBelow); + await Token.connect(A[0]).approve(await Staking.getAddress(), justBelow); + await Staking.stake(nodeId, justBelow); + + await StakingStorage.setOperatorFeeBalance( + nodeId, + hre.ethers.parseEther('2'), + ); + await Staking.restakeOperatorFee(nodeId, hre.ethers.parseEther('2')); + + expect(await ShardingTableStorage.nodeExists(nodeId)).to.be.true; + }); + + // -------------------------------------------------- + // SECTION 7 – Zero score / zero stake edge‑cases + // -------------------------------------------------- + it('updates lastClaimedEpoch even when reward == 0', async () => { + const nodeId = await createProfile({ accounts: A, Profile }); + const deleg = A[13]; + const stake = hre.ethers.parseEther('20'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(nodeId, stake); + + const epoch = await Chronos.getCurrentEpoch(); // produce NO score + await EpochStorage.addTokensToEpochRange( + 1, + epoch, + epoch, + hre.ethers.parseEther('3'), + ); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + await Staking.connect(deleg).claimDelegatorRewards( + nodeId, + epoch, + deleg.address, + ); + expect( + await DelegatorsInfo.getLastClaimedEpoch(nodeId, deleg.address), + ).to.equal(epoch); + }); + + // -------------------------------------------------- + // SECTION 8 – Batch claim gas sanity (already included in Section 2) + // -------------------------------------------------- +}); diff --git a/test/integration/Staking.fullscenario.test.ts b/test/integration/Staking.fullscenario.test.ts new file mode 100644 index 00000000..589acfc6 --- /dev/null +++ b/test/integration/Staking.fullscenario.test.ts @@ -0,0 +1,2791 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; +// @ts-expect-error: No type definitions available for assertion-tools +import { kcTools } from 'assertion-tools'; +import { expect } from 'chai'; +import hre, { ethers } from 'hardhat'; + +import { + Hub, + Token, + Chronos, + StakingStorage, + RandomSamplingStorage, + ParametersStorage, + ProfileStorage, + EpochStorage, + DelegatorsInfo, + Ask, + Staking, + StakingKPI, + RandomSampling, + Profile, + KnowledgeCollection, + AskStorage, +} from '../../typechain'; +import { createKnowledgeCollection } from '../helpers/kc-helpers'; +import { createProfile } from '../helpers/profile-helpers'; + +// Sample data for KC +const quads = [ + ' "468.9 sq mi" .', + ' "New York" .', + ' "8,336,817" .', + ' "New York" .', + ' .', + ' "0xaac2a420672a1eb77506c544ff01beed2be58c0ee3576fe037c846f97481cefd" .', + ' .', + ' .', + ' .', + // Add more quads to ensure we have enough chunks + ...Array(1000).fill( + ' .', + ), +]; +const merkleRoot = kcTools.calculateMerkleRoot(quads, 32); + +const toTRAC = (x: string | number) => ethers.parseUnits(x.toString(), 18); + +// ================================================================================================================ +// HELPER FUNCTIONS: Extract common functionality for better readability and reusability +// ================================================================================================================ + +type TestContracts = { + hub: Hub; + token: Token; + chronos: Chronos; + stakingStorage: StakingStorage; + randomSamplingStorage: RandomSamplingStorage; + parametersStorage: ParametersStorage; + profileStorage: ProfileStorage; + epochStorage: EpochStorage; + delegatorsInfo: DelegatorsInfo; + staking: Staking; + stakingKPI: StakingKPI; + profile: Profile; + randomSampling: RandomSampling; + kc: KnowledgeCollection; + askStorage: AskStorage; + ask: Ask; +}; + +type TestAccounts = { + owner: SignerWithAddress; + node1: { operational: SignerWithAddress; admin: SignerWithAddress }; + node2: { operational: SignerWithAddress; admin: SignerWithAddress }; + delegator1: SignerWithAddress; + delegator2: SignerWithAddress; + delegator3: SignerWithAddress; + kcCreator: SignerWithAddress; + receiver1: { operational: SignerWithAddress; admin: SignerWithAddress }; + receiver2: { operational: SignerWithAddress; admin: SignerWithAddress }; + receiver3: { operational: SignerWithAddress; admin: SignerWithAddress }; +}; + +/** + * Calculate expected node score manually to verify contract calculation + * This implements the same logic as RandomSampling.calculateNodeScore() + */ +async function calculateExpectedNodeScore( + nodeId: bigint, + contracts: TestContracts, +): Promise { + const SCALE18 = ethers.parseUnits('1', 18); + + // 1. Node stake factor calculation + const maximumStake = await contracts.parametersStorage.maximumStake(); + let nodeStake = await contracts.stakingStorage.getNodeStake(nodeId); + nodeStake = nodeStake > maximumStake ? maximumStake : nodeStake; + + const stakeRatio18 = (nodeStake * SCALE18) / maximumStake; + const nodeStakeFactor18 = (2n * stakeRatio18 * stakeRatio18) / SCALE18; + + // 2. Node ask factor calculation + const nodeAsk18 = (await contracts.profileStorage.getAsk(nodeId)) * SCALE18; + const [askLowerBound18, askUpperBound18] = + await contracts.askStorage.getAskBounds(); + + let nodeAskFactor18 = 0n; + if ( + askUpperBound18 > askLowerBound18 && + nodeAsk18 >= askLowerBound18 && + nodeAsk18 <= askUpperBound18 + ) { + const askDiffRatio18 = + ((askUpperBound18 - nodeAsk18) * SCALE18) / + (askUpperBound18 - askLowerBound18); + nodeAskFactor18 = (stakeRatio18 * askDiffRatio18 ** 2n) / SCALE18 ** 2n; + } + + // 3. Node publishing factor calculation + const nodePub = + await contracts.epochStorage.getNodeCurrentEpochProducedKnowledgeValue( + nodeId, + ); + const maxNodePub = + await contracts.epochStorage.getCurrentEpochNodeMaxProducedKnowledgeValue(); + + let nodePublishingFactor18 = 0n; + if (maxNodePub > 0n) { + const pubRatio18 = (nodePub * SCALE18) / maxNodePub; + nodePublishingFactor18 = (nodeStakeFactor18 * pubRatio18) / SCALE18; + } + + return nodeStakeFactor18 + nodeAskFactor18 + nodePublishingFactor18; +} + +/** + * Calculate expected delegator score earned during a period + */ +// TODO: Does this make sense? +function calculateExpectedDelegatorScore( + delegatorStake: bigint, + nodeScorePerStake: bigint, + delegatorLastSettledNodeScorePerStake: bigint, +): bigint { + const diff = nodeScorePerStake - delegatorLastSettledNodeScorePerStake; + const SCALE18 = ethers.parseUnits('1', 18); + return (delegatorStake * diff) / SCALE18; +} + +async function epochRewardsPoolPrecisionLoss( + contracts: TestContracts, + claimEpoch: bigint, + netNodeRewards: bigint, + expectedRewardsPool: bigint, +): Promise { + const epochRewardsPool = await contracts.epochStorage.getEpochPool( + 1, + claimEpoch, + ); + console.log( + ` ✅ Epoch rewards pool: ${ethers.formatUnits(epochRewardsPool, 18)} TRAC`, + ); + expect(epochRewardsPool).to.equal(netNodeRewards); + console.log( + ` ✅ Expected rewards pool: ${ethers.formatUnits(expectedRewardsPool, 18)} TRAC`, + ); + console.log( + ` ⚠️ [Epoch ${claimEpoch}] Precision loss: ${ethers.formatUnits( + epochRewardsPool - expectedRewardsPool, + 18, + )} TRAC`, + ); + expect(epochRewardsPool).to.be.closeTo( + expectedRewardsPool, + ethers.parseUnits('0.0000002', 18), + ); +} + +/** + * Submit a proof for a node and verify the score calculation + */ +async function submitProofAndVerifyScore( + nodeId: bigint, + node: { operational: SignerWithAddress; admin: SignerWithAddress }, + contracts: TestContracts, + epoch: bigint, + expectedTotalStake: bigint, +): Promise<{ nodeScore: bigint; nodeScorePerStake: bigint }> { + console.log(` 📋 Submitting proof for node ${nodeId}...`); + + // Get scores before proof submission + const nodeScoreBeforeProofSubmission = + await contracts.randomSamplingStorage.getNodeEpochScore(epoch, nodeId); + const nodeScorePerStakeBeforeProofSubmission = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + epoch, + nodeId, + ); + + // Create challenge + await contracts.randomSampling.connect(node.operational).createChallenge(); + const challenge = + await contracts.randomSamplingStorage.getNodeChallenge(nodeId); + + // Generate and submit proof + const chunks = kcTools.splitIntoChunks(quads, 32); + const chunkId = Number(challenge[1]); + const { proof } = kcTools.calculateMerkleProof(quads, 32, chunkId); + await contracts.randomSampling + .connect(node.operational) + .submitProof(chunks[chunkId], proof); + + // Get actual score from contract + const nodeScoreAfterProofSubmission = + await contracts.randomSamplingStorage.getNodeEpochScore(epoch, nodeId); + const nodeScorePerStake = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + epoch, + nodeId, + ); + + // Calculate expected scores + const nodeScoreIncrement = await calculateExpectedNodeScore( + nodeId, + contracts, + ); + console.log(` ✅ Node score expected increment: ${nodeScoreIncrement}`); + const expectedNodeScore = nodeScoreBeforeProofSubmission + nodeScoreIncrement; + console.log( + ` ✅ Expected node score: ${nodeScoreBeforeProofSubmission} + ${nodeScoreIncrement} = ${expectedNodeScore}, actual ${nodeScoreAfterProofSubmission}`, + ); + // Verify scores match + expect(nodeScoreAfterProofSubmission).to.be.gt( + 0, + 'Node score should be positive', + ); + expect(nodeScoreAfterProofSubmission).to.be.equal(expectedNodeScore); + + const nodeScorePerStakeIncrement = + (nodeScoreIncrement * ethers.parseUnits('1', 18)) / expectedTotalStake; + console.log( + ` ✅ Node score per stake expected increment: ${nodeScorePerStakeIncrement}`, + ); + const expectedNodeScorePerStake = + nodeScorePerStakeBeforeProofSubmission + nodeScorePerStakeIncrement; + console.log( + ` ✅ Node score per stake: expected ${nodeScorePerStakeBeforeProofSubmission} + ${nodeScorePerStakeIncrement} = ${expectedNodeScorePerStake}, actual ${nodeScorePerStake}`, + ); + expect(nodeScorePerStake).to.be.gt( + 0, + 'Node score per stake should be positive', + ); + expect(nodeScorePerStake).to.be.equal(expectedNodeScorePerStake); + + return { + nodeScore: nodeScoreAfterProofSubmission, + nodeScorePerStake: nodeScorePerStake, + }; +} + +/** + * Advance to next proofing period by mining blocks + */ +async function advanceToNextProofingPeriod( + contracts: TestContracts, +): Promise { + const proofingPeriodDuration = + await contracts.randomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + const { activeProofPeriodStartBlock, isValid } = + await contracts.randomSamplingStorage.getActiveProofPeriodStatus(); + if (isValid) { + // Find out how many blocks are left in the current proofing period + const blocksLeft = + Number(activeProofPeriodStartBlock) + + Number(proofingPeriodDuration) - + Number(await hre.network.provider.send('eth_blockNumber')) + + 1; + for (let i = 0; i < blocksLeft; i++) { + await hre.network.provider.send('evm_mine'); + } + } + await contracts.randomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); +} + +async function ensureNodeHasChunksThisEpoch( + nodeId: bigint, + node: { operational: SignerWithAddress; admin: SignerWithAddress }, + contracts: TestContracts, + accounts: TestAccounts, + receivingNodes: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }[], + receivingNodesIdentityIds: number[], + chunkSize: number, +): Promise { + const produced = + await contracts.epochStorage.getNodeCurrentEpochProducedKnowledgeValue( + nodeId, + ); + + if (produced === 0n) { + if ( + !receivingNodes.some( + (r) => r.operational.address === node.operational.address, + ) + ) { + receivingNodes.unshift(node); + receivingNodesIdentityIds.unshift(Number(nodeId)); + } + + await createKnowledgeCollection( + node.operational, // signer = node.operational + node, // publisher-node + Number(nodeId), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + `ensure-chunks-${Date.now()}`, + 1, // holders + chunkSize, // byteSize - must be >= CHUNK_BYTE_SIZE to avoid division by zero + 1, // replicas + toTRAC(1), + ); + + await contracts.randomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + } +} + +/** + * Setup initial test environment with accounts and contracts + */ +async function setupTestEnvironment(): Promise<{ + accounts: TestAccounts; + contracts: TestContracts; + nodeIds: { node1Id: bigint; node2Id: bigint }; + chunkSize: number; +}> { + await hre.deployments.fixture(); + + const signers = await hre.ethers.getSigners(); + const accounts: TestAccounts = { + owner: signers[0], + node1: { operational: signers[1], admin: signers[2] }, + node2: { operational: signers[3], admin: signers[4] }, + delegator1: signers[5], + delegator2: signers[6], + delegator3: signers[7], + kcCreator: signers[8], + receiver1: { operational: signers[9], admin: signers[10] }, + receiver2: { operational: signers[11], admin: signers[12] }, + receiver3: { operational: signers[13], admin: signers[14] }, + }; + + const contracts: TestContracts = { + hub: await hre.ethers.getContract('Hub'), + token: await hre.ethers.getContract('Token'), + chronos: await hre.ethers.getContract('Chronos'), + stakingStorage: + await hre.ethers.getContract('StakingStorage'), + randomSamplingStorage: await hre.ethers.getContract( + 'RandomSamplingStorage', + ), + parametersStorage: + await hre.ethers.getContract('ParametersStorage'), + profileStorage: + await hre.ethers.getContract('ProfileStorage'), + epochStorage: await hre.ethers.getContract('EpochStorageV8'), + delegatorsInfo: + await hre.ethers.getContract('DelegatorsInfo'), + staking: await hre.ethers.getContract('Staking'), + stakingKPI: await hre.ethers.getContract('StakingKPI'), + profile: await hre.ethers.getContract('Profile'), + randomSampling: + await hre.ethers.getContract('RandomSampling'), + kc: await hre.ethers.getContract( + 'KnowledgeCollection', + ), + askStorage: await hre.ethers.getContract('AskStorage'), + ask: await hre.ethers.getContract('Ask'), + }; + + // Get chunk size to avoid division by zero in challenge generation + const chunkSize = Number( + await contracts.randomSamplingStorage.CHUNK_BYTE_SIZE(), + ); + + await contracts.hub.setContractAddress('HubOwner', accounts.owner.address); + + // Mint tokens for all participants + for (const delegator of [ + accounts.delegator1, + accounts.delegator2, + accounts.delegator3, + ]) { + await contracts.token.mint(delegator.address, toTRAC(100_000)); + } + // const d2Balance = await contracts.token.balanceOf( + // accounts.delegator2.address, + // ); + /* console.log( + `\n💰💰💰 INITIAL BALANCE 💰💰💰 Delegator2 balance after minting: ${ethers.formatUnits( + d2Balance, + await contracts.token.decimals(), + )} TRAC\n`, + ); */ + await contracts.token.mint(accounts.owner.address, toTRAC(1_000_000)); + await contracts.token.mint( + accounts.node1.operational.address, + toTRAC(1_000_000), + ); + await contracts.token.mint(accounts.kcCreator.address, toTRAC(1_000_000)); + + await contracts.parametersStorage + .connect(accounts.owner) // HubOwner + .setOperatorFeeUpdateDelay(0); + + // Create node profiles + const { identityId: node1Id } = await createProfile( + contracts.profile, + accounts.node1, + ); + const { identityId: node2Id } = await createProfile( + contracts.profile, + accounts.node2, + ); + console.log(`\n📚 Node1 ID = ${node1Id}, operator fee=0`); + await contracts.profile + .connect(accounts.node1.admin) + .updateOperatorFee(node1Id, 0); // 0 % + + expect(await contracts.profileStorage.getOperatorFee(node1Id)).to.equal(0); + + console.log(`\n📚 Node2 ID = ${node2Id}, operator fee=0`); + await contracts.profile + .connect(accounts.node2.admin) + .updateOperatorFee(node2Id, 0); // 0 % + + expect(await contracts.profileStorage.getOperatorFee(node2Id)).to.equal(0); + // Initialize ask system (required to prevent division by zero in RandomSampling) + await contracts.parametersStorage.setMinimumStake(toTRAC(100)); + + // Set operator fee to 0% for testing purposes + + // TODO: is this needed? + // await contracts.token + // .connect(accounts.node1.operational) + // .approve(await contracts.staking.getAddress(), toTRAC(100)); + // await contracts.staking + // .connect(accounts.node1.operational) + // .stake(node1Id, toTRAC(100)); + + // const nodeAsk = ethers.parseUnits('0.2', 18); + // await contracts.profile + // .connect(accounts.node1.operational) + // .updateAsk(node1Id, nodeAsk); + // await contracts.ask.connect(accounts.owner).recalculateActiveSet(); + + // Jump to clean epoch start + const timeUntilNextEpoch = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(timeUntilNextEpoch + 1n); + + return { + accounts, + contracts, + nodeIds: { node1Id: BigInt(node1Id), node2Id: BigInt(node2Id) }, + chunkSize, + }; +} + +describe(`Full complex scenario`, function () { + let accounts: TestAccounts; + let contracts: TestContracts; + let nodeIds: { node1Id: bigint; node2Id: bigint }; + let node1Id: bigint; + let d1Key: string, d2Key: string, d3Key: string; + let epoch1: bigint; + let receivingNodes: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }[]; + let receivingNodesIdentityIds: number[]; + let TOKEN_DECIMALS = 18; + let chunkSize: number; + + it('Should execute steps 1-7 with detailed score calculations and verification', async function () { + // ================================================================================================================ + // SETUP: Initialize test environment + // ================================================================================================================ + const setup = await setupTestEnvironment(); + accounts = setup.accounts; + contracts = setup.contracts; + nodeIds = setup.nodeIds; + chunkSize = setup.chunkSize; + node1Id = nodeIds.node1Id; + + TOKEN_DECIMALS = Number(await contracts.token.decimals()); + + epoch1 = await contracts.chronos.getCurrentEpoch(); + const epochLength = await contracts.chronos.epochLength(); + const leftUntilNextEpoch = await contracts.chronos.timeUntilNextEpoch(); + console.log(`\n🏁 Starting test in epoch ${epoch1}`); + console.log(`\n🏁 Epoch length ${epochLength}`); + console.log(`\n🏁 Time until next epoch ${leftUntilNextEpoch}`); + console.log( + `\n🏁 Remaining percentage of time until next epoch ${leftUntilNextEpoch / epochLength}`, + ); + // Create delegator keys for state verification + d1Key = ethers.keccak256( + ethers.solidityPacked(['address'], [accounts.delegator1.address]), + ); + d2Key = ethers.keccak256( + ethers.solidityPacked(['address'], [accounts.delegator2.address]), + ); + d3Key = ethers.keccak256( + ethers.solidityPacked(['address'], [accounts.delegator3.address]), + ); + + // ================================================================================================================ + // SETUP: Create Knowledge Collection for reward pool + // ================================================================================================================ + console.log(`\n📚 Creating Knowledge Collection for reward pool...`); + + receivingNodes = [ + accounts.receiver1, + accounts.receiver2, + accounts.receiver3, + ]; + receivingNodesIdentityIds = []; + for (const recNode of receivingNodes) { + const { identityId } = await createProfile(contracts.profile, recNode); + receivingNodesIdentityIds.push(Number(identityId)); + } + + const kcTokenAmount = toTRAC(48_000); + const numberOfEpochs = 10; + console.log( + `\n📚 Reward pool = ${ethers.formatUnits(kcTokenAmount, 18)} TRAC, for ${numberOfEpochs} epochs = ${kcTokenAmount / BigInt(numberOfEpochs)} per epoch`, + ); + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'test-op-id', + 10, + chunkSize * 10, // byteSize - use multiple of chunkSize for proper chunk generation + numberOfEpochs, + kcTokenAmount, + ); + + // we're sure tokens are well distributed to epochs + + // ================================================================================================================ + // STEP 1: Delegator1 stakes 10,000 TRAC + // ================================================================================================================ + console.log(`\n📊 STEP 1: Delegator1 stakes 10,000 TRAC`); + + const epochBeforeStake = await contracts.chronos.getCurrentEpoch(); + console.log(` ℹ️ Current epoch before staking: ${epochBeforeStake}`); + + await contracts.token + .connect(accounts.delegator1) + .approve(await contracts.staking.getAddress(), toTRAC(10_000)); + await contracts.staking + .connect(accounts.delegator1) + .stake(node1Id, toTRAC(10_000)); + + // Verify state + const totalStakeAfterStep1 = + await contracts.stakingStorage.getNodeStake(node1Id); + console.log( + ` ✅ Node1 total stake: ${ethers.formatUnits(totalStakeAfterStep1, 18)} TRAC`, + ); + expect(totalStakeAfterStep1).to.equal(toTRAC(10_000)); + const totalDelegatorStakeAfterStep1 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + console.log( + ` ✅ Delegator1 total stake: ${ethers.formatUnits(totalDelegatorStakeAfterStep1, 18)} TRAC`, + ); + expect(totalDelegatorStakeAfterStep1).to.equal(toTRAC(10_000)); + + // ================================================================================================================ + // STEP 2: Delegator2 stakes 20,000 TRAC + // ================================================================================================================ + console.log(`\n📊 STEP 2: Delegator2 stakes 20,000 TRAC`); + + await contracts.token + .connect(accounts.delegator2) + .approve(await contracts.staking.getAddress(), toTRAC(20_000)); + await contracts.staking + .connect(accounts.delegator2) + .stake(node1Id, toTRAC(20_000)); + + const totalStakeAfterStep2 = + await contracts.stakingStorage.getNodeStake(node1Id); + console.log( + ` ✅ Node1 total stake: ${ethers.formatUnits(totalStakeAfterStep2, 18)} TRAC`, + ); + expect(totalStakeAfterStep2).to.equal(toTRAC(30_000)); + const totalDelegatorStakeAfterStep2 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d2Key); + console.log( + ` ✅ Delegator2 total stake: ${ethers.formatUnits(totalDelegatorStakeAfterStep2, 18)} TRAC`, + ); + expect(totalDelegatorStakeAfterStep2).to.equal(toTRAC(20_000)); + + // ================================================================================================================ + // STEP 3: Delegator3 stakes 30,000 TRAC + // ================================================================================================================ + console.log(`\n📊 STEP 3: Delegator3 stakes 30,000 TRAC`); + + await contracts.token + .connect(accounts.delegator3) + .approve(await contracts.staking.getAddress(), toTRAC(30_000)); + await contracts.staking + .connect(accounts.delegator3) + .stake(node1Id, toTRAC(30_000)); + + const totalStakeAfterStep3 = + await contracts.stakingStorage.getNodeStake(node1Id); + console.log( + ` ✅ Node1 total stake: ${ethers.formatUnits(totalStakeAfterStep3, 18)} TRAC`, + ); + expect(totalStakeAfterStep3).to.equal(toTRAC(60_000)); + const totalDelegatorStakeAfterStep3 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d3Key); + console.log( + ` ✅ Delegator3 total stake: ${ethers.formatUnits(totalDelegatorStakeAfterStep3, 18)} TRAC`, + ); + expect(totalDelegatorStakeAfterStep3).to.equal(toTRAC(30_000)); + + // ================================================================================================================ + // STEP 4: Node1 submits first proof with score verification + // ================================================================================================================ + console.log(`\n🔬 STEP 4: Node1 submits first proof`); + + await contracts.randomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + const { + nodeScore: scoreAfter1, + nodeScorePerStake: nodeScorePerStakeAfter1, + } = await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + epoch1, + totalStakeAfterStep3, + ); + + // ================================================================================================================ + // STEP 5: Delegator1 stakes additional 10,000 TRAC with score settlement verification + // ================================================================================================================ + console.log(`\n📊 STEP 5: Delegator1 stakes additional 10,000 TRAC`); + + // Get delegator1's score before staking + const d1ScoreBeforeStake = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + epoch1, + node1Id, + d1Key, + ); + + // Get delegator1's last settled node score per stake + const d1LastSettledNodeScorePerStake = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + epoch1, + node1Id, + d1Key, + ); + + // Stake additional 10,000 TRAC + await contracts.token + .connect(accounts.delegator1) + .approve(await contracts.staking.getAddress(), toTRAC(10_000)); + await contracts.staking + .connect(accounts.delegator1) + .stake(node1Id, toTRAC(10_000)); + + // Verify node1's total stake + const totalStakeAfterStep5 = + await contracts.stakingStorage.getNodeStake(node1Id); + console.log( + ` ✅ Node1 total stake: ${ethers.formatUnits(totalStakeAfterStep5, 18)} TRAC`, + ); + expect(totalStakeAfterStep5).to.equal(toTRAC(70_000)); + const totalDelegator1StakeAfterStep5 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + console.log( + ` ✅ Delegator1 total stake: ${ethers.formatUnits(totalDelegator1StakeAfterStep5, 18)} TRAC`, + ); + expect(totalDelegator1StakeAfterStep5).to.equal(toTRAC(20_000)); + + // Verify delegator1's score settlement from first proof period + const d1ScoreAfterStake = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + epoch1, + node1Id, + d1Key, + ); + const expectedD1ScoreIncrement = calculateExpectedDelegatorScore( + toTRAC(10_000), + nodeScorePerStakeAfter1, + d1LastSettledNodeScorePerStake, + ); + const expectedD1Score = d1ScoreBeforeStake + expectedD1ScoreIncrement; + + console.log(` 🧮 Delegator1 score settlement verification:`); + console.log( + ` ✅ Expected: ${expectedD1Score}, Actual: ${d1ScoreAfterStake}`, + ); + expect(d1ScoreAfterStake).to.equal( + expectedD1Score, + 'Delegator1 score settlement mismatch', + ); + + // ================================================================================================================ + // STEP 6: Node1 submits second proof + // ================================================================================================================ + console.log(`\n🔬 STEP 6: Node1 submits second proof`); + + await advanceToNextProofingPeriod(contracts); + const { + nodeScore: scoreAfter2, + nodeScorePerStake: nodeScorePerStakeAfter2, + } = await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + epoch1, + totalStakeAfterStep5, + ); + + expect(scoreAfter2).to.be.gt( + scoreAfter1, + 'Second proof should increase total score', + ); + expect(nodeScorePerStakeAfter2).to.be.gt( + nodeScorePerStakeAfter1, + 'Score per stake should increase', + ); + + // ================================================================================================================ + // ADVANCE TO NEXT EPOCH AND FINALIZE + // ================================================================================================================ + console.log(`\n⏭️ Advancing to next epoch and finalizing...`); + + const timeUntilNextEpoch = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(timeUntilNextEpoch + 1n); + const epoch2 = await contracts.chronos.getCurrentEpoch(); + console.log(` ✅ Advanced to epoch ${epoch2}`); + + // Create another KC to trigger epoch finalization + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'dummy-op-id-2', + 1, // holders + chunkSize * 5, // byteSize - use multiple of chunkSize + 1, // replicas + toTRAC(10), // small fee for finalization + ); + + expect(await contracts.epochStorage.lastFinalizedEpoch(1)).to.be.gte( + epoch1, + ); + console.log(` ✅ Epoch ${epoch1} finalized`); + + // ================================================================================================================ + // STEP 7: Delegator1 claims rewards with detailed verification + // ================================================================================================================ + console.log(`\n💰 STEP 7: Delegator1 claims rewards for epoch ${epoch1}`); + + const d1StakeBaseBefore = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + + // Get node score + const nodeFinalScore = + await contracts.randomSamplingStorage.getNodeEpochScore(epoch1, node1Id); + const netNodeRewards = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + epoch1, + ); + + const epocRewardsPool = await contracts.epochStorage.getEpochPool( + 1, + epoch1, + ); + expect(netNodeRewards).to.equal(epocRewardsPool); + + console.log(` 🧮 Reward calculation verification:`); + console.log(` 📊 Node1 final score: ${nodeFinalScore}`); + console.log( + ` 💎 Net delegator rewards: ${ethers.formatUnits(netNodeRewards, 18)} TRAC should be equal to epoch rewards pool: ${ethers.formatUnits(epocRewardsPool, 18)} TRAC`, + ); + + // Claim rewards + await contracts.staking + .connect(accounts.delegator1) + .claimDelegatorRewards(node1Id, epoch1, accounts.delegator1.address); + + const d1FinalScore = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + epoch1, + node1Id, + d1Key, + ); + + // Calculate expected reward: (delegator_score / node_score) * available_rewards + const expectedReward = (d1FinalScore * netNodeRewards) / nodeFinalScore; + + console.log(` 📊 Delegator1 final score: ${d1FinalScore}`); + console.log( + ` 💰 Expected reward for Delegator1: ${ethers.formatUnits(expectedReward, 18)} TRAC`, + ); + + const d1StakeBaseAfter = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + const d1LastClaimedEpoch = + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator1.address, + ); + + // Verify reward was restaked (since gap is only 1 epoch) + const actualReward = d1StakeBaseAfter - d1StakeBaseBefore; + console.log( + ` ✅ Actual reward for Delegator1: ${ethers.formatUnits(actualReward, 18)} TRAC`, + ); + + // TODO: Fix manual reward calculation - delegator accumulates score across multiple proof periods + // The actual reward is higher because delegator1 earned score in both periods: + // Period 1: 10k stake * score_per_stake_1 + // Period 2: 20k stake * (score_per_stake_2 - score_per_stake_1) + console.log( + ` 📝 Note: Manual calculation needs to account for multi-period accumulation`, + ); + expect(actualReward).to.be.gt(0, 'Reward should be positive'); + expect(d1LastClaimedEpoch).to.equal( + epoch1, + 'Last claimed epoch not updated', + ); + expect(actualReward).to.equal(expectedReward); + + // Verify other delegators haven't claimed yet + expect( + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator2.address, + ), + ).to.equal(epoch1 - 1n); + expect( + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator3.address, + ), + ).to.equal(epoch1 - 1n); + + // ================================================================================================================ + // FINAL VERIFICATION: Test completed successfully + // ================================================================================================================ + console.log( + `\n✨ STEPS 1-7 COMPLETED SUCCESSFULLY WITH FULL VERIFICATION ✨`, + ); + console.log( + `📈 Final Node1 total stake: ${ethers.formatUnits(await contracts.stakingStorage.getNodeStake(node1Id), 18)} TRAC`, + ); + console.log( + `👤 Final Delegator1 stake: ${ethers.formatUnits(await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key), 18)} TRAC`, + ); + console.log( + `👤 Final Delegator2 stake: ${ethers.formatUnits(await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d2Key), 18)} TRAC`, + ); + console.log( + `👤 Final Delegator3 stake: ${ethers.formatUnits(await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d3Key), 18)} TRAC`, + ); + + // Key verifications completed: + // ✅ 1. Delegators can stake on a node + // ✅ 2. Node can submit proofs and accumulate score (with manual verification) + // ✅ 3. Delegator scores are properly settled when additional stakes are made (with manual verification) + // ✅ 4. Epochs can be finalized + // ✅ 5. Delegators can claim rewards based on their proportional score (with manual verification) + // ✅ 6. Rewards are auto-staked when epoch gap ≤ 1 + // ✅ 7. All score calculations match manual computations + }); + + /****************************************************************************************** + * Steps 8 → 14 (continues from the chain state left after Step 7) * + ******************************************************************************************/ + + it('Should execute steps 8-14 with detailed score calculations and verification', async function () { + /* Epoch markers */ + const currentEpoch = await contracts.chronos.getCurrentEpoch(); // == 2 + const previousEpoch = currentEpoch - 1n; // == 1 + + /********************************************************************** + * STEP 8 – Delegator2 claims rewards for previousEpoch * + **********************************************************************/ + console.log( + `\n💰 STEP 8: Delegator2 claims rewards for epoch ${previousEpoch}`, + ); + + const d2BaseBefore = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d2Key, + ); + + const nodeScorePrev = + await contracts.randomSamplingStorage.getNodeEpochScore( + previousEpoch, + node1Id, + ); + const netRewardsPrev = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + previousEpoch, + ); + + await contracts.staking + .connect(accounts.delegator2) + .claimDelegatorRewards( + node1Id, + previousEpoch, + accounts.delegator2.address, + ); + + const d2ScorePrev = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + previousEpoch, + node1Id, + d2Key, + ); + const d2ExpectedReward = (d2ScorePrev * netRewardsPrev) / nodeScorePrev; + + const d2BaseAfter = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d2Key, + ); + const d2ActualReward = d2BaseAfter - d2BaseBefore; + + console.log( + ` ✅ D2 staked reward ${ethers.formatUnits(d2ActualReward, 18)} TRAC (expected ${ethers.formatUnits( + d2ExpectedReward, + 18, + )})`, + ); + expect(d2ActualReward).to.equal(d2ExpectedReward); + + const expectedDelegatorRewards = + await contracts.stakingKPI.getDelegatorReward( + node1Id, + previousEpoch, + accounts.delegator2.address, + ); + console.log( + ` ✅ Expected delegator rewards from StakingKPI: ${ethers.formatUnits(expectedDelegatorRewards, 18)} TRAC`, + ); + expect(d2ActualReward).to.equal(expectedDelegatorRewards); + + await epochRewardsPoolPrecisionLoss( + contracts, + previousEpoch, + netRewardsPrev, + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + previousEpoch, + accounts.delegator1.address, + )) + + d2ActualReward + + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + previousEpoch, + accounts.delegator3.address, + )), + ); + + /********************************************************************** + * STEP 9 – Delegator3 attempts withdrawal before claim → revert * + **********************************************************************/ + console.log( + '\n⛔ STEP 9: Delegator3 withdrawal should revert because they did not claim rewards for all previous epochs', + ); + + await expect( + contracts.staking + .connect(accounts.delegator3) + .requestWithdrawal(node1Id, ethers.parseUnits('5000', 18)), + ).to.be.revertedWith( + 'Must claim the previous epoch rewards before changing stake', + ); + console.log(' ✅ revert received as expected'); + + /********************************************************************** + * STEP 10 – Node1 submits first proof in currentEpoch * + **********************************************************************/ + console.log( + `\n🔬 STEP 10: Node1 submits first proof in epoch ${currentEpoch}`, + ); + + /* move to the next proof-period so the challenge is fresh */ + await advanceToNextProofingPeriod(contracts); + + /* --- BEFORE snapshot ------------------------------------------------ */ + const stakeBeforeProof = + await contracts.stakingStorage.getNodeStake(node1Id); + const scoreBeforeProof = + await contracts.randomSamplingStorage.getNodeEpochScore( + currentEpoch, + node1Id, + ); + const perStakeBefore = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch, + node1Id, + ); + + console.log( + ` ℹ️ before-proof: score=${scoreBeforeProof}, nodeScorePerStake=${perStakeBefore}, stake=${ethers.formatUnits(stakeBeforeProof, 18)} TRAC`, + ); + + /* --- Submit proof & verify internal math --------------------------- */ + await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + currentEpoch, + stakeBeforeProof, + ); + + /* --- AFTER snapshot ------------------------------------------------- */ + const scoreAfterProof = + await contracts.randomSamplingStorage.getNodeEpochScore( + currentEpoch, + node1Id, + ); + const perStakeAfter = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch, + node1Id, + ); + + /* --- Assertions ----------------------------------------------------- */ + expect(scoreAfterProof).to.be.gt( + scoreBeforeProof, + 'Node epoch score must increase after proof', + ); + expect(perStakeAfter).to.be.gt( + perStakeBefore, + 'Score-per-stake must increase after proof', + ); + + console.log( + ` ✅ score increased: ${scoreBeforeProof} → ${scoreAfterProof}; ` + + `nodeScorePerStake increased: ${perStakeBefore} → ${perStakeAfter}`, + ); + + /********************************************************************** + * STEP 11 – Delegator 2 requests withdrawal of 10 000 TRAC * + **********************************************************************/ + console.log('\n📤 STEP 11: Delegator2 requests withdrawal of 10 000 TRAC'); + + /* ---------- BEFORE snapshot -------------------------------------- */ + const d2StakeBaseBefore = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d2Key); + const nodeStakeBefore11 = + await contracts.stakingStorage.getNodeStake(node1Id); + + const scorePerStakeCur = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch, + node1Id, + ); + const d2LastSettledBefore = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + currentEpoch, + node1Id, + d2Key, + ); + const d2ScoreBefore = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + currentEpoch, + node1Id, + d2Key, + ); + + /* how much score should be settled by _prepareForStakeChange() */ + const expectedScoreIncrement = calculateExpectedDelegatorScore( + d2StakeBaseBefore, // stake before withdrawal + scorePerStakeCur, + d2LastSettledBefore, + ); + + /* ---------- perform withdrawal request --------------------------- */ + await contracts.staking + .connect(accounts.delegator2) + .requestWithdrawal(node1Id, ethers.parseUnits('10000', 18)); + + /* ---------- AFTER snapshot --------------------------------------- */ + const d2StakeBaseAfter = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d2Key); + const nodeStakeAfter11 = + await contracts.stakingStorage.getNodeStake(node1Id); + + const d2ScoreAfter = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + currentEpoch, + node1Id, + d2Key, + ); + const d2LastSettledAfter = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + currentEpoch, + node1Id, + d2Key, + ); + + const [withdrawAmount] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d2Key, + ); + + /* ---------- Assertions ------------------------------------------- */ + expect(withdrawAmount).to.equal( + ethers.parseUnits('10000', 18), + 'withdrawal request amount', + ); + expect(nodeStakeAfter11).to.equal( + nodeStakeBefore11 - ethers.parseUnits('10000', 18), + 'node total stake should fall by 10 000 TRAC', + ); + expect(d2StakeBaseAfter).to.equal( + d2StakeBaseBefore - ethers.parseUnits('10000', 18), + 'delegator base stake should fall by 10 000 TRAC', + ); + expect(d2ScoreAfter).to.equal( + d2ScoreBefore + expectedScoreIncrement, + 'delegator score must be lazily settled before stake change', + ); + expect(d2LastSettledAfter).to.equal( + scorePerStakeCur, + 'lastSettled index must be bumped to current nodeScorePerStake', + ); + + console.log( + ` ✅ withdrawal request stored (${ethers.formatUnits(withdrawAmount, 18)} TRAC)`, + ); + console.log( + ` ✅ node stake decreased: ${ethers.formatUnits(nodeStakeBefore11, 18)} → ${ethers.formatUnits(nodeStakeAfter11, 18)} TRAC`, + ); + console.log( + ` ✅ D2 stakeBase decreased: ${ethers.formatUnits(d2StakeBaseBefore, 18)} → ${ethers.formatUnits(d2StakeBaseAfter, 18)} TRAC`, + ); + console.log( + ` ✅ D2 epochScore increased: ${d2ScoreBefore} → ${d2ScoreAfter} (settled +${expectedScoreIncrement})`, + ); + + /********************************************************************** + * STEP 12 – Node1 submits **second** proof in currentEpoch * + **********************************************************************/ + console.log( + `\n🔬 STEP 12: Node1 submits second proof in epoch ${currentEpoch}`, + ); + + /* --------------------------------------------------------------- + * 1️⃣ Shift to new proof-period so challenge is valid + * ------------------------------------------------------------- */ + await advanceToNextProofingPeriod(contracts); + + /* --------------------------------------------------------------- + * 2️⃣ BEFORE snapshot + * ------------------------------------------------------------- */ + const nodeStakeBefore12 = + await contracts.stakingStorage.getNodeStake(node1Id); // ≈ 62 100 TRAC + const nodeScoreBefore12 = + await contracts.randomSamplingStorage.getNodeEpochScore( + currentEpoch, + node1Id, + ); + const perStakeBefore12 = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch, + node1Id, + ); + const allNodesScoreBefore12 = + await contracts.randomSamplingStorage.getAllNodesEpochScore(currentEpoch); + + console.log( + ` ℹ️ before-proof: nodeScore=${nodeScoreBefore12}, nodeScorePerStake=${perStakeBefore12}, ` + + `allNodesScore=${allNodesScoreBefore12}, nodeStake=${ethers.formatUnits(nodeStakeBefore12, 18)} TRAC`, + ); + + /* --------------------------------------------------------------- + * 3️⃣ Perform proof + builtin math-check + * ------------------------------------------------------------- */ + await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + currentEpoch, + nodeStakeBefore12, + ); + + /* --------------------------------------------------------------- + * 4️⃣ AFTER snapshot + * ------------------------------------------------------------- */ + const nodeStakeAfter12 = + await contracts.stakingStorage.getNodeStake(node1Id); + const nodeScoreAfter12 = + await contracts.randomSamplingStorage.getNodeEpochScore( + currentEpoch, + node1Id, + ); + const perStakeAfter12 = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch, + node1Id, + ); + const allNodesScoreAfter12 = + await contracts.randomSamplingStorage.getAllNodesEpochScore(currentEpoch); + + /* --------------------------------------------------------------- + * 5️⃣ Assertions – strict before/after checks + * ------------------------------------------------------------- */ + expect(nodeStakeAfter12).to.equal( + nodeStakeBefore12, + 'Node stake must not change when only submitting a proof', + ); + + expect(nodeScoreAfter12).to.be.gt( + nodeScoreBefore12, + 'Node epoch score must increase after second proof', + ); + + expect(perStakeAfter12).to.be.gt( + perStakeBefore12, + 'Score-per-stake must increase after second proof', + ); + + expect(allNodesScoreAfter12).to.be.gt( + allNodesScoreBefore12, + 'Global all-nodes score must increase after proof', + ); + + console.log( + ` ✅ nodeScore: ${nodeScoreBefore12} → ${nodeScoreAfter12}\n` + + ` ✅ scorePerStake: ${perStakeBefore12} → ${perStakeAfter12}\n`, + ); + + /********************************************************************** + * STEP 13 – Delegator 1 claims rewards for epoch `claimEpoch` + * (diagram "Delegator1 claims reward for epoch 2") + **********************************************************************/ + + console.log('\n💰 STEP 13: Delegator1 claims rewards for previous epoch'); + + /* --------------------------------------------------------------- + * 1️⃣ Finalise currentEpoch so rewards become claimable + * – we need to be in epoch 3 and claim for epoch 2 + * ------------------------------------------------------------- */ + const timeUntilNextEpoch = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(timeUntilNextEpoch + 1n); + + const epochAfterFinalize = await contracts.chronos.getCurrentEpoch(); // == currentEpoch + 1 + const claimEpoch = epochAfterFinalize - 1n; // epoch we are claiming for + + /* one more dummy KC → triggers epoch finalisation */ + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'finalise-epoch', + 10, // holders + chunkSize * 15, // byteSize - use multiple of chunkSize for proper chunk generation + 10, // replicas + toTRAC(50_000), // <-- epoch fee identical to the diagram + ); + + /* epoch really finalised? */ + expect(await contracts.epochStorage.lastFinalizedEpoch(1)).to.be.gte( + claimEpoch, + 'Epoch must be finalised before claiming', + ); + + /* --------------------------------------------------------------- + * 2️⃣ BEFORE snapshot – **manual** reward calculation + * ------------------------------------------------------------- */ + const SCALE18 = ethers.parseUnits('1', 18); + + const d1BaseBefore = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const nodeScore = await contracts.randomSamplingStorage.getNodeEpochScore( + claimEpoch, + node1Id, + ); + const perStake = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + claimEpoch, + node1Id, + ); + const d1LastSettled = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + claimEpoch, + node1Id, + d1Key, + ); + const d1StoredScore = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + claimEpoch, + node1Id, + d1Key, + ); + + /* "lazy-settle" delta that _will_ be written inside claim() */ + const d1SettleDiff = perStake - d1LastSettled; + const earnedScore = (BigInt(d1BaseBefore) * d1SettleDiff) / SCALE18; + + /* total score that delegator should have after settle */ + const d1TotalScore = d1StoredScore + earnedScore; + + /* net pool for delegators that epoch */ + const netDelegatorRewards13 = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + claimEpoch, + ); + + /* expected TRAC reward (18 decimals) */ + const expectedReward13 = + nodeScore === 0n + ? 0n + : (d1TotalScore * netDelegatorRewards13) / nodeScore; + + console.log( + ` ℹ️ claimEpoch=${claimEpoch}, nodeScore=${nodeScore}, d1Score(before)=${d1StoredScore}, earned score=${earnedScore}, pool=${ethers.formatUnits(netDelegatorRewards13, 18)} TRAC`, + ); + console.log( + ` 🔢 nodeScore = ${nodeScore}`, + `\n 🔢 d1StoredScore = ${d1StoredScore}`, + `\n 🔢 d1EarnedScore = ${earnedScore}`, + ); + + /* --------------------------------------------------------------- + * 3️⃣ Perform claim + * ------------------------------------------------------------- */ + await contracts.staking + .connect(accounts.delegator1) + .claimDelegatorRewards(node1Id, claimEpoch, accounts.delegator1.address); + + /* --------------------------------------------------------------- + * 4️⃣ AFTER snapshot + * ------------------------------------------------------------- */ + const d1BaseAfter = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const nodeStakeAfter13 = + await contracts.stakingStorage.getNodeStake(node1Id); + const d1LastClaimed13 = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator1.address, + ); + + /* --------------------------------------------------------------- + * 5️⃣ Assertions + * ------------------------------------------------------------- */ + const actualReward13 = d1BaseAfter - d1BaseBefore; + const expectedDelegatorRewardKPI = + await contracts.stakingKPI.getDelegatorReward( + node1Id, + claimEpoch, + accounts.delegator1.address, + ); + + expect(actualReward13, 'restaked reward amount').to.equal(expectedReward13); + expect(expectedDelegatorRewardKPI).to.equal(actualReward13); + expect(d1LastClaimed13, 'lastClaimedEpoch update').to.equal(claimEpoch); + expect(nodeStakeAfter13).to.equal( + nodeStakeAfter12 + actualReward13, + 'node total stake must include newly auto-staked reward', + ); + console.log( + ` 🧮 EXPECTED reward = ${ethers.formatUnits(expectedReward13, 18)} TRAC`, + `\n ✅ ACTUAL reward = ${ethers.formatUnits(actualReward13, 18)} TRAC`, + ); + + /* nice console output */ + console.log( + ` ✅ D1 reward ${ethers.formatUnits(actualReward13, 18)} TRAC ` + + `staked → new base ${ethers.formatUnits(d1BaseAfter, 18)} TRAC`, + ); + console.log(` ✅ lastClaimedEpoch set to ${d1LastClaimed13}\n`); + + /********************************************************************** + * STEP 14 – Delegator 2 claims rewards for epoch `claimEpoch` (= 2) + **********************************************************************/ + + console.log( + '\n💰 STEP 14: Delegator2 claims rewards for epoch', + claimEpoch, + ); + + /* --------------------------------------------------------------- + * 1️⃣ Pre-claim snapshot + * ------------------------------------------------------------- */ + const d2BaseBefore14 = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d2Key, + ); + const d2LastClaimed14 = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator2.address, + ); + + // Must be claiming the next unclaimed epoch (1 → 2) + expect(d2LastClaimed14).to.equal( + claimEpoch - 1n, + 'Delegator2 is not claiming the oldest pending epoch', + ); + + /* --------------------------------------------------------------- + * 2️⃣ Manual reward calculation + * ------------------------------------------------------------- */ + const nodeScoreClaim = + await contracts.randomSamplingStorage.getNodeEpochScore( + claimEpoch, + node1Id, + ); + const perStakeClaim = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + claimEpoch, + node1Id, + ); + const d2LastSettledClaim = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + claimEpoch, + node1Id, + d2Key, + ); + const d2StoredScore = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + claimEpoch, + node1Id, + d2Key, + ); + + /* "lazy-settle" part to be added inside claim() */ + const d2SettleDiff = perStakeClaim - d2LastSettledClaim; + const d2EarnedScore = (d2BaseBefore14 * d2SettleDiff) / SCALE18; + const d2TotalScore = d2StoredScore + d2EarnedScore; + + const netDelegatorRewards14 = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + claimEpoch, + ); + + const expectedReward14 = + nodeScoreClaim === 0n + ? 0n + : (d2TotalScore * netDelegatorRewards14) / nodeScoreClaim; + + console.log( + ` 🔢 nodeScore = ${nodeScoreClaim}`, + `\n 🔢 d2StoredScore = ${d2StoredScore}`, + `\n 🔢 d2EarnedScore = ${d2EarnedScore}`, + `pool=${ethers.formatUnits(netDelegatorRewards14, 18)} TRAC`, + ); + + /* --------------------------------------------------------------- + * 3️⃣ Claim transaction + * ------------------------------------------------------------- */ + await contracts.staking + .connect(accounts.delegator2) + .claimDelegatorRewards(node1Id, claimEpoch, accounts.delegator2.address); + + /* --------------------------------------------------------------- + * 4️⃣ Post-claim snapshot + * ------------------------------------------------------------- */ + const d2BaseAfter14 = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d2Key, + ); + const d2LastClaimedAfter = + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator2.address, + ); + const actualReward14 = d2BaseAfter14 - d2BaseBefore14; + const expectedDelegatorRewardKPI14 = + await contracts.stakingKPI.getDelegatorReward( + node1Id, + claimEpoch, + accounts.delegator2.address, + ); + + console.log( + ` 🧮 EXPECTED reward = ${ethers.formatUnits(expectedReward14, 18)} TRAC`, + `\n ✅ ACTUAL reward = ${ethers.formatUnits(actualReward14, 18)} TRAC`, + ); + + /* --------------------------------------------------------------- + * 5️⃣ Assertions + * ------------------------------------------------------------- */ + expect(actualReward14, 'staked reward mismatch').to.equal(expectedReward14); + expect(expectedDelegatorRewardKPI14).to.equal(actualReward14); + expect(d2LastClaimedAfter, 'lastClaimedEpoch not updated').to.equal( + claimEpoch, + ); + + // Node stake should grow by the auto-staked reward + const nodeStakeAfter14 = + await contracts.stakingStorage.getNodeStake(node1Id); + expect(nodeStakeAfter14).to.equal( + nodeStakeAfter13 + actualReward14, + 'Node total stake did not include Delegator2 reward', + ); + + // Pending withdrawal request must stay untouched + const [withdrawPending] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d2Key, + ); + expect(withdrawPending).to.equal( + ethers.parseUnits('10000', 18), + 'Withdrawal request amount changed after claim', + ); + + console.log( + ` ✅ D2 reward ${ethers.formatUnits(actualReward14, 18)} TRAC ` + + `restaked → new base ${ethers.formatUnits(d2BaseAfter14, 18)} TRAC`, + ); + console.log(` ✅ lastClaimedEpoch set to ${d2LastClaimedAfter}\n`); + console.log('\n✨ Steps 8-14 completed – ready for next tests ✨\n'); + + await epochRewardsPoolPrecisionLoss( + contracts, + claimEpoch, + netDelegatorRewards14, + actualReward13 + + actualReward14 + + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + claimEpoch, + accounts.delegator3.address, + )), + ); + }); + + /****************************************************************************************** + * Steps 15 – 21 (continue from the chain-state left after Step 14) * + ******************************************************************************************/ + it('Should execute steps 15-23 with detailed score calculations and verification', async function () { + /* helpers already in scope from previous tests */ + const toTRAC18 = (x: number | string) => + ethers.parseUnits(x.toString(), 18); + + const TEN_K = ethers.parseUnits('10000', TOKEN_DECIMALS); + + /********************************************************************** + * STEP 15 – Delegator 2 finalises withdrawal of 10 000 TRAC + **********************************************************************/ + console.log('\n📤 STEP 15: Delegator2 finalises withdrawal of 10 000 TRAC'); + + /* 1️⃣ Make sure the request exists and the delay has passed */ + const [pending, , releaseTs] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d2Key, + ); + + expect(pending, 'pending amount mismatch').to.equal(TEN_K); + + const now = BigInt(await time.latest()); + if (now < releaseTs) await time.increase(releaseTs - now + 1n); + + /* 2️⃣ Snapshot BEFORE */ + const balBefore = await contracts.token.balanceOf(accounts.delegator2); + const nodeStakeBefore15 = + await contracts.stakingStorage.getNodeStake(node1Id); + + console.log( + ` 🪙 Wallet BEFORE: ${ethers.formatUnits(balBefore, TOKEN_DECIMALS)} TRAC`, + ); + + /* 3️⃣ Finalise */ + await contracts.staking + .connect(accounts.delegator2) + .finalizeWithdrawal(node1Id); + + /* 4️⃣ Snapshot AFTER */ + const balAfter = await contracts.token.balanceOf(accounts.delegator2); + const nodeStakeAfter15 = + await contracts.stakingStorage.getNodeStake(node1Id); + const [reqAfter] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d2Key, + ); + + /* 5️⃣ Assertions */ + expect(balAfter - balBefore, 'wallet diff').to.equal(TEN_K); // ← BigInt diff + expect(nodeStakeAfter15, 'node stake already reduced in step 11').to.equal( + nodeStakeBefore15, + ); + expect(reqAfter, 'withdrawal request should be cleared').to.equal(0n); + + console.log( + ` 🪙 Wallet AFTER : ${ethers.formatUnits(balAfter, TOKEN_DECIMALS)} TRAC`, + `\n ✅ 10 000 TRAC transferred successfully`, + ); + + /********************************************************************** + * STEP 16 – Delegator 3 tries to stake extra 5 000 TRAC (must revert) * + **********************************************************************/ + console.log( + '\n⛔ STEP 16: Delegator3 attempts to stake 5 000 TRAC – should revert', + ); + + await contracts.token + .connect(accounts.delegator3) + .approve(await contracts.staking.getAddress(), toTRAC18(5_000)); + + await expect( + contracts.staking + .connect(accounts.delegator3) + .stake(node1Id, toTRAC18(5_000)), + ).to.be.revertedWith( + 'Must claim all previous epoch rewards before changing stake', + ); + + console.log( + ' ✅ Revert received as expected – Delegator3 must claim epochs 2 & 3 first', + ); + + /********************************************************************** + * STEP 17 – Delegator 3 claims rewards for epoch 1 + **********************************************************************/ + console.log('\n💰 STEP 17: Delegator3 claims rewards for epoch 2'); + + const claimEpoch17 = 2n; + + const SCALE18 = ethers.parseUnits('1', 18); + + /* ── 1. Preconditions ────────────────────────────────────────────── */ + const lastClaimedBefore = + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator3.address, + ); + + /** + * 0 – sentinel "never claimed" (default) + * n–1 – standard "oldest un-claimed epoch" + */ + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + lastClaimedBefore === 0n || lastClaimedBefore === claimEpoch17 - 1n, + 'Delegator-3 must claim the oldest pending epoch first', + ).to.be.true; + + /* ── 2. Manual reward calculation (for assertions) ───────────────── */ + const stakeBaseBefore = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d3Key); + + const nodeScore17 = await contracts.randomSamplingStorage.getNodeEpochScore( + claimEpoch17, + node1Id, + ); + const perStake17 = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + claimEpoch17, + node1Id, + ); + + const lastSettled17 = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + claimEpoch17, + node1Id, + d3Key, + ); + const storedScore17 = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + claimEpoch17, + node1Id, + d3Key, + ); + + const earnedScore17 = + (stakeBaseBefore * (perStake17 - lastSettled17)) / SCALE18; + const totalScore17 = storedScore17 + earnedScore17; + + const rewardsPool17 = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + claimEpoch17, + ); + + const expectedReward17 = + nodeScore17 === 0n ? 0n : (totalScore17 * rewardsPool17) / nodeScore17; + + /* ── 3. Claim transaction ────────────────────────────────────────── */ + await contracts.staking + .connect(accounts.delegator3) + .claimDelegatorRewards( + node1Id, + claimEpoch17, + accounts.delegator3.address, + ); + + /* ── 4. Post-claim checks ────────────────────────────────────────── */ + const stakeBaseAfter = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d3Key, + ); // must stay 30 000 + const rollingRewards = + await contracts.delegatorsInfo.getDelegatorRollingRewards( + node1Id, + accounts.delegator3.address, + ); + const lastClaimedAfter = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator3.address, + ); + + expect( + stakeBaseAfter, + 'stakeBase unchanged while older epochs remain', + ).to.equal(stakeBaseBefore); + expect(rollingRewards, 'rollingRewards incorrect').to.equal( + expectedReward17, + ); + expect(lastClaimedAfter, 'lastClaimedEpoch not updated').to.equal( + claimEpoch17, + ); + + console.log( + ` ✅ rollingRewards = ${ethers.formatUnits(rollingRewards, 18)} TRAC`, + `\n ✅ lastClaimedEpoch = ${lastClaimedAfter}\n`, + ); + + /********************************************************************** + * STEP 18 – Delegator 3 claims rewards for epoch 2 + * -------------------------------------------------------------------- + **********************************************************************/ + console.log('\n💰 STEP 18: Delegator3 claims rewards for epoch 3'); + + const claimEpoch18 = 3n; + + /* ── 1. PRE-CONDITIONS ──────────────────────────────────────────────── */ + const d3LastClaimedBefore18 = + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator3.address, + ); + + // Must be claiming the oldest pending epoch (1 → 2) + expect(d3LastClaimedBefore18).to.equal( + claimEpoch18 - 1n, + 'Delegator-3 is skipping an older unclaimed epoch', + ); + + /* ── 2. MANUAL REWARD CALCULATION ───────────────────────────────────── */ + const d3BaseBefore18 = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d3Key, + ); + const d3RollingBefore18 = + await contracts.delegatorsInfo.getDelegatorRollingRewards( + node1Id, + accounts.delegator3.address, + ); + + const nodeScoreEp2 = + await contracts.randomSamplingStorage.getNodeEpochScore( + claimEpoch18, + node1Id, + ); + const perStakeEp2 = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + claimEpoch18, + node1Id, + ); + + const d3LastSettledEp2 = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + claimEpoch18, + node1Id, + d3Key, + ); + const d3StoredScoreEp2 = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + claimEpoch18, + node1Id, + d3Key, + ); + + /* "lazy-settle" part that will be written inside claim() */ + const d3EarnedScore = + (d3BaseBefore18 * (perStakeEp2 - d3LastSettledEp2)) / SCALE18; + const d3TotalScore = d3StoredScoreEp2 + d3EarnedScore; + + const netDelegatorRewardsEp2 = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + claimEpoch18, + ); + + // New reward for epoch 2 + const rewardEp2 = + nodeScoreEp2 === 0n + ? 0n + : (d3TotalScore * netDelegatorRewardsEp2) / nodeScoreEp2; + + // ► what will actually be auto-staked: + const expectedStakeIncrease18 = d3RollingBefore18 + rewardEp2; + + /* ── 3. CLAIM TRANSACTION ───────────────────────────────────────────── */ + await contracts.staking + .connect(accounts.delegator3) + .claimDelegatorRewards( + node1Id, + claimEpoch18, + accounts.delegator3.address, + ); + + /* ── 4. POST-CLAIM SNAPSHOT ─────────────────────────────────────────── */ + const d3BaseAfter18 = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d3Key, + ); + const d3RollingAfter18 = + await contracts.delegatorsInfo.getDelegatorRollingRewards( + node1Id, + accounts.delegator3.address, + ); + const d3LastClaimedAfter18 = + await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator3.address, + ); + const nodeStakeAfter18 = + await contracts.stakingStorage.getNodeStake(node1Id); + + /* ── 5. ASSERTIONS ──────────────────────────────────────────────────── */ + expect( + d3BaseAfter18 - d3BaseBefore18, + 'auto-staked amount mismatch', + ).to.equal(expectedStakeIncrease18); + + expect(d3RollingAfter18, 'rollingRewards should now be 0').to.equal(0n); + + expect(d3LastClaimedAfter18, 'lastClaimedEpoch not updated').to.equal( + claimEpoch18, + ); + + // nodeStakeAfter14 must be in scope from previous step + expect(nodeStakeAfter18).to.equal( + nodeStakeAfter15 + expectedStakeIncrease18, + 'Node total stake should include D3 reward', + ); + + console.log( + ` 🧮 reward(epoch3) = ${ethers.formatUnits(rewardEp2, 18)} TRAC`, + `\n 🧮 rolling(before) = ${ethers.formatUnits(d3RollingBefore18, 18)} TRAC`, + `\n ✅ total reward = ${ethers.formatUnits(expectedStakeIncrease18, 18)} TRAC`, + ); + console.log( + ` ✅ new D3 stakeBase = ${ethers.formatUnits(d3BaseAfter18, 18)} TRAC`, + `\n ✅ rolling(after) = ${ethers.formatUnits(d3RollingAfter18, 18)} TRAC`, + `\n ✅ lastClaimedEpoch = ${d3LastClaimedAfter18}\n`, + ); + + /********************************************************************** + * STEP 19 – Delegator 3 requests withdrawal of 10 000 TRAC * + **********************************************************************/ + console.log('\n📤 STEP 19: Delegator3 requests withdrawal of 10 000 TRAC'); + + /* ---------- BEFORE snapshot -------------------------------------- */ + const d3StakeBaseBefore19 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d3Key); + const nodeStakeBefore19 = + await contracts.stakingStorage.getNodeStake(node1Id); + + // latest epoch (== 4) + const currentEpoch19 = await contracts.chronos.getCurrentEpoch(); + console.log(` ℹ️ current epoch = ${currentEpoch19}`); + + const scorePerStakeCur19 = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + currentEpoch19, + node1Id, + ); + const d3LastSettledBefore19 = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + currentEpoch19, + node1Id, + d3Key, + ); + const d3ScoreBefore19 = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + currentEpoch19, + node1Id, + d3Key, + ); + + /* how much score will be lazily settled by _prepareForStakeChange() */ + const expectedScoreInc19 = calculateExpectedDelegatorScore( + d3StakeBaseBefore19, + scorePerStakeCur19, + d3LastSettledBefore19, + ); + + /* ---------- perform withdrawal request --------------------------- */ + await contracts.staking + .connect(accounts.delegator3) + .requestWithdrawal(node1Id, TEN_K); // TEN_K = 10 000 TRAC + + /* ---------- AFTER snapshot --------------------------------------- */ + const d3StakeBaseAfter19 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d3Key); + const nodeStakeAfter19 = + await contracts.stakingStorage.getNodeStake(node1Id); + + const d3ScoreAfter19 = + await contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + currentEpoch19, + node1Id, + d3Key, + ); + const d3LastSettledAfter19 = + await contracts.randomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + currentEpoch19, + node1Id, + d3Key, + ); + + const [withdrawAmount19] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d3Key, + ); + + /* ---------- Assertions ------------------------------------------- */ + expect(withdrawAmount19).to.equal( + TEN_K, + 'withdrawal request amount mismatch', + ); + + expect(nodeStakeAfter19).to.equal( + nodeStakeBefore19 - TEN_K, + 'node total stake should fall by 10 000 TRAC', + ); + expect(d3StakeBaseAfter19).to.equal( + d3StakeBaseBefore19 - TEN_K, + 'delegator base stake should fall by 10 000 TRAC', + ); + + expect(d3ScoreAfter19).to.equal( + d3ScoreBefore19 + expectedScoreInc19, + 'delegator score must be lazily settled before stake change', + ); + expect(d3LastSettledAfter19).to.equal( + scorePerStakeCur19, + 'lastSettled index must be bumped to current nodeScorePerStake', + ); + + /* ---------- Console summary -------------------------------------- */ + console.log( + ` ✅ withdrawal request stored (${ethers.formatUnits(withdrawAmount19, 18)} TRAC)`, + ); + console.log( + ` ✅ node stake ${ethers.formatUnits(nodeStakeBefore19, 18)} → ${ethers.formatUnits(nodeStakeAfter19, 18)} TRAC`, + ); + console.log( + ` ✅ D3 stakeBase ${ethers.formatUnits(d3StakeBaseBefore19, 18)} → ${ethers.formatUnits(d3StakeBaseAfter19, 18)} TRAC`, + ); + console.log( + ` ✅ D3 epoch-score ${d3ScoreBefore19} → ${d3ScoreAfter19} (settled +${expectedScoreInc19})`, + ); + + /********************************************************************** + * STEP 20 – Jump to epoch-5 ➜ finalise withdrawal of 10 000 TRAC + **********************************************************************/ + console.log( + '\n⏭️ STEP 20: Node 1 Submit Proof for epoch-4, Jump to epoch-5 so epoch-4 is finalised and D3 finalises withdrawal', + ); + + await advanceToNextProofingPeriod(contracts); + + // 2. take a stake snapshot (needed by the helper that double-checks maths) + const stakeSnapshot = await contracts.stakingStorage.getNodeStake(node1Id); + + // 3. have node-1 submit one more proof for *epoch-4* + await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + currentEpoch19, // <- epoch-4 + stakeSnapshot, + ); + + /* 1️⃣ → epoch-5 */ + const ttn = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); // epoch 5 + + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'finalise-epoch4', + 1, // holders + chunkSize * 2, // byteSize - use multiple of chunkSize for proper chunk generation + 1, // replicas + toTRAC(1), // + ); + + expect(await contracts.epochStorage.lastFinalizedEpoch(1)).to.equal( + 4n, + 'Epoch-4 should now be finalised', + ); + + const epoch5 = await contracts.chronos.getCurrentEpoch(); // == 5 + console.log(` ✅ Now in epoch ${epoch5} (epoch-4 finalised)`); + expect(epoch5).to.equal(5n); + + const epoc4 = 4n; + + const netNodeRewards = await contracts.stakingKPI.getNetNodeRewards( + node1Id, + epoc4, + ); + const allDelegatorsRewards = + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + epoc4, + accounts.delegator1.address, + )) + + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + epoc4, + accounts.delegator2.address, + )) + + (await contracts.stakingKPI.getDelegatorReward( + node1Id, + epoc4, + accounts.delegator3.address, + )); + + await epochRewardsPoolPrecisionLoss( + contracts, + epoc4, + netNodeRewards, + allDelegatorsRewards, + ); + + /* 3️⃣ Make sure the withdrawal delay elapsed */ + const [pending20, , releaseTs20] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d3Key, + ); + + expect(pending20).to.equal(TEN_K, 'pending amount mismatch'); + + const now20 = BigInt(await time.latest()); + if (now20 < releaseTs20) await time.increase(releaseTs20 - now20 + 1n); + + /* 4️⃣ BEFORE snapshot */ + const balBefore20 = await contracts.token.balanceOf(accounts.delegator3); + const nodeStakeBefore20 = + await contracts.stakingStorage.getNodeStake(node1Id); + + /* 5️⃣ Finalise withdrawal */ + await contracts.staking + .connect(accounts.delegator3) + .finalizeWithdrawal(node1Id); + + /* 6️⃣ AFTER snapshot & asserts */ + const balAfter20 = await contracts.token.balanceOf(accounts.delegator3); + const nodeStakeAfter20 = + await contracts.stakingStorage.getNodeStake(node1Id); + const [reqAfter20] = + await contracts.stakingStorage.getDelegatorWithdrawalRequest( + node1Id, + d3Key, + ); + + expect(balAfter20 - balBefore20).to.equal(TEN_K, 'wallet diff'); + expect(nodeStakeAfter20).to.equal( + nodeStakeBefore20, + 'node stake invariant', + ); + expect(reqAfter20).to.equal(0n, 'request must be cleared'); + + console.log( + ` 🪙 +${ethers.formatUnits(TEN_K, 18)} TRAC to Delegator3 – withdrawal finalised`, + ); + + /********************************************************************** + * STEP 21 – Delegator 1 tries to stake extra 5 000 TRAC (★ must revert) + **********************************************************************/ + console.log( + '\n⛔ STEP 21: Delegator1 attempts to stake 5 000 TRAC – should revert', + ); + + /* ---------- context info ---------------------------------------- */ + const currentEpoch21 = await contracts.chronos.getCurrentEpoch(); // == epoch5 + const d1LastClaimed21 = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator1.address, + ); + console.log( + ` ℹ️ currentEpoch = ${currentEpoch21}, D1.lastClaimedEpoch = ${d1LastClaimed21}`, + ); + + // D1 has NOT yet claimed epoch 3 (and 4) → stake change must fail + + /* ---------- BEFORE snapshot ------------------------------------- */ + const d1StakeBaseBefore21 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + const nodeStakeBefore21 = + await contracts.stakingStorage.getNodeStake(node1Id); + + /* ---------- token approval -------------------------------------- */ + await contracts.token + .connect(accounts.delegator1) + .approve(await contracts.staking.getAddress(), toTRAC18(5_000)); + + /* ---------- stake tx (expect revert) ---------------------------- */ + await expect( + contracts.staking + .connect(accounts.delegator1) + .stake(node1Id, toTRAC18(5_000)), + ).to.be.revertedWith( + 'Must claim the previous epoch rewards before changing stake', + ); + + console.log( + ' ✅ Revert received – Delegator1 must first claim epoch 4 rewards', + ); + + /* ---------- AFTER snapshot -------------------------------------- */ + const d1StakeBaseAfter21 = + await contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key); + const nodeStakeAfter21 = + await contracts.stakingStorage.getNodeStake(node1Id); + + /* ---------- invariants ----------------------------------------- */ + expect(d1StakeBaseAfter21, 'D1.stakeBase should stay unchanged').to.equal( + d1StakeBaseBefore21, + ); + expect(nodeStakeAfter21, 'Node total stake should stay unchanged').to.equal( + nodeStakeBefore21, + ); + + /* ---------- console summary ------------------------------------ */ + console.log( + ` ❌ Stake blocked – D1 must claim rewards first`, + `\n ✅ D1.stakeBase remains ${ethers.formatUnits(d1StakeBaseAfter21, 18)} TRAC`, + `\n ✅ Node1.totalStake remains ${ethers.formatUnits(nodeStakeAfter21, 18)} TRAC\n`, + ); + }); + + /* ------------------------------------------------------------------ + * STEP A (Claim, Redelegate, Proof) + * ------------------------------------------------------------------ */ + it('Redelegate steps – Step A (D1 claims, redelegates N1->N2, then N1 submits proof)', async function () { + /* ------------------------------------------------------------------ + * 1. PRE-CONDITION: CLAIM PENDING REWARDS + * ------------------------------------------------------------------ */ + console.log( + '\n⏳ STEP A.1: Delegator1 claiming pending rewards for epoch 4...', + ); + + // From previous tests, we know epoch 4 is the last finalized one, + // and D1's last claim was for epoch 2. So, epochs 3 and 4 are pending. + + await contracts.staking + .connect(accounts.delegator1) + .claimDelegatorRewards(node1Id, 4n, accounts.delegator1.address); + + const d1LastClaimed = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + accounts.delegator1.address, + ); + expect(d1LastClaimed).to.be.gte( + 4n, + 'Delegator1 should have claimed all pending rewards up to epoch 4', + ); + console.log( + ` ✅ Pending rewards claimed. D1 last claimed epoch is now ${d1LastClaimed}.`, + ); + + /* ------------------------------------------------------------------ + * 2. REDELEGATE N1 -> N2 (with checks and logs) + * ------------------------------------------------------------------ */ + console.log( + '\n✈️ STEP A.2: Delegator1 redelegating from Node1 to Node2...', + ); + + // Snapshot BEFORE + const stakeToMove = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const n1StakeBefore = await contracts.stakingStorage.getNodeStake(node1Id); + const n2StakeBefore = await contracts.stakingStorage.getNodeStake( + nodeIds.node2Id, + ); + console.log( + ` [BEFORE] N1.total=${ethers.formatUnits( + n1StakeBefore, + 18, + )} | N2.total=${ethers.formatUnits( + n2StakeBefore, + 18, + )} | D1.stake=${ethers.formatUnits(stakeToMove, 18)}`, + ); + + // Perform Redelegate + await contracts.staking + .connect(accounts.delegator1) + .redelegate(node1Id, nodeIds.node2Id, stakeToMove); + + // Snapshot AFTER + const n1StakeAfter = await contracts.stakingStorage.getNodeStake(node1Id); + const n2StakeAfter = await contracts.stakingStorage.getNodeStake( + nodeIds.node2Id, + ); + const d1BaseN1 = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const d1BaseN2 = await contracts.stakingStorage.getDelegatorStakeBase( + nodeIds.node2Id, + d1Key, + ); + const d1StillOnN1 = await contracts.delegatorsInfo.isNodeDelegator( + node1Id, + accounts.delegator1.address, + ); + const d1OnN2 = await contracts.delegatorsInfo.isNodeDelegator( + nodeIds.node2Id, + accounts.delegator1.address, + ); + + console.log( + ` [AFTER] N1.total=${ethers.formatUnits( + n1StakeAfter, + 18, + )} | N2.total=${ethers.formatUnits( + n2StakeAfter, + 18, + )} | D1.base(N1)=${d1BaseN1} | D1.base(N2)=${d1BaseN2}`, + ); + + // Assertions + expect(d1BaseN1).to.equal(0n, 'D1 should have 0 stake on N1'); + expect(d1BaseN2).to.equal(stakeToMove, 'Stake should be moved to N2'); + expect(n1StakeAfter).to.equal(n1StakeBefore - stakeToMove); + expect(n2StakeAfter).to.equal(n2StakeBefore + stakeToMove); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(d1StillOnN1).to.be.false; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(d1OnN2).to.be.true; + + // Log the crucial state for debugging Step B + const lastStakeHeldEpochN1 = + await contracts.delegatorsInfo.getLastStakeHeldEpoch( + node1Id, + accounts.delegator1.address, + ); + console.log( + ` [DEBUG] D1 on N1: isDelegator=${d1StillOnN1}, lastStakeHeldEpoch=${lastStakeHeldEpochN1}`, + ); + + console.log(' ✅ Redelegation successful.'); + + /* ------------------------------------------------------------------ + * 3. NODE 1 SUBMITS PROOF + * ------------------------------------------------------------------ */ + console.log('\n🔬 STEP A.3: Node1 submitting proof for current epoch...'); + const curEpoch = await contracts.chronos.getCurrentEpoch(); // Should be epoch 5 + expect(curEpoch).to.equal(5n); + + await advanceToNextProofingPeriod(contracts); + + await ensureNodeHasChunksThisEpoch( + node1Id, + accounts.node1, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + + const n1StakeNow = await contracts.stakingStorage.getNodeStake(node1Id); + await submitProofAndVerifyScore( + node1Id, + accounts.node1, + contracts, + curEpoch, + n1StakeNow, + ); + console.log(' ✅ Node1 proof submitted.'); + + console.log( + ` [DEBUG2] D1 on N1: isDelegator=${d1StillOnN1}, lastStakeHeldEpoch=${lastStakeHeldEpochN1}`, + ); + + /* ------------------------------------------------------------------ + * 4. ADVANCE TO NEXT EPOCH + * ------------------------------------------------------------------ */ + console.log('\n⏭️ STEP A.4: Advancing to the next epoch...'); + const ttn5 = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(ttn5 + 1n); // → epoch-6 + const epoch6 = await contracts.chronos.getCurrentEpoch(); + + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node2, + Number(nodeIds.node2Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'test-op-id-node2-proof-stepA4', + 10, + chunkSize * 8, // byteSize - use multiple of chunkSize for proper chunk generation + 10, + toTRAC(1000), + ); + + /* Verify epoch-5 is now finalised so its rewards can be claimed */ + expect( + await contracts.epochStorage.lastFinalizedEpoch(1), + 'epoch-5 should now be finalised', + ).to.equal(5n); + + expect(epoch6).to.equal(6n); + console.log(` ✅ Advanced to epoch ${epoch6}.`); + }); + + /* ------------------------------------------------------------------ + * STEP B – redelegate all stake N2 → N1 + * ------------------------------------------------------------------ */ + it('Redelegate steps – Step B (N2 → N1)', async function () { + /* ──────────────── 1. PREPARATION & INITIAL STATE ───────────────── */ + const epoch = await contracts.chronos.getCurrentEpoch(); + console.log(`\n\n--- STEP B: Redelegate N2 -> N1 (Epoch ${epoch}) ---`); + + const d1isDelegatorN2_before = + await contracts.delegatorsInfo.isNodeDelegator( + nodeIds.node2Id, + accounts.delegator1.address, + ); + const d1LastStakeHeldN2_before = + await contracts.delegatorsInfo.getLastStakeHeldEpoch( + nodeIds.node2Id, + accounts.delegator1.address, + ); + console.log( + `🔎 [B.1] Initial D1 on N2: isDelegator=${d1isDelegatorN2_before}, lastStakeHeldEpoch=${d1LastStakeHeldN2_before}`, + ); + + const d1BaseN2_before = + await contracts.stakingStorage.getDelegatorStakeBase( + nodeIds.node2Id, + d1Key, + ); + expect( + d1BaseN2_before, + 'D1 must have stake on N2 to start Step B', + ).to.be.gt(0n); + + /* ──────────────── 2. NODE-2 SUBMITS PROOF ───────── */ + console.log(`🔬 [B.2] Node2 submitting proof...`); + + await advanceToNextProofingPeriod(contracts); + + await ensureNodeHasChunksThisEpoch( + nodeIds.node2Id, + accounts.node2, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + + const n2Stake_beforeProof = await contracts.stakingStorage.getNodeStake( + nodeIds.node2Id, + ); + await submitProofAndVerifyScore( + nodeIds.node2Id, + accounts.node2, + contracts, + epoch, + n2Stake_beforeProof, + ); + console.log(` ✅ Node2 proof submitted.`); + + /* ──────────────── 3. REDELEGATE N2 → N1 ─────────── */ + console.log(`✈️ [B.3] D1 redelegating all stake from N2 to N1...`); + const n1Stake_beforeRedelegate = + await contracts.stakingStorage.getNodeStake(node1Id); + await contracts.staking + .connect(accounts.delegator1) + .redelegate(nodeIds.node2Id, node1Id, d1BaseN2_before); + console.log(' ✅ Redelegation transaction sent.'); + + /* ──────────────── 4. POST-SNAPSHOT & ASSERTIONS ──────────────── */ + console.log(`🔎 [B.4] Final State & Assertions...`); + + const [ + d1BaseN2_after, + d1BaseN1_after, + n2Stake_after, + n1Stake_after, + stillDelegatorOnN2, + lastStakeHeldEpochN2, + ] = await Promise.all([ + contracts.stakingStorage.getDelegatorStakeBase(nodeIds.node2Id, d1Key), + contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key), + contracts.stakingStorage.getNodeStake(nodeIds.node2Id), + contracts.stakingStorage.getNodeStake(node1Id), + contracts.delegatorsInfo.isNodeDelegator( + nodeIds.node2Id, + accounts.delegator1.address, + ), + contracts.delegatorsInfo.getLastStakeHeldEpoch( + nodeIds.node2Id, + accounts.delegator1.address, + ), + ]); + + console.log( + ` - Final D1 on N2: isDelegator=${stillDelegatorOnN2}, lastStakeHeldEpoch=${lastStakeHeldEpochN2}`, + ); + + expect(d1BaseN2_after, 'D1 stake on N2 should now be zero').to.equal(0n); + expect(d1BaseN1_after, 'Stake must fully move to N1').to.equal( + d1BaseN2_before, + ); + expect(n2Stake_after).to.equal( + n2Stake_beforeProof - d1BaseN2_before, + 'N2 total stake should decrease by the redelegated amount', + ); + expect(n1Stake_after).to.equal( + n1Stake_beforeRedelegate + d1BaseN2_before, + 'N1 total stake should increase by the redelegated amount', + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(stillDelegatorOnN2, 'D1 must remain delegator on N2').to.be.true; + expect( + lastStakeHeldEpochN2, + 'lastStakeHeldEpoch mismatch, should be set to current epoch', + ).to.equal(epoch); + }); + + /** + * STEP C – Move to the next epoch, explicitly call + * _validateDelegatorEpochClaims twice (N1 ✓, N2 ✗), + * then try the real redelegate which must revert. + */ + it('STEP C – validate twice, cancelWithdrawal, then failed redelegate', async function () { + /* ────────────────────────────────────────────────────────────── + * 1️⃣ Advance exactly one epoch forward + * (make the test independent of the absolute epoch number) + * ────────────────────────────────────────────────────────────── */ + const beforeEpoch = await contracts.chronos.getCurrentEpoch(); + const ttn = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); // → +1 epoch + const afterEpoch = await contracts.chronos.getCurrentEpoch(); + + expect(afterEpoch).to.equal( + beforeEpoch + 1n, + 'Epoch did not advance by exactly one', + ); + console.log(`\n🚦 STEP C: now in epoch ${afterEpoch}`); + + /* ---------------------------------------------------------------- + * 1-b) Finalise the *previous* epoch by creating a tiny KC + * (prevents "epoch not finalised" surprises in later claims) + * ---------------------------------------------------------------- */ + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, // any node is fine – we use N1 + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'finalise-stepC', + 1, // holders + chunkSize * 2, // byteSize - use multiple of chunkSize for proper chunk generation + 1, // replicas + toTRAC(1), // 1 TRAC fee – enough to finalise + ); + + expect( + await contracts.epochStorage.lastFinalizedEpoch(1), + 'Previous epoch should now be finalised', + ).to.be.gte(afterEpoch - 1n); + + /* ---------------------------------------------------------------- + * Helper – current Delegator-1 stake on N1 (used later) + * ---------------------------------------------------------------- */ + const stakeN1_start = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + + /* ────────────────────────────────────────────────────────────── + * 2️⃣ Dry-run the internal validator through callStatic + * ────────────────────────────────────────────────────────────── */ + console.log('\n🔍 Manual _validateDelegatorEpochClaims checks…'); + + // 2-a) N1 – should **pass** + await expect( + contracts.staking + .connect(accounts.delegator1) + .requestWithdrawal.staticCall(node1Id, 1n), // 1 wei is enough + ).to.not.be.reverted; + console.log(' ✅ Validation on N1 passed'); + + // Make a real 1-wei withdrawal so we can cancel it immediately + await contracts.staking + .connect(accounts.delegator1) + .requestWithdrawal(node1Id, 1n); + await contracts.staking + .connect(accounts.delegator1) + .cancelWithdrawal(node1Id); + console.log(' ↩️ requestWithdrawal + cancelWithdrawal on N1 succeeded'); + + // 2-b) N2 – must **revert** + await expect( + contracts.staking + .connect(accounts.delegator1) + .requestWithdrawal.staticCall(nodeIds.node2Id, 1n), + ).to.be.revertedWith( + 'Must claim rewards up to the lastStakeHeldEpoch before changing stake', + ); + console.log(' ✅ Validation on N2 reverted as expected'); + + /* ────────────────────────────────────────────────────────────── + * 3️⃣ Attempt a real redelegate N1 ➜ N2 – must revert + * ────────────────────────────────────────────────────────────── */ + const halfStake = stakeN1_start / 2n; + console.log( + `\n↪️ Attempting to redelegate ${ethers.formatUnits(halfStake, 18)} TRAC N1 ➜ N2`, + ); + + await expect( + contracts.staking + .connect(accounts.delegator1) + .redelegate(node1Id, nodeIds.node2Id, halfStake), + ).to.be.revertedWith( + 'Must claim rewards up to the lastStakeHeldEpoch before changing stake', + ); + console.log(' ✅ Redelegate reverted – pending N2 rewards not claimed'); + + /* ────────────────────────────────────────────────────────────── + * 4️⃣ Sanity-check – stake amounts must be unchanged + * ────────────────────────────────────────────────────────────── */ + const stakeN1_end = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const stakeN2_end = await contracts.stakingStorage.getDelegatorStakeBase( + nodeIds.node2Id, + d1Key, + ); + + expect(stakeN1_end).to.equal( + stakeN1_start, + 'Stake on N1 must remain unchanged', + ); + expect(stakeN2_end).to.equal(0n, 'Stake on N2 must remain zero'); + + console.log( + ` ✅ State unchanged → N1: ${ethers.formatUnits(stakeN1_end, 18)} TRAC | ` + + `N2: ${ethers.formatUnits(stakeN2_end, 18)} TRAC`, + ); + console.log(`\n🚦 STEP C: now in epoch ${afterEpoch}`); + }); + + /****************************************************************************************** + * STEP D – two un-claimed epochs, claim one, redelegate half, check rolling + /* ------------------------------------------------------------------ + * STEP D – epoch-8: claim epoch-6 on N2 (→ goes to rollingRewards), + * redelegate half of live stake N1 → N2, verify state + * ------------------------------------------------------------------ */ + it('STEP D – claim one on N2, redelegate half, check rolling', async function () { + const delegator = accounts.delegator1; + const fmt = (x: bigint) => ethers.formatUnits(x, 18); + + /* ── 0. Move to epoch-8 and finalise epoch-7 ────────────────────── */ + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); // → 8 + const epoch8 = await contracts.chronos.getCurrentEpoch(); + + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + Number(node1Id), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + 'finalise-ep7', + 1, + chunkSize * 2, // byteSize - use multiple of chunkSize for proper chunk generation + 1, + toTRAC(1), + ); + expect(await contracts.epochStorage.lastFinalizedEpoch(1)).to.be.gte(7n); + + console.log( + '\n────────────── STEP D – STATE BEFORE ACTIONS ──────────────', + ); + console.log(`[D-0] Current epoch: ${epoch8}`); + + /* ── 1. Quick sanity check for claimable epochs ────────────────── */ + const lastClaimedN1 = await contracts.delegatorsInfo.getLastClaimedEpoch( + node1Id, + delegator.address, + ); // 6 + const lastClaimedN2 = await contracts.delegatorsInfo.getLastClaimedEpoch( + nodeIds.node2Id, + delegator.address, + ); // 5 + const lastStakeHeldN2 = + await contracts.delegatorsInfo.getLastStakeHeldEpoch( + nodeIds.node2Id, + delegator.address, + ); // 6 + + console.log(`[D-1] N1.lastClaimed = ${lastClaimedN1}`); + console.log(`[D-1] N2.lastClaimed = ${lastClaimedN2}`); + console.log(`[D-1] N2.lastStakeHeldEpoch = ${lastStakeHeldN2}`); + + // exactly one claimable epoch on N2 → epoch-6 + expect(lastClaimedN2 + 1n).to.equal(lastStakeHeldN2); + expect(epoch8 - lastClaimedN2).to.equal(3n); // epochs 6-8 + + /* ── 2. Claim epoch-6 on N2 (gap = 2 ⇒ reward → rollingRewards) ── */ + const [baseN2_before, rollingN2_before, nodeScore6, delegScore6, pool6] = + await Promise.all([ + contracts.stakingStorage.getDelegatorStakeBase(nodeIds.node2Id, d1Key), + contracts.delegatorsInfo.getDelegatorRollingRewards( + nodeIds.node2Id, + delegator.address, + ), + contracts.randomSamplingStorage.getNodeEpochScore(6n, nodeIds.node2Id), + contracts.randomSamplingStorage.getEpochNodeDelegatorScore( + 6n, + nodeIds.node2Id, + d1Key, + ), + contracts.stakingKPI.getNetNodeRewards(nodeIds.node2Id, 6n), + ]); + const expectedReward6 = + nodeScore6 === 0n ? 0n : (delegScore6 * pool6) / nodeScore6; + + console.log('\n[D-2] BEFORE claim epoch-6 on N2'); + console.log(` baseN2 : ${fmt(baseN2_before)} TRAC`); + console.log(` rollingN2 : ${fmt(rollingN2_before)} TRAC`); + console.log(` expectedReward: ${fmt(expectedReward6)} TRAC`); + + await contracts.staking + .connect(delegator) + .claimDelegatorRewards(nodeIds.node2Id, 6n, delegator.address); + + const [baseN2_after, rollingN2_after, lastClaimedN2_after] = + await Promise.all([ + contracts.stakingStorage.getDelegatorStakeBase(nodeIds.node2Id, d1Key), + contracts.delegatorsInfo.getDelegatorRollingRewards( + nodeIds.node2Id, + delegator.address, + ), + contracts.delegatorsInfo.getLastClaimedEpoch( + nodeIds.node2Id, + delegator.address, + ), + ]); + + console.log('\n[D-2] AFTER claim epoch-6 on N2'); + console.log(` baseN2 : ${fmt(baseN2_after)} TRAC`); + console.log(` rollingN2 : ${fmt(rollingN2_after)} TRAC`); + console.log(` lastClaimedN2 : ${lastClaimedN2_after}`); + + // reward should sit in rollingRewards, stake stays unchanged + expect(baseN2_after).to.equal(baseN2_before, 'base stake unchanged'); + expect(rollingN2_after - rollingN2_before).to.equal( + expectedReward6, + 'rolling diff', + ); + expect(lastClaimedN2_after).to.equal(6n); + + /* ── 3. Redelegate half of live stake N1 → N2 ─────────────────── */ + const baseN1_before = await contracts.stakingStorage.getDelegatorStakeBase( + node1Id, + d1Key, + ); + const halfStake = baseN1_before / 2n; + + const [n1Total_before, n2Total_before] = await Promise.all([ + contracts.stakingStorage.getNodeStake(node1Id), + contracts.stakingStorage.getNodeStake(nodeIds.node2Id), + ]); + + console.log('\n[D-3] BEFORE redelegate'); + console.log(` baseN1 : ${fmt(baseN1_before)} TRAC`); + console.log(` baseN2 : ${fmt(baseN2_after)} TRAC`); + console.log(` halfStake : ${fmt(halfStake)} TRAC`); + + await contracts.staking + .connect(delegator) + .redelegate(node1Id, nodeIds.node2Id, halfStake); + + /* ── 4. Post-redelegate assertions & logs ──────────────────────── */ + const [ + baseN1_after, + baseN2_final, + n1Total_after, + n2Total_after, + rollingN1_final, + rollingN2_final, + ] = await Promise.all([ + contracts.stakingStorage.getDelegatorStakeBase(node1Id, d1Key), + contracts.stakingStorage.getDelegatorStakeBase(nodeIds.node2Id, d1Key), + contracts.stakingStorage.getNodeStake(node1Id), + contracts.stakingStorage.getNodeStake(nodeIds.node2Id), + contracts.delegatorsInfo.getDelegatorRollingRewards( + node1Id, + delegator.address, + ), + contracts.delegatorsInfo.getDelegatorRollingRewards( + nodeIds.node2Id, + delegator.address, + ), + ]); + + console.log('\n[D-4] AFTER redelegate'); + console.log(` baseN1 : ${fmt(baseN1_after)} TRAC`); + console.log(` baseN2 : ${fmt(baseN2_final)} TRAC`); + console.log( + ` N1 total stake: ${fmt(n1Total_before)} ➜ ${fmt(n1Total_after)} TRAC`, + ); + console.log( + ` N2 total stake: ${fmt(n2Total_before)} ➜ ${fmt(n2Total_after)} TRAC`, + ); + console.log(` rollingN1 : ${fmt(rollingN1_final)} TRAC`); + console.log(` rollingN2 : ${fmt(rollingN2_final)} TRAC\n`); + + // stake balances + expect(baseN1_after).to.equal(baseN1_before - halfStake); + expect(baseN2_final).to.equal(baseN2_after + halfStake); + expect(n1Total_after).to.equal(n1Total_before - halfStake); + expect(n2Total_after).to.equal(n2Total_before + halfStake); + + // rollingRewards must stay the same after redelegate + expect(rollingN2_final).to.equal( + rollingN2_after, + 'rolling on N2 unchanged', + ); + expect(rollingN1_final).to.equal(0n, 'rolling on N1 remains zero'); + + console.log( + ` ✔ Redelegate OK – N1:${fmt(baseN1_after)} | N2:${fmt(baseN2_final)} TRAC`, + ); + }); +}); diff --git a/test/integration/Staking.test.ts b/test/integration/Staking.test.ts new file mode 100644 index 00000000..c4e940db --- /dev/null +++ b/test/integration/Staking.test.ts @@ -0,0 +1,3083 @@ +// test/rewards.initial-state.spec.ts +// @ts-nocheck +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import hre from 'hardhat'; +import { randomBytes } from 'crypto'; +import { time } from '@nomicfoundation/hardhat-network-helpers'; +import { expect } from 'chai'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; + +import { + Token, + Profile, + ProfileStorage, + Staking, + Chronos, + RandomSamplingStorage, + EpochStorage, + KnowledgeCollection, + Hub, + StakingStorage, + RandomSampling, + Ask, + AskStorage, + ParametersStorage, + DelegatorsInfo, +} from '../../typechain'; +import { createKnowledgeCollection } from '../helpers/kc-helpers'; +import { createProfile } from '../helpers/profile-helpers'; + +/* ────────────────────────── helpers ────────────────────────── */ + +const toTRAC = (x: number) => hre.ethers.parseEther(x.toString()); + +// Sample data for KC (copied from full scenario) +const quads = [ + ' "468.9 sq mi" .', + ' "New York" .', + ' "8,336,817" .', + ' "New York" .', + ' .', + // Add more quads to ensure we have enough chunks + ...Array(1000).fill( + ' .', + ), +]; + +// Helper function to ensure node has chunks and submit proof +async function ensureNodeHasChunksThisEpoch( + nodeId: number, + node: { operational: SignerWithAddress; admin: SignerWithAddress }, + contracts: any, + accounts: any, + receivingNodes: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }[], + receivingNodesIdentityIds: number[], + chunkSize: number, +): Promise { + const produced = + await contracts.epochStorage.getNodeCurrentEpochProducedKnowledgeValue( + nodeId, + ); + + if (produced === 0n) { + if ( + !receivingNodes.some( + (r) => r.operational.address === node.operational.address, + ) + ) { + receivingNodes.unshift(node); + receivingNodesIdentityIds.unshift(Number(nodeId)); + } + + const { kcTools } = await import('assertion-tools'); + const merkleRoot = kcTools.calculateMerkleRoot(quads, 32); + + await createKnowledgeCollection( + node.operational, // signer = node.operational + node, // publisher-node + Number(nodeId), + receivingNodes, + receivingNodesIdentityIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + `ensure-chunks-${Date.now()}`, + 1, // knowledgeAssetsAmount + chunkSize, // byteSize - must be >= CHUNK_BYTE_SIZE to avoid division by zero + 1, // epochs + toTRAC(1), + ); + + await contracts.randomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + } +} + +// Helper function to advance to next proofing period +async function advanceToNextProofingPeriod(contracts: any): Promise { + const proofingPeriodDuration = + await contracts.randomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + const { activeProofPeriodStartBlock, isValid } = + await contracts.randomSamplingStorage.getActiveProofPeriodStatus(); + if (isValid) { + // Find out how many blocks are left in the current proofing period + const blocksLeft = + Number(activeProofPeriodStartBlock) + + Number(proofingPeriodDuration) - + Number(await hre.network.provider.send('eth_blockNumber')) + + 1; + for (let i = 0; i < blocksLeft; i++) { + await hre.network.provider.send('evm_mine'); + } + } + await contracts.randomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); +} + +// Helper function to submit proof and log scores +async function submitProofAndLogScore( + nodeId: number, + nodeAccount: { operational: SignerWithAddress; admin: SignerWithAddress }, + contracts: any, + epoch: bigint, + nodeName: string, +) { + // Get score before proof + const scoreBefore = await contracts.randomSamplingStorage.getNodeEpochScore( + epoch, + nodeId, + ); + + // Create challenge and submit proof + await contracts.randomSampling + .connect(nodeAccount.operational) + .createChallenge(); + const challenge = + await contracts.randomSamplingStorage.getNodeChallenge(nodeId); + + // Calculate merkle proof for the challenge + const { kcTools } = await import('assertion-tools'); + const chunks = kcTools.splitIntoChunks(quads, 32); + const chunkId = Number(challenge[1]); + const { proof } = kcTools.calculateMerkleProof(quads, 32, chunkId); + + await contracts.randomSampling + .connect(nodeAccount.operational) + .submitProof(chunks[chunkId], proof); + + // Get score after proof + const scoreAfter = await contracts.randomSamplingStorage.getNodeEpochScore( + epoch, + nodeId, + ); + const scorePerStake = + await contracts.randomSamplingStorage.getNodeEpochScorePerStake( + epoch, + nodeId, + ); + + return { scoreBefore, scoreAfter, scorePerStake }; +} + +/* ───────────────────── fixture: build initial state ───────────────────── */ + +export async function buildInitialRewardsState() { + await hre.deployments.fixture(); + + const signers = await hre.ethers.getSigners(); + + const contracts = { + hub: await hre.ethers.getContract('Hub'), + token: await hre.ethers.getContract('Token'), + chronos: await hre.ethers.getContract('Chronos'), + profile: await hre.ethers.getContract('Profile'), + staking: await hre.ethers.getContract('Staking'), + stakingStorage: + await hre.ethers.getContract('StakingStorage'), + delegatorsInfo: + await hre.ethers.getContract('DelegatorsInfo'), + randomSamplingStorage: await hre.ethers.getContract( + 'RandomSamplingStorage', + ), + randomSampling: + await hre.ethers.getContract('RandomSampling'), + epochStorage: await hre.ethers.getContract('EpochStorageV8'), + kc: await hre.ethers.getContract( + 'KnowledgeCollection', + ), + ask: await hre.ethers.getContract('Ask'), + askStorage: await hre.ethers.getContract('AskStorage'), + parametersStorage: + await hre.ethers.getContract('ParametersStorage'), + profileStorage: + await hre.ethers.getContract('ProfileStorage'), + }; + + // Get chunk size to avoid division by zero in challenge generation + const chunkSize = Number( + await contracts.randomSamplingStorage.CHUNK_BYTE_SIZE(), + ); + + const accounts = { + owner: signers[0], + // 4 nodes with separate operational and admin wallets + node1: { operational: signers[1], admin: signers[2] }, + node2: { operational: signers[3], admin: signers[4] }, + node3: { operational: signers[5], admin: signers[6] }, + node4: { operational: signers[7], admin: signers[8] }, + // 12 delegators now (need more for the new distribution) + delegators: signers.slice(10, 22), + kcCreator: signers[9], + }; + + // Create receiving nodes arrays for proof submissions (all nodes) + const receivingNodes = [ + accounts.node1, + accounts.node2, + accounts.node3, + accounts.node4, + ]; + const receivingNodesIdentityIds: number[] = []; + + await contracts.hub.setContractAddress('HubOwner', accounts.owner.address); + + // Initialize ask system to prevent division by zero + await contracts.parametersStorage.setMinimumStake(toTRAC(100)); + await contracts.parametersStorage + .connect(accounts.owner) + .setOperatorFeeUpdateDelay(0); + + // Mint tokens for all delegators + for (const delegator of accounts.delegators) { + await contracts.token.mint(delegator.address, toTRAC(1_000_000)); + } + await contracts.token.mint(accounts.kcCreator.address, toTRAC(1_000_000)); + + // Create node profiles + const { identityId: node1Id } = await createProfile( + contracts.profile, + accounts.node1, + ); + const { identityId: node2Id } = await createProfile( + contracts.profile, + accounts.node2, + ); + const { identityId: node3Id } = await createProfile( + contracts.profile, + accounts.node3, + ); + const { identityId: node4Id } = await createProfile( + contracts.profile, + accounts.node4, + ); + + // Set operator fees to 10% + await contracts.profile + .connect(accounts.node1.admin) + .updateOperatorFee(node1Id, 1000); + await contracts.profile + .connect(accounts.node2.admin) + .updateOperatorFee(node2Id, 1000); + await contracts.profile + .connect(accounts.node3.admin) + .updateOperatorFee(node3Id, 1000); + await contracts.profile + .connect(accounts.node4.admin) + .updateOperatorFee(node4Id, 1000); + + // Populate receiving nodes identity IDs + receivingNodesIdentityIds.push(node1Id, node2Id, node3Id, node4Id); + + // Initialize ask system for nodes + const nodeAsk = hre.ethers.parseUnits('0.2', 18); + await contracts.profile + .connect(accounts.node1.operational) + .updateAsk(node1Id, nodeAsk); + await contracts.profile + .connect(accounts.node2.operational) + .updateAsk(node2Id, nodeAsk); + await contracts.profile + .connect(accounts.node3.operational) + .updateAsk(node3Id, nodeAsk); + await contracts.profile + .connect(accounts.node4.operational) + .updateAsk(node4Id, nodeAsk); + await contracts.ask.connect(accounts.owner).recalculateActiveSet(); + + const nodes = [ + { + identityId: node1Id, + operational: accounts.node1.operational, + admin: accounts.node1.admin, + }, + { + identityId: node2Id, + operational: accounts.node2.operational, + admin: accounts.node2.admin, + }, + { + identityId: node3Id, + operational: accounts.node3.operational, + admin: accounts.node3.admin, + }, + { + identityId: node4Id, + operational: accounts.node4.operational, + admin: accounts.node4.admin, + }, + ]; + + // Jump to clean epoch start + const timeUntilNextEpoch = await contracts.chronos.timeUntilNextEpoch(); + await time.increase(timeUntilNextEpoch + 1n); + + // Fast-forward to epoch-2 + while ((await contracts.chronos.getCurrentEpoch()) < 2n) { + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + } + + // Create identical reward pools for epoch-2 (each node publishes same amount) + const kcTokenAmount = toTRAC(250); // Split total among 4 nodes + const numberOfEpochs = 5; + const { kcTools } = await import('assertion-tools'); + const merkleRoot = kcTools.calculateMerkleRoot(quads, 32); + + // Create identical KC for each node to ensure equal publishing values + for (let i = 0; i < nodes.length; i++) { + const publisherNode = nodes[i]; + const otherNodes = nodes.filter((_, idx) => idx !== i); + const otherNodeIds = otherNodes.map((n) => n.identityId); + + await createKnowledgeCollection( + accounts.kcCreator, + publisherNode, + publisherNode.identityId, + otherNodes, + otherNodeIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + `epoch-2-node-${i + 1}-kc`, + 3, // Same knowledge assets amount for all + chunkSize * 3, // Same byte size for all + numberOfEpochs, + kcTokenAmount, // Same token amount for all + ); + } + + // EPOCH-2 STAKES: + // Node-1: D1→10k, D2→20k + // Node-2: D3→10k, D4→20k (same pattern as Node-1) + console.log( + '\n╔══════════════════════════════════════════════════════════════════════════════════╗', + ); + console.log( + '║ EPOCH-2 STAKING ║', + ); + console.log( + '╠══════════════════════════════════════════════════════════════════════════════════╣', + ); + + // Node-1 delegators + await contracts.token + .connect(accounts.delegators[0]) + .approve(await contracts.staking.getAddress(), toTRAC(10_000)); + await contracts.staking + .connect(accounts.delegators[0]) + .stake(node1Id, toTRAC(10_000)); + console.log( + '║ 📍 D1 → 10,000 TRAC → Node-1 ║', + ); + + await contracts.token + .connect(accounts.delegators[1]) + .approve(await contracts.staking.getAddress(), toTRAC(20_000)); + await contracts.staking + .connect(accounts.delegators[1]) + .stake(node1Id, toTRAC(20_000)); + console.log( + '║ 📍 D2 → 20,000 TRAC → Node-1 ║', + ); + + // Node-2 delegators (same pattern) + await contracts.token + .connect(accounts.delegators[2]) + .approve(await contracts.staking.getAddress(), toTRAC(10_000)); + await contracts.staking + .connect(accounts.delegators[2]) + .stake(node2Id, toTRAC(10_000)); + console.log( + '║ 📍 D3 → 10,000 TRAC → Node-2 ║', + ); + + await contracts.token + .connect(accounts.delegators[3]) + .approve(await contracts.staking.getAddress(), toTRAC(20_000)); + await contracts.staking + .connect(accounts.delegators[3]) + .stake(node2Id, toTRAC(20_000)); + console.log( + '║ 📍 D4 → 20,000 TRAC → Node-2 ║', + ); + console.log( + '╚══════════════════════════════════════════════════════════════════════════════════╝', + ); + + // Submit proofs at end of epoch-2 + await advanceToNextProofingPeriod(contracts); + + // All nodes already have equal KC chunks from the identical KC creation above + // No need for ensureNodeHasChunksThisEpoch() since each node published identical KC + + console.log('\n🔬 EPOCH-2 PROOFS SUBMITTED:'); + const node1Proof2 = await submitProofAndLogScore( + node1Id, + accounts.node1, + contracts, + 2n, + 'Node-1', + ); + console.log( + ` ✅ Node-1: Score ${node1Proof2.scoreBefore} → ${node1Proof2.scoreAfter} (gain: ${node1Proof2.scoreAfter - node1Proof2.scoreBefore})`, + ); + + const node2Proof2 = await submitProofAndLogScore( + node2Id, + accounts.node2, + contracts, + 2n, + 'Node-2', + ); + console.log( + ` ✅ Node-2: Score ${node2Proof2.scoreBefore} → ${node2Proof2.scoreAfter} (gain: ${node2Proof2.scoreAfter - node2Proof2.scoreBefore})`, + ); + + const node3Proof2 = await submitProofAndLogScore( + node3Id, + accounts.node3, + contracts, + 2n, + 'Node-3', + ); + console.log( + ` ✅ Node-3: Score ${node3Proof2.scoreBefore} → ${node3Proof2.scoreAfter} (gain: ${node3Proof2.scoreAfter - node3Proof2.scoreBefore})`, + ); + + const node4Proof2 = await submitProofAndLogScore( + node4Id, + accounts.node4, + contracts, + 2n, + 'Node-4', + ); + console.log( + ` ✅ Node-4: Score ${node4Proof2.scoreBefore} → ${node4Proof2.scoreAfter} (gain: ${node4Proof2.scoreAfter - node4Proof2.scoreBefore})`, + ); + + // → EPOCH-3 + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + + // Create identical reward pools for epoch-3 (each node publishes same amount) + const kcTokenAmountEpoch3 = toTRAC(100); // Split total among 4 nodes + const numberOfEpochsEpoch3 = 1; + + // Create identical KC for each node to ensure equal publishing values + for (let i = 0; i < nodes.length; i++) { + const publisherNode = nodes[i]; + const otherNodes = nodes.filter((_, idx) => idx !== i); + const otherNodeIds = otherNodes.map((n) => n.identityId); + + await createKnowledgeCollection( + accounts.kcCreator, + publisherNode, + publisherNode.identityId, + otherNodes, + otherNodeIds, + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, + `epoch-3-node-${i + 1}-kc`, + 1, // Same knowledge assets amount for all + chunkSize * 5, // Same byte size for all + numberOfEpochsEpoch3, + kcTokenAmountEpoch3, // Same token amount for all + ); + } + + // EPOCH-3 STAKES: + // Node-1: D5→30k, D6→40k, D7→50k + // Node-2: D8→30k, D9→40k, D10→50k (same pattern as Node-1) + // Node-3: D11→60k, D12→50k (original Node-2 pattern from your request) + console.log( + '\n╔══════════════════════════════════════════════════════════════════════════════════╗', + ); + console.log( + '║ EPOCH-3 STAKING ║', + ); + console.log( + '╠══════════════════════════════════════════════════════════════════════════════════╣', + ); + + // Node-1 additional delegators + await contracts.token + .connect(accounts.delegators[4]) + .approve(await contracts.staking.getAddress(), toTRAC(30_000)); + await contracts.staking + .connect(accounts.delegators[4]) + .stake(node1Id, toTRAC(30_000)); + console.log( + '║ 📍 D5 → 30,000 TRAC → Node-1 ║', + ); + + await contracts.token + .connect(accounts.delegators[5]) + .approve(await contracts.staking.getAddress(), toTRAC(40_000)); + await contracts.staking + .connect(accounts.delegators[5]) + .stake(node1Id, toTRAC(40_000)); + console.log( + '║ 📍 D6 → 40,000 TRAC → Node-1 ║', + ); + + await contracts.token + .connect(accounts.delegators[6]) + .approve(await contracts.staking.getAddress(), toTRAC(50_000)); + await contracts.staking + .connect(accounts.delegators[6]) + .stake(node1Id, toTRAC(50_000)); + console.log( + '║ 📍 D7 → 50,000 TRAC → Node-1 ║', + ); + + // Node-2 additional delegators (same pattern as Node-1) + await contracts.token + .connect(accounts.delegators[7]) + .approve(await contracts.staking.getAddress(), toTRAC(30_000)); + await contracts.staking + .connect(accounts.delegators[7]) + .stake(node2Id, toTRAC(30_000)); + console.log( + '║ 📍 D8 → 30,000 TRAC → Node-2 ║', + ); + + await contracts.token + .connect(accounts.delegators[8]) + .approve(await contracts.staking.getAddress(), toTRAC(40_000)); + await contracts.staking + .connect(accounts.delegators[8]) + .stake(node2Id, toTRAC(40_000)); + console.log( + '║ 📍 D9 → 40,000 TRAC → Node-2 ║', + ); + + await contracts.token + .connect(accounts.delegators[9]) + .approve(await contracts.staking.getAddress(), toTRAC(50_000)); + await contracts.staking + .connect(accounts.delegators[9]) + .stake(node2Id, toTRAC(50_000)); + console.log( + '║ 📍 D10 → 50,000 TRAC → Node-2 ║', + ); + + // Node-3 delegators (your original Node-2 pattern) + await contracts.token + .connect(accounts.delegators[10]) + .approve(await contracts.staking.getAddress(), toTRAC(60_000)); + await contracts.staking + .connect(accounts.delegators[10]) + .stake(node3Id, toTRAC(60_000)); + console.log( + '║ 📍 D11 → 60,000 TRAC → Node-3 ║', + ); + + await contracts.token + .connect(accounts.delegators[11]) + .approve(await contracts.staking.getAddress(), toTRAC(50_000)); + await contracts.staking + .connect(accounts.delegators[11]) + .stake(node3Id, toTRAC(50_000)); + console.log( + '║ 📍 D12 → 50,000 TRAC → Node-3 ║', + ); + console.log( + '╚══════════════════════════════════════════════════════════════════════════════════╝', + ); + + // Submit proofs at end of epoch-3 + await advanceToNextProofingPeriod(contracts); + + // All nodes already have equal KC chunks from the identical KC creation above + // No need for ensureNodeHasChunksThisEpoch() since each node published identical KC + + console.log('\n🔬 EPOCH-3 PROOFS SUBMITTED:'); + const node1Proof3 = await submitProofAndLogScore( + node1Id, + accounts.node1, + contracts, + 3n, + 'Node-1', + ); + console.log( + ` ✅ Node-1: Score ${node1Proof3.scoreBefore} → ${node1Proof3.scoreAfter} (gain: ${node1Proof3.scoreAfter - node1Proof3.scoreBefore})`, + ); + + const node2Proof3 = await submitProofAndLogScore( + node2Id, + accounts.node2, + contracts, + 3n, + 'Node-2', + ); + console.log( + ` ✅ Node-2: Score ${node2Proof3.scoreBefore} → ${node2Proof3.scoreAfter} (gain: ${node2Proof3.scoreAfter - node2Proof3.scoreBefore})`, + ); + + const node3Proof3 = await submitProofAndLogScore( + node3Id, + accounts.node3, + contracts, + 3n, + 'Node-3', + ); + console.log( + ` ✅ Node-3: Score ${node3Proof3.scoreBefore} → ${node3Proof3.scoreAfter} (gain: ${node3Proof3.scoreAfter - node3Proof3.scoreBefore})`, + ); + + const node4Proof3 = await submitProofAndLogScore( + node4Id, + accounts.node4, + contracts, + 3n, + 'Node-4', + ); + console.log( + ` ✅ Node-4: Score ${node4Proof3.scoreBefore} → ${node4Proof3.scoreAfter} (gain: ${node4Proof3.scoreAfter - node4Proof3.scoreBefore})`, + ); + + // → EPOCH-4 (to finalize epoch-3) + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + + // Create KC to finalize epoch-3 (this is crucial for epoch finalization!) + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node4, + node4Id, + [accounts.node1, accounts.node2, accounts.node3], + [node1Id, node2Id, node3Id], + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, // Use consistent merkleRoot from quads + 'finalize-epoch-3', + 10, + chunkSize * 20, // byteSize - use multiple of chunkSize + 10, + toTRAC(50_000), + ); + + // Submit proofs at end of epoch-4 + await advanceToNextProofingPeriod(contracts); + + // Ensure all nodes have chunks before submitting proofs for epoch-4 + await ensureNodeHasChunksThisEpoch( + node1Id, + accounts.node1, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + await ensureNodeHasChunksThisEpoch( + node2Id, + accounts.node2, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + await ensureNodeHasChunksThisEpoch( + node3Id, + accounts.node3, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + await ensureNodeHasChunksThisEpoch( + node4Id, + accounts.node4, + contracts, + accounts, + receivingNodes, + receivingNodesIdentityIds, + chunkSize, + ); + + console.log('\n🔬 EPOCH-4 PROOFS SUBMITTED:'); + const node1Proof4 = await submitProofAndLogScore( + node1Id, + accounts.node1, + contracts, + 4n, + 'Node-1', + ); + console.log( + ` ✅ Node-1: Score ${node1Proof4.scoreBefore} → ${node1Proof4.scoreAfter} (gain: ${node1Proof4.scoreAfter - node1Proof4.scoreBefore})`, + ); + + const node2Proof4 = await submitProofAndLogScore( + node2Id, + accounts.node2, + contracts, + 4n, + 'Node-2', + ); + console.log( + ` ✅ Node-2: Score ${node2Proof4.scoreBefore} → ${node2Proof4.scoreAfter} (gain: ${node2Proof4.scoreAfter - node2Proof4.scoreBefore})`, + ); + + const node3Proof4 = await submitProofAndLogScore( + node3Id, + accounts.node3, + contracts, + 4n, + 'Node-3', + ); + console.log( + ` ✅ Node-3: Score ${node3Proof4.scoreBefore} → ${node3Proof4.scoreAfter} (gain: ${node3Proof4.scoreAfter - node3Proof4.scoreBefore})`, + ); + + const node4Proof4 = await submitProofAndLogScore( + node4Id, + accounts.node4, + contracts, + 4n, + 'Node-4', + ); + console.log( + ` ✅ Node-4: Score ${node4Proof4.scoreBefore} → ${node4Proof4.scoreAfter} (gain: ${node4Proof4.scoreAfter - node4Proof4.scoreBefore})`, + ); + + // → EPOCH-5 + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + + // Create KC for epoch-5 to ensure there's activity + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node1, + node1Id, + [accounts.node2, accounts.node3, accounts.node4], + [node2Id, node3Id, node4Id], + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, // Use consistent merkleRoot from quads + 'epoch-5-no-proofs', + 5, + chunkSize * 15, // byteSize - use multiple of chunkSize + 3, + toTRAC(2_000), + ); + + // EPOCH-5 STAKES: + // Add delegator 13 and 14 with 35k TRAC each + console.log( + '\n╔══════════════════════════════════════════════════════════════════════════════════╗', + ); + console.log( + '║ EPOCH-5 STAKING ║', + ); + console.log( + '╠══════════════════════════════════════════════════════════════════════════════════╣', + ); + + // Need to add more delegators to accounts since we only had 12 before + if (accounts.delegators.length < 14) { + const additionalDelegators = signers.slice(22, 24); // Get signers 22 and 23 for D13 and D14 + accounts.delegators.push(...additionalDelegators); + + // Mint tokens for new delegators + for (const delegator of additionalDelegators) { + await contracts.token.mint(delegator.address, toTRAC(1_000_000)); + } + } + + // D13 stakes 35k to Node-1 + await contracts.token + .connect(accounts.delegators[12]) + .approve(await contracts.staking.getAddress(), toTRAC(35_000)); + await contracts.staking + .connect(accounts.delegators[12]) + .stake(node1Id, toTRAC(35_000)); + console.log( + '║ 📍 D13 → 35,000 TRAC → Node-1 ║', + ); + + // D14 stakes 35k to Node-2 + await contracts.token + .connect(accounts.delegators[13]) + .approve(await contracts.staking.getAddress(), toTRAC(35_000)); + await contracts.staking + .connect(accounts.delegators[13]) + .stake(node2Id, toTRAC(35_000)); + console.log( + '║ 📍 D14 → 35,000 TRAC → Node-2 ║', + ); + console.log( + '╚══════════════════════════════════════════════════════════════════════════════════╝', + ); + + console.log('\n🚫 EPOCH-5: NO PROOFS SUBMITTED'); + + // → EPOCH-6 (to finalize epoch-5) + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + + // Create KC for epoch-6 to finalize epoch-5 + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node3, + node3Id, + [accounts.node1, accounts.node2, accounts.node4], + [node1Id, node2Id, node4Id], + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, // Use consistent merkleRoot from quads + 'finalize-epoch-5', + 8, + chunkSize * 25, // byteSize - use multiple of chunkSize + 5, + toTRAC(10_000), + ); + + console.log('\n🚫 EPOCH-6: NO PROOFS SUBMITTED'); + + // → EPOCH-7 (to finalize epoch-6) + await time.increase((await contracts.chronos.timeUntilNextEpoch()) + 1n); + + // Create KC for epoch-7 to finalize epoch-6 + await createKnowledgeCollection( + accounts.kcCreator, + accounts.node4, + node4Id, + [accounts.node1, accounts.node2, accounts.node3], + [node1Id, node2Id, node3Id], + { KnowledgeCollection: contracts.kc, Token: contracts.token }, + merkleRoot, // Use consistent merkleRoot from quads + 'finalize-epoch-6', + 12, + chunkSize * 30, // byteSize - use multiple of chunkSize + 8, + toTRAC(15_000), + ); + + console.log('\n📝 EPOCH-7: System ready for comprehensive testing'); + + // Print detailed snapshot + console.log('\n'); + console.log( + '═══════════════════════════════════════════════════════════════════════════════════════════════', + ); + console.log( + ' 🎯 FINAL SYSTEM STATE 🎯 ', + ); + console.log( + '═══════════════════════════════════════════════════════════════════════════════════════════════', + ); + + const currentEpoch = await contracts.chronos.getCurrentEpoch(); + const lastFinalizedEpoch = await contracts.epochStorage.lastFinalizedEpoch(1); + console.log( + `📅 Current Epoch: ${currentEpoch} | Last Finalized: ${lastFinalizedEpoch}`, + ); + console.log(''); + + console.log( + '┌─────────────────────────────────────────────────────────────────────────────────────────────┐', + ); + console.log( + '│ 📊 STAKING TIMELINE │', + ); + console.log( + '├─────────────────────────────────────────────────────────────────────────────────────────────┤', + ); + console.log( + '│ EPOCH-2: D1→10k, D2→20k (Node-1) │ D3→10k, D4→20k (Node-2) │ All nodes proofs │', + ); + console.log( + '│ EPOCH-3: D5→30k, D6→40k, D7→50k (Node-1) │ D8→30k, D9→40k, D10→50k (Node-2) │', + ); + console.log( + '│ D11→60k, D12→50k (Node-3) │ All nodes submitted proofs │', + ); + console.log( + '│ EPOCH-4: All nodes submitted proofs │', + ); + console.log( + '│ EPOCH-5: D13→35k (Node-1) │ D14→35k (Node-2) │ NO PROOFS SUBMITTED │', + ); + console.log( + '│ EPOCH-6: NO PROOFS SUBMITTED (finalization epoch for epoch-5) │', + ); + console.log( + '│ EPOCH-7: Current epoch (finalization epoch for epoch-6) │', + ); + console.log( + '└─────────────────────────────────────────────────────────────────────────────────────────────┘', + ); + console.log(''); + + for (const [i, node] of nodes.entries()) { + const totalStake = await contracts.stakingStorage.getNodeStake( + node.identityId, + ); + const nodeScore2 = await contracts.randomSamplingStorage.getNodeEpochScore( + 2n, + node.identityId, + ); + const nodeScore3 = await contracts.randomSamplingStorage.getNodeEpochScore( + 3n, + node.identityId, + ); + const nodeScore4 = await contracts.randomSamplingStorage.getNodeEpochScore( + 4n, + node.identityId, + ); + const nodeScore5 = await contracts.randomSamplingStorage.getNodeEpochScore( + 5n, + node.identityId, + ); + + console.log(`🚀 Node-${i + 1} (ID: ${node.identityId})`); + console.log( + ` 💰 Total Stake: ${hre.ethers.formatUnits(totalStake, 18)} TRAC | 🎯 Operator Fee: 10%`, + ); + console.log( + ` 📊 Scores → E2: ${nodeScore2} | E3: ${nodeScore3} | E4: ${nodeScore4} | E5: ${nodeScore5}`, + ); + + const delegatorStakes = []; + for (let d = 0; d < accounts.delegators.length; d++) { + const key = hre.ethers.keccak256( + hre.ethers.solidityPacked( + ['address'], + [accounts.delegators[d].address], + ), + ); + const stake = await contracts.stakingStorage.getDelegatorStakeBase( + node.identityId, + key, + ); + if (stake > 0n) { + delegatorStakes.push(`D${d + 1}: ${hre.ethers.formatUnits(stake, 18)}`); + } + } + + if (delegatorStakes.length > 0) { + console.log(` 👥 Delegators: ${delegatorStakes.join(' | ')}`); + } + console.log(''); + } + + console.log( + '═══════════════════════════════════════════════════════════════════════════════════════════════\n', + ); + + // Return environment for tests + return { + Token: contracts.token, + Profile: contracts.profile, + ProfileStorage: contracts.profileStorage, + Staking: contracts.staking, + StakingStorage: contracts.stakingStorage, + DelegatorsInfo: contracts.delegatorsInfo, + Chronos: contracts.chronos, + RandomSamplingStorage: contracts.randomSamplingStorage, + EpochStorage: contracts.epochStorage, + KC: contracts.kc, + delegators: accounts.delegators, + nodes, + receivingNodes, + receivingNodesIdentityIds, + accounts, + }; +} + +/* ───────────────────────────── tests ───────────────────────────── */ + +describe('rewards tests', () => { + /* fixture state visible to all tests in this describe-block */ + let env: Awaited>; + + before(async () => { + env = await buildInitialRewardsState(); + }); + + /* 1️⃣ Claim-jumping guard. */ + it('D1 cannot claim the newest finalised epoch while older remain unclaimed', async () => { + const { Staking, EpochStorage, delegators, nodes } = env; + const newestFinalised = await EpochStorage.lastFinalizedEpoch(1); // == 3 + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + newestFinalised, + delegators[0].address, + ), + ).to.be.reverted; + }); + + /* 2️⃣ Operator-fee sanity (all nodes @ 1000 ‱). */ + it('every node stores 10 % operator fee', async () => { + const { ProfileStorage, nodes } = env; + for (const n of nodes) { + const opFee = await ProfileStorage.getOperatorFee(n.identityId); + expect(opFee).to.equal(1000); // 1000 ‱ == 10 % + } + }); + + /* Add more `it()` tests below using env.* contracts & objects. */ +}); + +describe('Claim order enforcement tests', () => { + /* fixture state visible to all tests in this describe-block */ + let env: Awaited>; + + before(async () => { + env = await buildInitialRewardsState(); + }); + + it('D1, D3 attempt to claim epoch 3 rewards - should revert (must claim epoch 2 first)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 1: D1, D3 attempting to claim epoch 3 - should revert', + ); + + // D1 attempts to claim epoch 3 + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 3n, // epoch 3 + delegators[0].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D1 claim for epoch 3 reverted as expected'); + + // D3 attempts to claim epoch 3 + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 3n, // epoch 3 + delegators[2].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D3 claim for epoch 3 reverted as expected'); + }); + + it('D1, D3 attempt to claim epoch 4 rewards - should revert (must claim epoch 2 first)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 2: D1, D3 attempting to claim epoch 4 - should revert', + ); + + // D1 attempts to claim epoch 4 + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 4n, // epoch 4 + delegators[0].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D1 claim for epoch 4 reverted as expected'); + + // D3 attempts to claim epoch 4 + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 4n, // epoch 4 + delegators[2].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D3 claim for epoch 4 reverted as expected'); + }); + + it('D1, D3 attempt to claim epoch 5 rewards - should revert (must claim epoch 2 first)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 3: D1, D3 attempting to claim epoch 5 - should revert', + ); + + // D1 attempts to claim epoch 5 + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 5n, // epoch 5 + delegators[0].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D1 claim for epoch 5 reverted as expected'); + + // D3 attempts to claim epoch 5 + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 5n, // epoch 5 + delegators[2].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log(' ✅ D3 claim for epoch 5 reverted as expected'); + }); + + it('D5, D8, D10 attempt to claim epoch 2 rewards - should revert (were not delegators in that epoch)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 4: D5, D8, D10 attempting to claim epoch 2 - should revert (not delegators then)', + ); + + // D5 attempts to claim epoch 2 (but was not delegator in epoch 2) + await expect( + Staking.connect(delegators[4]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 2n, // epoch 2 + delegators[4].address, + ), + ).to.be.revertedWith('Epoch already claimed'); + + console.log( + ' ✅ D5 claim for epoch 2 reverted as expected (was not delegator)', + ); + + // D8 attempts to claim epoch 2 (but was not delegator in epoch 2) + await expect( + Staking.connect(delegators[7]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 2n, // epoch 2 + delegators[7].address, + ), + ).to.be.revertedWith('Epoch already claimed'); + + console.log( + ' ✅ D8 claim for epoch 2 reverted as expected (was not delegator)', + ); + + // D10 attempts to claim epoch 2 (but was not delegator in epoch 2) + await expect( + Staking.connect(delegators[9]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 2n, // epoch 2 + delegators[9].address, + ), + ).to.be.revertedWith('Epoch already claimed'); + + console.log( + ' ✅ D10 claim for epoch 2 reverted as expected (was not delegator)', + ); + }); + + it('D1, D3 successfully claim epoch 2 rewards - should succeed with equal rewards', async () => { + const { + Staking, + StakingStorage, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log('\n✅ TEST 5: D1, D3 successfully claiming epoch 2 rewards'); + + // Get initial state + const d1Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[0].address]), + ); + const d3Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[2].address]), + ); + + const d1StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d1Key, + ); + const d3StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d3Key, + ); + + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Verify nodes have equal scores (due to identical KC setup) + const node1Score2 = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[0].identityId, + ); + const node2Score2 = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[1].identityId, + ); + + expect(node1Score2).to.equal( + node2Score2, + 'Node-1 and Node-2 should have equal scores', + ); + console.log(` 📊 Both nodes have equal score: ${node1Score2}`); + + // D1 claims epoch 2 rewards + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 2n, // epoch 2 + delegators[0].address, + ); + + console.log(' ✅ D1 successfully claimed epoch 2 rewards'); + + // D3 claims epoch 2 rewards + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 2n, // epoch 2 + delegators[2].address, + ); + + console.log(' ✅ D3 successfully claimed epoch 2 rewards'); + + // Get final state + const d1StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d1Key, + ); + const d3StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d3Key, + ); + + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Calculate rewards and stake changes + const d1Reward = d1RollingAfter - d1RollingBefore; + const d3Reward = d3RollingAfter - d3RollingBefore; + const d1StakeChange = d1StakeBaseAfter - d1StakeBaseBefore; + const d3StakeChange = d3StakeBaseAfter - d3StakeBaseBefore; + + console.log( + ` 💰 D1 rolling reward: ${hre.ethers.formatUnits(d1Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 rolling reward: ${hre.ethers.formatUnits(d3Reward, 18)} TRAC`, + ); + + // Verify equal rewards (since equal stakes and equal node scores) + expect(d1Reward).to.equal( + d3Reward, + 'D1 and D3 should receive equal rewards', + ); + + // StakeBase should not change (future epochs remain to claim) + expect(d1StakeChange).to.equal( + 0n, + 'D1 stakeBase should not change (rolling rewards)', + ); + expect(d3StakeChange).to.equal( + 0n, + 'D3 stakeBase should not change (rolling rewards)', + ); + + // Both should receive positive rewards + expect(d1Reward).to.be.gt(0n, 'D1 rolling rewards should be positive'); + expect(d3Reward).to.be.gt(0n, 'D3 rolling rewards should be positive'); + + console.log(' ✅ Both delegators received equal rolling rewards'); + console.log( + ' ✅ StakeBase remained unchanged - rewards went to rolling rewards', + ); + console.log( + ' 📝 Note: Equal stakes + equal node performance = equal rewards', + ); + }); + + it('Node scores verification - Node-1 and Node-2 should have identical scores in epoch 2', async () => { + const { RandomSamplingStorage, nodes } = env; + + console.log('\n✅ TEST 6: Verifying equal node scores in epoch 2'); + + // Get node scores for epoch 2 + const node1Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[0].identityId, + ); + const node2Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[1].identityId, + ); + const node3Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[2].identityId, + ); + const node4Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[3].identityId, + ); + + // Get score per stake + const node1ScorePerStake = + await RandomSamplingStorage.getNodeEpochScorePerStake( + 2n, + nodes[0].identityId, + ); + const node2ScorePerStake = + await RandomSamplingStorage.getNodeEpochScorePerStake( + 2n, + nodes[1].identityId, + ); + + console.log(` 📊 Node-1 score: ${node1Score}`); + console.log(` 📊 Node-2 score: ${node2Score}`); + console.log(` 📊 Node-3 score: ${node3Score} (should be 0 - no stake)`); + console.log(` 📊 Node-4 score: ${node4Score} (should be 0 - no stake)`); + console.log(` 📈 Node-1 score per stake: ${node1ScorePerStake}`); + console.log(` 📈 Node-2 score per stake: ${node2ScorePerStake}`); + + // Verify equal scores for nodes with stakes + expect(node1Score).to.equal( + node2Score, + 'Node-1 and Node-2 should have equal total scores', + ); + expect(node1ScorePerStake).to.equal( + node2ScorePerStake, + 'Node-1 and Node-2 should have equal score per stake', + ); + + // Verify zero scores for nodes without stakes + expect(node3Score).to.equal( + 0n, + 'Node-3 should have zero score (no stake in epoch 2)', + ); + expect(node4Score).to.equal( + 0n, + 'Node-4 should have zero score (no stake in epoch 2)', + ); + + // Both nodes should have positive scores + expect(node1Score).to.be.gt(0n, 'Node-1 should have positive score'); + expect(node2Score).to.be.gt(0n, 'Node-2 should have positive score'); + + console.log( + ' ✅ Node-1 and Node-2 have identical scores and score per stake', + ); + console.log( + ' ✅ Node-3 and Node-4 have zero scores (no stakes in epoch 2)', + ); + console.log( + ' 📝 Note: Equal KC setup resulted in equal node performance', + ); + }); + + it('D1, D3 claim epoch 3 rewards - rolling rewards should accumulate', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ TEST 7: D1, D3 claiming epoch 3 rewards - rolling accumulation', + ); + + // Get rolling rewards after epoch 2 claims (from previous test) + const d1RollingAfterEpoch2 = + await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfterEpoch2 = + await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + console.log( + ` 🔄 D1 rolling after epoch 2: ${hre.ethers.formatUnits(d1RollingAfterEpoch2, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling after epoch 2: ${hre.ethers.formatUnits(d3RollingAfterEpoch2, 18)} TRAC`, + ); + + // Verify both have some rolling rewards from epoch 2 + expect(d1RollingAfterEpoch2).to.be.gt( + 0n, + 'D1 should have rolling rewards from epoch 2', + ); + expect(d3RollingAfterEpoch2).to.be.gt( + 0n, + 'D3 should have rolling rewards from epoch 2', + ); + + // Check epoch 3 node scores (these will be different due to different stakes) + const node1Score3 = await RandomSamplingStorage.getNodeEpochScore( + 3n, + nodes[0].identityId, + ); + const node2Score3 = await RandomSamplingStorage.getNodeEpochScore( + 3n, + nodes[1].identityId, + ); + + console.log(` 📊 Node-1 epoch 3 score: ${node1Score3}`); + console.log(` 📊 Node-2 epoch 3 score: ${node2Score3}`); + + // D1 claims epoch 3 rewards + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 3n, // epoch 3 + delegators[0].address, + ); + + console.log(' ✅ D1 successfully claimed epoch 3 rewards'); + + // D3 claims epoch 3 rewards + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 3n, // epoch 3 + delegators[2].address, + ); + + console.log(' ✅ D3 successfully claimed epoch 3 rewards'); + + // Get rolling rewards after epoch 3 claims + const d1RollingAfterEpoch3 = + await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfterEpoch3 = + await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Calculate epoch 3 rewards + const d1Epoch3Reward = d1RollingAfterEpoch3 - d1RollingAfterEpoch2; + const d3Epoch3Reward = d3RollingAfterEpoch3 - d3RollingAfterEpoch2; + + console.log( + ` 💰 D1 epoch 3 reward: ${hre.ethers.formatUnits(d1Epoch3Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 3 reward: ${hre.ethers.formatUnits(d3Epoch3Reward, 18)} TRAC`, + ); + console.log( + ` 🔄 D1 total rolling after epoch 3: ${hre.ethers.formatUnits(d1RollingAfterEpoch3, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 total rolling after epoch 3: ${hre.ethers.formatUnits(d3RollingAfterEpoch3, 18)} TRAC`, + ); + + // Verify rolling rewards increased (accumulated) + expect(d1RollingAfterEpoch3).to.be.gt( + d1RollingAfterEpoch2, + 'D1 rolling rewards should increase after epoch 3 claim', + ); + expect(d3RollingAfterEpoch3).to.be.gt( + d3RollingAfterEpoch2, + 'D3 rolling rewards should increase after epoch 3 claim', + ); + + // Both should receive positive epoch 3 rewards + expect(d1Epoch3Reward).to.be.gt( + 0n, + 'D1 should receive positive epoch 3 rewards', + ); + expect(d3Epoch3Reward).to.be.gt( + 0n, + 'D3 should receive positive epoch 3 rewards', + ); + + // Verify accumulation: total = epoch2 + epoch3 + expect(d1RollingAfterEpoch3).to.equal( + d1RollingAfterEpoch2 + d1Epoch3Reward, + 'D1 total rolling should equal epoch 2 + epoch 3 rewards', + ); + expect(d3RollingAfterEpoch3).to.equal( + d3RollingAfterEpoch2 + d3Epoch3Reward, + 'D3 total rolling should equal epoch 2 + epoch 3 rewards', + ); + + console.log( + ' ✅ Rolling rewards successfully accumulated from both epochs', + ); + console.log(' ✅ Both delegators received positive epoch 3 rewards'); + console.log( + ' 📝 Note: Rolling rewards = Epoch 2 rewards + Epoch 3 rewards', + ); + }); + + it('D1, D3 attempt to claim epoch 5 rewards - should revert (must claim epoch 4 first)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 8: D1, D3 attempting to claim epoch 5 - should revert (must claim epoch 4 first)', + ); + + // D1 attempts to claim epoch 5 (but hasn't claimed epoch 4 yet) + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 5n, // epoch 5 + delegators[0].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log( + ' ✅ D1 claim for epoch 5 reverted as expected (must claim epoch 4 first)', + ); + + // D3 attempts to claim epoch 5 (but hasn't claimed epoch 4 yet) + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 5n, // epoch 5 + delegators[2].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log( + ' ✅ D3 claim for epoch 5 reverted as expected (must claim epoch 4 first)', + ); + console.log( + ' 📝 Note: Sequential claiming enforced - cannot skip epoch 4', + ); + }); + + it('D1, D3 claim epoch 4 rewards - should succeed with equal rewards (equal stakes + all nodes submitted proofs)', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ TEST 9: D1, D3 claiming epoch 4 rewards - should get equal rewards', + ); + + // Get rolling rewards before epoch 4 claims (should have epoch 2 + epoch 3 rewards) + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + console.log( + ` 🔄 D1 rolling before epoch 4: ${hre.ethers.formatUnits(d1RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling before epoch 4: ${hre.ethers.formatUnits(d3RollingBefore, 18)} TRAC`, + ); + + // Check epoch 4 node scores (should be positive since all nodes submitted proofs) + const node1Score4 = await RandomSamplingStorage.getNodeEpochScore( + 4n, + nodes[0].identityId, + ); + const node2Score4 = await RandomSamplingStorage.getNodeEpochScore( + 4n, + nodes[1].identityId, + ); + + console.log(` 📊 Node-1 epoch 4 score: ${node1Score4}`); + console.log(` 📊 Node-2 epoch 4 score: ${node2Score4}`); + + // Both nodes should have positive scores (all submitted proofs) + expect(node1Score4).to.be.gt( + 0n, + 'Node-1 should have positive score in epoch 4', + ); + expect(node2Score4).to.be.gt( + 0n, + 'Node-2 should have positive score in epoch 4', + ); + + // D1 claims epoch 4 rewards + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 4n, // epoch 4 + delegators[0].address, + ); + + console.log(' ✅ D1 successfully claimed epoch 4 rewards'); + + // D3 claims epoch 4 rewards + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 4n, // epoch 4 + delegators[2].address, + ); + + console.log(' ✅ D3 successfully claimed epoch 4 rewards'); + + // Get rolling rewards after epoch 4 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Calculate epoch 4 rewards + const d1Epoch4Reward = d1RollingAfter - d1RollingBefore; + const d3Epoch4Reward = d3RollingAfter - d3RollingBefore; + + console.log( + ` 💰 D1 epoch 4 reward: ${hre.ethers.formatUnits(d1Epoch4Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 4 reward: ${hre.ethers.formatUnits(d3Epoch4Reward, 18)} TRAC`, + ); + console.log( + ` 🔄 D1 total rolling after epoch 4: ${hre.ethers.formatUnits(d1RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 total rolling after epoch 4: ${hre.ethers.formatUnits(d3RollingAfter, 18)} TRAC`, + ); + + // Verify rolling rewards increased (accumulated) + expect(d1RollingAfter).to.be.gt( + d1RollingBefore, + 'D1 rolling rewards should increase after epoch 4 claim', + ); + expect(d3RollingAfter).to.be.gt( + d3RollingBefore, + 'D3 rolling rewards should increase after epoch 4 claim', + ); + + // Both should receive positive epoch 4 rewards + expect(d1Epoch4Reward).to.be.gt( + 0n, + 'D1 should receive positive epoch 4 rewards', + ); + expect(d3Epoch4Reward).to.be.gt( + 0n, + 'D3 should receive positive epoch 4 rewards', + ); + + // Verify equal rewards (equal stakes in epoch 4, all nodes submitted proofs) + expect(d1Epoch4Reward).to.equal( + d3Epoch4Reward, + 'D1 and D3 should receive equal epoch 4 rewards (equal stakes)', + ); + + console.log( + ' ✅ Rolling rewards successfully accumulated (epochs 2+3+4)', + ); + console.log(' ✅ Both delegators received equal epoch 4 rewards'); + console.log( + ' 📝 Note: Equal stakes + all nodes submitted proofs = equal rewards', + ); + }); + + it('D1, D3 attempt to claim epoch 6 rewards - should revert (must claim epoch 5 first)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 10: D1, D3 attempting to claim epoch 6 - should revert (must claim epoch 5 first)', + ); + + // D1 attempts to claim epoch 6 (but hasn't claimed epoch 5 yet) + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 6n, // epoch 6 + delegators[0].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log( + ' ✅ D1 claim for epoch 6 reverted as expected (must claim epoch 5 first)', + ); + + // D3 attempts to claim epoch 6 (but hasn't claimed epoch 5 yet) + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 6n, // epoch 6 + delegators[2].address, + ), + ).to.be.revertedWith('Must claim older epochs first'); + + console.log( + ' ✅ D3 claim for epoch 6 reverted as expected (must claim epoch 5 first)', + ); + console.log( + ' 📝 Note: Sequential claiming enforced - cannot skip epoch 5', + ); + }); + + it('D1, D3 claim epoch 5 rewards - should succeed with 0 rewards (no proofs submitted)', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ TEST 11: D1, D3 claiming epoch 5 rewards - should get 0 rewards (no proofs)', + ); + + // Get rolling rewards before epoch 5 claims (should have epoch 2+3+4 rewards) + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + console.log( + ` 🔄 D1 rolling before epoch 5: ${hre.ethers.formatUnits(d1RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling before epoch 5: ${hre.ethers.formatUnits(d3RollingBefore, 18)} TRAC`, + ); + + // Check epoch 5 node scores (should be 0 since no proofs were submitted) + const node1Score5 = await RandomSamplingStorage.getNodeEpochScore( + 5n, + nodes[0].identityId, + ); + const node2Score5 = await RandomSamplingStorage.getNodeEpochScore( + 5n, + nodes[1].identityId, + ); + + console.log(` 📊 Node-1 epoch 5 score: ${node1Score5} (should be 0)`); + console.log(` 📊 Node-2 epoch 5 score: ${node2Score5} (should be 0)`); + + // Verify scores are 0 (no proofs submitted) + expect(node1Score5).to.equal(0n, 'Node-1 should have 0 score in epoch 5'); + expect(node2Score5).to.equal(0n, 'Node-2 should have 0 score in epoch 5'); + + // D1 claims epoch 5 rewards (should succeed but get 0 rewards) + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 5n, // epoch 5 + delegators[0].address, + ); + + console.log(' ✅ D1 successfully claimed epoch 5 rewards (0 TRAC)'); + + // D3 claims epoch 5 rewards (should succeed but get 0 rewards) + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 5n, // epoch 5 + delegators[2].address, + ); + + console.log(' ✅ D3 successfully claimed epoch 5 rewards (0 TRAC)'); + + // Get rolling rewards after epoch 5 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Calculate epoch 5 rewards (should be 0) + const d1Epoch5Reward = d1RollingAfter - d1RollingBefore; + const d3Epoch5Reward = d3RollingAfter - d3RollingBefore; + + console.log( + ` 💰 D1 epoch 5 reward: ${hre.ethers.formatUnits(d1Epoch5Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 5 reward: ${hre.ethers.formatUnits(d3Epoch5Reward, 18)} TRAC`, + ); + console.log( + ` 🔄 D1 total rolling after epoch 5: ${hre.ethers.formatUnits(d1RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 total rolling after epoch 5: ${hre.ethers.formatUnits(d3RollingAfter, 18)} TRAC`, + ); + + // Verify rolling rewards didn't change (no rewards from epoch 5) + expect(d1RollingAfter).to.equal( + d1RollingBefore, + 'D1 rolling rewards should not change (no epoch 5 rewards)', + ); + expect(d3RollingAfter).to.equal( + d3RollingBefore, + 'D3 rolling rewards should not change (no epoch 5 rewards)', + ); + + // Verify epoch 5 rewards are 0 + expect(d1Epoch5Reward).to.equal( + 0n, + 'D1 should receive 0 rewards from epoch 5', + ); + expect(d3Epoch5Reward).to.equal( + 0n, + 'D3 should receive 0 rewards from epoch 5', + ); + + // Verify both have same rolling rewards (should be equal after epochs 2+3+4) + expect(d1RollingAfter).to.equal( + d3RollingAfter, + 'D1 and D3 should have equal rolling rewards (equal stakes in all claimed epochs)', + ); + + console.log( + ' ✅ Both delegators successfully claimed epoch 5 with 0 rewards', + ); + console.log(' ✅ Rolling rewards remained unchanged (no new rewards)'); + console.log(' ✅ Both delegators have equal rolling rewards'); + console.log(' 📝 Note: No proofs in epoch 5 = no rewards to distribute'); + }); + + it('D1, D3 attempt to claim epoch 5 rewards again - should revert (already claimed)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 12: D1, D3 attempting to claim epoch 5 again - should revert (already claimed)', + ); + + // D1 attempts to claim epoch 5 again (but already claimed it) + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 5n, // epoch 5 + delegators[0].address, + ), + ).to.be.revertedWith('Epoch already claimed'); + + console.log( + ' ✅ D1 claim for epoch 5 reverted as expected (already claimed)', + ); + + // D3 attempts to claim epoch 5 again (but already claimed it) + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 5n, // epoch 5 + delegators[2].address, + ), + ).to.be.revertedWith('Epoch already claimed'); + + console.log( + ' ✅ D3 claim for epoch 5 reverted as expected (already claimed)', + ); + console.log( + ' 📝 Note: Cannot claim the same epoch twice - double claiming prevented', + ); + }); + + it('D1, D3 attempt to claim epoch 7 rewards - should revert (epoch not finalized)', async () => { + const { Staking, delegators, nodes } = env; + + console.log( + '\n⛔ TEST 13: D1, D3 attempting to claim epoch 7 - should revert (epoch not finalized)', + ); + + // D1 attempts to claim epoch 7 (but epoch 7 is not finalized yet) + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 7n, // epoch 7 + delegators[0].address, + ), + ).to.be.revertedWith('Epoch not finalised'); + + console.log( + ' ✅ D1 claim for epoch 7 reverted as expected (epoch not finalized)', + ); + + // D3 attempts to claim epoch 7 (but epoch 7 is not finalized yet) + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 7n, // epoch 7 + delegators[2].address, + ), + ).to.be.revertedWith('Epoch not finalised'); + + console.log( + ' ✅ D3 claim for epoch 7 reverted as expected (epoch not finalized)', + ); + console.log(' 📝 Note: Cannot claim rewards for non-finalized epochs'); + }); + + it('D1, D3 claim epoch 6 rewards - should succeed with 0 rewards (no proofs submitted)', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ TEST 14: D1, D3 claiming epoch 6 rewards - should get 0 rewards (no proofs)', + ); + + // Get rolling rewards before epoch 6 claims (should have epoch 2+3+4+5 rewards) + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + console.log( + ` 🔄 D1 rolling before epoch 6: ${hre.ethers.formatUnits(d1RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling before epoch 6: ${hre.ethers.formatUnits(d3RollingBefore, 18)} TRAC`, + ); + + // Check epoch 6 node scores (should be 0 since no proofs were submitted) + const node1Score6 = await RandomSamplingStorage.getNodeEpochScore( + 6n, + nodes[0].identityId, + ); + const node2Score6 = await RandomSamplingStorage.getNodeEpochScore( + 6n, + nodes[1].identityId, + ); + + console.log(` 📊 Node-1 epoch 6 score: ${node1Score6} (should be 0)`); + console.log(` 📊 Node-2 epoch 6 score: ${node2Score6} (should be 0)`); + + // Verify scores are 0 (no proofs submitted) + expect(node1Score6).to.equal(0n, 'Node-1 should have 0 score in epoch 6'); + expect(node2Score6).to.equal(0n, 'Node-2 should have 0 score in epoch 6'); + + // D1 claims epoch 6 rewards (should succeed but get 0 rewards) + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 6n, // epoch 6 + delegators[0].address, + ); + + console.log(' ✅ D1 successfully claimed epoch 6 rewards (0 TRAC)'); + + // D3 claims epoch 6 rewards (should succeed but get 0 rewards) + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 6n, // epoch 6 + delegators[2].address, + ); + + console.log(' ✅ D3 successfully claimed epoch 6 rewards (0 TRAC)'); + + // Get rolling rewards after epoch 6 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + + // Read actual values from contracts after claiming + const d1RollingTransferred = d1RollingBefore - d1RollingAfter; // How much was transferred + const d3RollingTransferred = d3RollingBefore - d3RollingAfter; // How much was transferred + + console.log(` 💰 D1 epoch 6 reward: 0.0 TRAC (no proofs submitted)`); + console.log(` 💰 D3 epoch 6 reward: 0.0 TRAC (no proofs submitted)`); + console.log( + ` 🔄 D1 rolling transferred: ${hre.ethers.formatUnits(d1RollingTransferred, 18)} TRAC → stakeBase`, + ); + console.log( + ` 🔄 D3 rolling transferred: ${hre.ethers.formatUnits(d3RollingTransferred, 18)} TRAC → stakeBase`, + ); + console.log( + ` 🔄 D1 total rolling after epoch 6: ${hre.ethers.formatUnits(d1RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 total rolling after epoch 6: ${hre.ethers.formatUnits(d3RollingAfter, 18)} TRAC`, + ); + + // Get stakeBase after epoch 6 claims to check if rolling rewards were transferred + const d1StakeBaseAfter = await env.StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[0].address]), + ), + ); + const d3StakeBaseAfter = await env.StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[2].address]), + ), + ); + + console.log( + ` 💎 D1 stakeBase after epoch 6: ${hre.ethers.formatUnits(d1StakeBaseAfter, 18)} TRAC`, + ); + console.log( + ` 💎 D3 stakeBase after epoch 6: ${hre.ethers.formatUnits(d3StakeBaseAfter, 18)} TRAC`, + ); + + // Verify epoch 6 behavior - no new rewards, but rolling rewards transferred + // Since no proofs were submitted in epoch 6, no new rewards should be generated + // But rolling rewards should be transferred to stakeBase since this is the last claimable epoch + + // Since epoch 6 is the last claimable epoch (epoch 7 is current and not finalized), + // rolling rewards should have been transferred to stakeBase + expect(d1RollingAfter).to.equal( + 0n, + 'D1 rolling rewards should be 0 (transferred to stakeBase as last epoch)', + ); + expect(d3RollingAfter).to.equal( + 0n, + 'D3 rolling rewards should be 0 (transferred to stakeBase as last epoch)', + ); + + // Verify that rolling rewards were properly transferred to stakeBase + // Both delegators should have equal stakeBase (since they had equal stakes and equal rewards) + expect(d1StakeBaseAfter).to.equal( + d3StakeBaseAfter, + 'D1 and D3 should have equal stakeBase after claiming all epochs', + ); + + // Verify that stakeBase increased by the amount of rolling rewards that were transferred + expect(d1StakeBaseAfter).to.be.gt( + toTRAC(10_000), + 'D1 stakeBase should be greater than original 10k stake (includes transferred rewards)', + ); + expect(d3StakeBaseAfter).to.be.gt( + toTRAC(10_000), + 'D3 stakeBase should be greater than original 10k stake (includes transferred rewards)', + ); + + console.log( + ' ✅ Both delegators successfully claimed epoch 6 with 0 rewards', + ); + console.log( + ' ✅ Rolling rewards transferred to stakeBase (last claimable epoch)', + ); + console.log(' ✅ Both delegators have equal final stakeBase'); + console.log( + ' 📝 Note: Last epoch claim transfers rolling rewards to stakeBase', + ); + }); + + it('D1, D3 attempt to claim epoch 7 rewards again - should revert (epoch not finalized)', async () => { + const { Staking, delegators, nodes, Chronos, EpochStorage } = env; + + console.log( + '\n⛔ TEST 15: D1, D3 attempting to claim epoch 7 - should revert (epoch not finalized)', + ); + + // Verify current state + const currentEpoch = await Chronos.getCurrentEpoch(); + const lastFinalizedEpoch = await EpochStorage.lastFinalizedEpoch(1); + + console.log(` ℹ️ Current epoch: ${currentEpoch}`); + console.log(` ℹ️ Last finalized epoch: ${lastFinalizedEpoch}`); + + // Verify epoch 7 is current and not finalized + expect(currentEpoch).to.equal(7n, 'Current epoch should be 7'); + expect(lastFinalizedEpoch).to.be.lt( + 7n, + 'Epoch 7 should not be finalized yet', + ); + + // D1 attempts to claim epoch 7 (but epoch 7 is current and not finalized) + await expect( + Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, // Node-1 + 7n, // epoch 7 + delegators[0].address, + ), + ).to.be.revertedWith('Epoch not finalised'); + + console.log( + ' ✅ D1 claim for epoch 7 reverted as expected (epoch not finalized)', + ); + + // D3 attempts to claim epoch 7 (but epoch 7 is current and not finalized) + await expect( + Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, // Node-2 + 7n, // epoch 7 + delegators[2].address, + ), + ).to.be.revertedWith('Epoch not finalised'); + + console.log( + ' ✅ D3 claim for epoch 7 reverted as expected (epoch not finalized)', + ); + console.log( + ' 📝 Note: Cannot claim rewards for current/non-finalized epochs', + ); + console.log( + ' 📝 Note: Epoch must be finalized before rewards can be claimed', + ); + }); +}); + +describe('Proportional rewards tests - Double stake = Double rewards', () => { + /* fixture state visible to all tests in this describe-block */ + let env: Awaited>; + + before(async () => { + env = await buildInitialRewardsState(); + }); + + it('D1, D2, D3, D4 claim epoch 2 rewards - D2 and D4 should get double rewards (double stakes)', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ PROPORTIONAL TEST 1: Epoch 2 rewards - Double stake = Double rewards', + ); + + // Verify stakes first + const d1Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[0].address]), + ); + const d2Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[1].address]), + ); + const d3Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[2].address]), + ); + const d4Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[3].address]), + ); + + const d1Stake = await env.StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d1Key, + ); + const d2Stake = await env.StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d2Key, + ); + const d3Stake = await env.StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d3Key, + ); + const d4Stake = await env.StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d4Key, + ); + + console.log( + ` 💰 D1 stake: ${hre.ethers.formatUnits(d1Stake, 18)} TRAC (Node-1)`, + ); + console.log( + ` 💰 D2 stake: ${hre.ethers.formatUnits(d2Stake, 18)} TRAC (Node-1)`, + ); + console.log( + ` 💰 D3 stake: ${hre.ethers.formatUnits(d3Stake, 18)} TRAC (Node-2)`, + ); + console.log( + ` 💰 D4 stake: ${hre.ethers.formatUnits(d4Stake, 18)} TRAC (Node-2)`, + ); + + // Verify stake ratios + expect(d2Stake).to.equal(d1Stake * 2n, 'D2 should have double D1 stake'); + expect(d4Stake).to.equal(d3Stake * 2n, 'D4 should have double D3 stake'); + expect(d1Stake).to.equal(d3Stake, 'D1 and D3 should have equal stakes'); + expect(d2Stake).to.equal(d4Stake, 'D2 and D4 should have equal stakes'); + + // Verify nodes have equal scores + const node1Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[0].identityId, + ); + const node2Score = await RandomSamplingStorage.getNodeEpochScore( + 2n, + nodes[1].identityId, + ); + expect(node1Score).to.equal(node2Score, 'Nodes should have equal scores'); + console.log(` 📊 Both nodes have equal score: ${node1Score}`); + + // Get rolling rewards before claiming + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // All should start with 0 rolling rewards + expect(d1RollingBefore).to.equal( + 0n, + 'D1 should start with 0 rolling rewards', + ); + expect(d2RollingBefore).to.equal( + 0n, + 'D2 should start with 0 rolling rewards', + ); + expect(d3RollingBefore).to.equal( + 0n, + 'D3 should start with 0 rolling rewards', + ); + expect(d4RollingBefore).to.equal( + 0n, + 'D4 should start with 0 rolling rewards', + ); + + // Claim epoch 2 rewards for all delegators + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + 2n, + delegators[0].address, + ); + await Staking.connect(delegators[1]).claimDelegatorRewards( + nodes[0].identityId, + 2n, + delegators[1].address, + ); + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, + 2n, + delegators[2].address, + ); + await Staking.connect(delegators[3]).claimDelegatorRewards( + nodes[1].identityId, + 2n, + delegators[3].address, + ); + + console.log(' ✅ All delegators successfully claimed epoch 2 rewards'); + + // Get rolling rewards after claiming + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Calculate epoch 2 rewards + const d1Reward = d1RollingAfter - d1RollingBefore; + const d2Reward = d2RollingAfter - d2RollingBefore; + const d3Reward = d3RollingAfter - d3RollingBefore; + const d4Reward = d4RollingAfter - d4RollingBefore; + + console.log( + ` 💰 D1 epoch 2 reward: ${hre.ethers.formatUnits(d1Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D2 epoch 2 reward: ${hre.ethers.formatUnits(d2Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 2 reward: ${hre.ethers.formatUnits(d3Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D4 epoch 2 reward: ${hre.ethers.formatUnits(d4Reward, 18)} TRAC`, + ); + + // Verify proportional rewards (allow small rounding differences) + const d2ToD1Ratio = Number(d2Reward) / Number(d1Reward); + const d4ToD3Ratio = Number(d4Reward) / Number(d3Reward); + + expect(d2ToD1Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should get approximately double D1 rewards', + ); + expect(d4ToD3Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should get approximately double D3 rewards', + ); + expect(d1Reward).to.equal( + d3Reward, + 'D1 and D3 should get equal rewards (equal stakes)', + ); + expect(d2Reward).to.equal( + d4Reward, + 'D2 and D4 should get equal rewards (equal stakes)', + ); + + // All rewards should be positive + expect(d1Reward).to.be.gt(0n, 'D1 should get positive rewards'); + expect(d2Reward).to.be.gt(0n, 'D2 should get positive rewards'); + expect(d3Reward).to.be.gt(0n, 'D3 should get positive rewards'); + expect(d4Reward).to.be.gt(0n, 'D4 should get positive rewards'); + + console.log(' ✅ PROPORTIONAL REWARDS VERIFIED:'); + console.log( + ` 📈 D2 reward / D1 reward = ${Number(d2Reward) / Number(d1Reward)} (should be 2.0)`, + ); + console.log( + ` 📈 D4 reward / D3 reward = ${Number(d4Reward) / Number(d3Reward)} (should be 2.0)`, + ); + console.log( + ' 📝 Note: Double stake = Double rewards confirmed for epoch 2', + ); + }); + + it('D1, D2, D3, D4 claim epoch 3 rewards - D2 and D4 should get proportionally more rewards', async () => { + const { + Staking, + DelegatorsInfo, + RandomSamplingStorage, + delegators, + nodes, + } = env; + + console.log( + '\n✅ PROPORTIONAL TEST 2: Epoch 3 rewards - Proportional to stakes', + ); + + // Get rolling rewards before epoch 3 claims + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + console.log( + ` 🔄 D1 rolling before epoch 3: ${hre.ethers.formatUnits(d1RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D2 rolling before epoch 3: ${hre.ethers.formatUnits(d2RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling before epoch 3: ${hre.ethers.formatUnits(d3RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D4 rolling before epoch 3: ${hre.ethers.formatUnits(d4RollingBefore, 18)} TRAC`, + ); + + // Verify epoch 3 node scores + const node1Score3 = await RandomSamplingStorage.getNodeEpochScore( + 3n, + nodes[0].identityId, + ); + const node2Score3 = await RandomSamplingStorage.getNodeEpochScore( + 3n, + nodes[1].identityId, + ); + console.log(` 📊 Node-1 epoch 3 score: ${node1Score3}`); + console.log(` 📊 Node-2 epoch 3 score: ${node2Score3}`); + + // Claim epoch 3 rewards for all delegators + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + 3n, + delegators[0].address, + ); + await Staking.connect(delegators[1]).claimDelegatorRewards( + nodes[0].identityId, + 3n, + delegators[1].address, + ); + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, + 3n, + delegators[2].address, + ); + await Staking.connect(delegators[3]).claimDelegatorRewards( + nodes[1].identityId, + 3n, + delegators[3].address, + ); + + console.log(' ✅ All delegators successfully claimed epoch 3 rewards'); + + // Get rolling rewards after epoch 3 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Calculate epoch 3 rewards + const d1Epoch3Reward = d1RollingAfter - d1RollingBefore; + const d2Epoch3Reward = d2RollingAfter - d2RollingBefore; + const d3Epoch3Reward = d3RollingAfter - d3RollingBefore; + const d4Epoch3Reward = d4RollingAfter - d4RollingBefore; + + console.log( + ` 💰 D1 epoch 3 reward: ${hre.ethers.formatUnits(d1Epoch3Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D2 epoch 3 reward: ${hre.ethers.formatUnits(d2Epoch3Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 3 reward: ${hre.ethers.formatUnits(d3Epoch3Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D4 epoch 3 reward: ${hre.ethers.formatUnits(d4Epoch3Reward, 18)} TRAC`, + ); + + // Verify proportional rewards (allow small rounding differences) + const d2ToD1Epoch3Ratio = Number(d2Epoch3Reward) / Number(d1Epoch3Reward); + const d4ToD3Epoch3Ratio = Number(d4Epoch3Reward) / Number(d3Epoch3Reward); + + expect(d2ToD1Epoch3Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should get approximately double D1 epoch 3 rewards', + ); + expect(d4ToD3Epoch3Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should get approximately double D3 epoch 3 rewards', + ); + expect(d1Epoch3Reward).to.equal( + d3Epoch3Reward, + 'D1 and D3 should get equal epoch 3 rewards', + ); + expect(d2Epoch3Reward).to.equal( + d4Epoch3Reward, + 'D2 and D4 should get equal epoch 3 rewards', + ); + + // All rewards should be positive + expect(d1Epoch3Reward).to.be.gt( + 0n, + 'D1 should get positive epoch 3 rewards', + ); + expect(d2Epoch3Reward).to.be.gt( + 0n, + 'D2 should get positive epoch 3 rewards', + ); + expect(d3Epoch3Reward).to.be.gt( + 0n, + 'D3 should get positive epoch 3 rewards', + ); + expect(d4Epoch3Reward).to.be.gt( + 0n, + 'D4 should get positive epoch 3 rewards', + ); + + // Verify total rolling rewards also maintain proportionality (allow small rounding differences) + const d2ToD1TotalRatio = Number(d2RollingAfter) / Number(d1RollingAfter); + const d4ToD3TotalRatio = Number(d4RollingAfter) / Number(d3RollingAfter); + + expect(d2ToD1TotalRatio).to.be.closeTo( + 2.0, + 0.001, + 'D2 total rolling should be approximately double D1 total rolling', + ); + expect(d4ToD3TotalRatio).to.be.closeTo( + 2.0, + 0.001, + 'D4 total rolling should be approximately double D3 total rolling', + ); + + console.log(' ✅ PROPORTIONAL REWARDS VERIFIED FOR EPOCH 3:'); + console.log( + ` 📈 D2 epoch 3 reward / D1 epoch 3 reward = ${Number(d2Epoch3Reward) / Number(d1Epoch3Reward)} (should be 2.0)`, + ); + console.log( + ` 📈 D4 epoch 3 reward / D3 epoch 3 reward = ${Number(d4Epoch3Reward) / Number(d3Epoch3Reward)} (should be 2.0)`, + ); + console.log( + ` 🔄 D2 total rolling / D1 total rolling = ${Number(d2RollingAfter) / Number(d1RollingAfter)} (should be 2.0)`, + ); + console.log( + ' 📝 Note: Proportional rewards maintained across multiple epochs', + ); + }); + + it('D1, D2, D3, D4 claim epoch 4 rewards - Proportional rewards continue', async () => { + const { Staking, DelegatorsInfo, delegators, nodes } = env; + + console.log( + '\n✅ PROPORTIONAL TEST 3: Epoch 4 rewards - Proportional rewards continue', + ); + + // Get rolling rewards before epoch 4 claims + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Claim epoch 4 rewards for all delegators + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + 4n, + delegators[0].address, + ); + await Staking.connect(delegators[1]).claimDelegatorRewards( + nodes[0].identityId, + 4n, + delegators[1].address, + ); + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, + 4n, + delegators[2].address, + ); + await Staking.connect(delegators[3]).claimDelegatorRewards( + nodes[1].identityId, + 4n, + delegators[3].address, + ); + + console.log(' ✅ All delegators successfully claimed epoch 4 rewards'); + + // Get rolling rewards after epoch 4 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Calculate epoch 4 rewards + const d1Epoch4Reward = d1RollingAfter - d1RollingBefore; + const d2Epoch4Reward = d2RollingAfter - d2RollingBefore; + const d3Epoch4Reward = d3RollingAfter - d3RollingBefore; + const d4Epoch4Reward = d4RollingAfter - d4RollingBefore; + + console.log( + ` 💰 D1 epoch 4 reward: ${hre.ethers.formatUnits(d1Epoch4Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D2 epoch 4 reward: ${hre.ethers.formatUnits(d2Epoch4Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D3 epoch 4 reward: ${hre.ethers.formatUnits(d3Epoch4Reward, 18)} TRAC`, + ); + console.log( + ` 💰 D4 epoch 4 reward: ${hre.ethers.formatUnits(d4Epoch4Reward, 18)} TRAC`, + ); + + // Verify proportional rewards continue (allow small rounding differences) + const d2ToD1Epoch4Ratio = Number(d2Epoch4Reward) / Number(d1Epoch4Reward); + const d4ToD3Epoch4Ratio = Number(d4Epoch4Reward) / Number(d3Epoch4Reward); + + expect(d2ToD1Epoch4Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should get approximately double D1 epoch 4 rewards', + ); + expect(d4ToD3Epoch4Ratio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should get approximately double D3 epoch 4 rewards', + ); + expect(d1Epoch4Reward).to.equal( + d3Epoch4Reward, + 'D1 and D3 should get equal epoch 4 rewards', + ); + expect(d2Epoch4Reward).to.equal( + d4Epoch4Reward, + 'D2 and D4 should get equal epoch 4 rewards', + ); + + // Verify total rolling rewards maintain proportionality (allow small rounding differences) + const d2ToD1TotalRatio4 = Number(d2RollingAfter) / Number(d1RollingAfter); + const d4ToD3TotalRatio4 = Number(d4RollingAfter) / Number(d3RollingAfter); + + expect(d2ToD1TotalRatio4).to.be.closeTo( + 2.0, + 0.001, + 'D2 total rolling should be approximately double D1 total rolling', + ); + expect(d4ToD3TotalRatio4).to.be.closeTo( + 2.0, + 0.001, + 'D4 total rolling should be approximately double D3 total rolling', + ); + + console.log(' ✅ PROPORTIONAL REWARDS VERIFIED FOR EPOCH 4'); + console.log( + ' 📝 Note: Proportional rewards consistently maintained across epochs 2, 3, and 4', + ); + }); + + it('D1, D2, D3, D4 claim epoch 5 rewards - Should get 0 rewards (no proofs) but maintain proportionality', async () => { + const { Staking, DelegatorsInfo, delegators, nodes } = env; + + console.log( + '\n✅ PROPORTIONAL TEST 4: Epoch 5 rewards - 0 rewards but proportionality maintained', + ); + + // Get rolling rewards before epoch 5 claims + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Verify proportional rolling rewards before epoch 5 (allow small rounding differences) + const d2ToD1BeforeRatio = Number(d2RollingBefore) / Number(d1RollingBefore); + const d4ToD3BeforeRatio = Number(d4RollingBefore) / Number(d3RollingBefore); + + expect(d2ToD1BeforeRatio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should have approximately double D1 rolling rewards before epoch 5', + ); + expect(d4ToD3BeforeRatio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should have approximately double D3 rolling rewards before epoch 5', + ); + + // Claim epoch 5 rewards for all delegators (should be 0) + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + 5n, + delegators[0].address, + ); + await Staking.connect(delegators[1]).claimDelegatorRewards( + nodes[0].identityId, + 5n, + delegators[1].address, + ); + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, + 5n, + delegators[2].address, + ); + await Staking.connect(delegators[3]).claimDelegatorRewards( + nodes[1].identityId, + 5n, + delegators[3].address, + ); + + console.log( + ' ✅ All delegators successfully claimed epoch 5 rewards (0 TRAC each)', + ); + + // Get rolling rewards after epoch 5 claims + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + // Verify no change in rolling rewards (no rewards from epoch 5) + expect(d1RollingAfter).to.equal( + d1RollingBefore, + 'D1 rolling rewards should not change', + ); + expect(d2RollingAfter).to.equal( + d2RollingBefore, + 'D2 rolling rewards should not change', + ); + expect(d3RollingAfter).to.equal( + d3RollingBefore, + 'D3 rolling rewards should not change', + ); + expect(d4RollingAfter).to.equal( + d4RollingBefore, + 'D4 rolling rewards should not change', + ); + + // Verify proportionality is still maintained (allow small rounding differences) + const d2ToD1AfterRatio = Number(d2RollingAfter) / Number(d1RollingAfter); + const d4ToD3AfterRatio = Number(d4RollingAfter) / Number(d3RollingAfter); + + expect(d2ToD1AfterRatio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should still have approximately double D1 rolling rewards', + ); + expect(d4ToD3AfterRatio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should still have approximately double D3 rolling rewards', + ); + + console.log( + ` 💰 All delegators got 0 TRAC from epoch 5 (no proofs submitted)`, + ); + console.log( + ` 🔄 D1 total rolling: ${hre.ethers.formatUnits(d1RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D2 total rolling: ${hre.ethers.formatUnits(d2RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 total rolling: ${hre.ethers.formatUnits(d3RollingAfter, 18)} TRAC`, + ); + console.log( + ` 🔄 D4 total rolling: ${hre.ethers.formatUnits(d4RollingAfter, 18)} TRAC`, + ); + console.log(' ✅ PROPORTIONALITY MAINTAINED: D2/D1 = D4/D3 = 2.0'); + console.log( + " 📝 Note: Zero rewards don't break proportional relationships", + ); + }); + + it('D1, D2, D3, D4 claim epoch 6 rewards - Final claim transfers rolling rewards to stakeBase proportionally', async () => { + const { Staking, StakingStorage, DelegatorsInfo, delegators, nodes } = env; + + console.log( + '\n✅ PROPORTIONAL TEST 5: Epoch 6 final claim - Proportional transfer to stakeBase', + ); + + // Get states before epoch 6 claims + const d1Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[0].address]), + ); + const d2Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[1].address]), + ); + const d3Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[2].address]), + ); + const d4Key = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegators[3].address]), + ); + + const d1StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d1Key, + ); + const d2StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d2Key, + ); + const d3StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d3Key, + ); + const d4StakeBaseBefore = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d4Key, + ); + + const d1RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingBefore = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + console.log( + ` 💎 D1 stakeBase before: ${hre.ethers.formatUnits(d1StakeBaseBefore, 18)} TRAC`, + ); + console.log( + ` 💎 D2 stakeBase before: ${hre.ethers.formatUnits(d2StakeBaseBefore, 18)} TRAC`, + ); + console.log( + ` 💎 D3 stakeBase before: ${hre.ethers.formatUnits(d3StakeBaseBefore, 18)} TRAC`, + ); + console.log( + ` 💎 D4 stakeBase before: ${hre.ethers.formatUnits(d4StakeBaseBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D1 rolling before: ${hre.ethers.formatUnits(d1RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D2 rolling before: ${hre.ethers.formatUnits(d2RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D3 rolling before: ${hre.ethers.formatUnits(d3RollingBefore, 18)} TRAC`, + ); + console.log( + ` 🔄 D4 rolling before: ${hre.ethers.formatUnits(d4RollingBefore, 18)} TRAC`, + ); + + // Verify proportional rolling rewards before final claim (allow small rounding differences) + const d2ToD1BeforeFinalRatio = + Number(d2RollingBefore) / Number(d1RollingBefore); + const d4ToD3BeforeFinalRatio = + Number(d4RollingBefore) / Number(d3RollingBefore); + + expect(d2ToD1BeforeFinalRatio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should have approximately double D1 rolling rewards', + ); + expect(d4ToD3BeforeFinalRatio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should have approximately double D3 rolling rewards', + ); + + // Claim epoch 6 rewards for all delegators (final claim - should transfer to stakeBase) + await Staking.connect(delegators[0]).claimDelegatorRewards( + nodes[0].identityId, + 6n, + delegators[0].address, + ); + await Staking.connect(delegators[1]).claimDelegatorRewards( + nodes[0].identityId, + 6n, + delegators[1].address, + ); + await Staking.connect(delegators[2]).claimDelegatorRewards( + nodes[1].identityId, + 6n, + delegators[2].address, + ); + await Staking.connect(delegators[3]).claimDelegatorRewards( + nodes[1].identityId, + 6n, + delegators[3].address, + ); + + console.log( + ' ✅ All delegators successfully claimed epoch 6 rewards (final claim)', + ); + + // Get states after epoch 6 claims + const d1StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d1Key, + ); + const d2StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[0].identityId, + d2Key, + ); + const d3StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d3Key, + ); + const d4StakeBaseAfter = await StakingStorage.getDelegatorStakeBase( + nodes[1].identityId, + d4Key, + ); + + const d1RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[0].address, + ); + const d2RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[0].identityId, + delegators[1].address, + ); + const d3RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[2].address, + ); + const d4RollingAfter = await DelegatorsInfo.getDelegatorRollingRewards( + nodes[1].identityId, + delegators[3].address, + ); + + console.log( + ` 💎 D1 stakeBase after: ${hre.ethers.formatUnits(d1StakeBaseAfter, 18)} TRAC`, + ); + console.log( + ` 💎 D2 stakeBase after: ${hre.ethers.formatUnits(d2StakeBaseAfter, 18)} TRAC`, + ); + console.log( + ` 💎 D3 stakeBase after: ${hre.ethers.formatUnits(d3StakeBaseAfter, 18)} TRAC`, + ); + console.log( + ` 💎 D4 stakeBase after: ${hre.ethers.formatUnits(d4StakeBaseAfter, 18)} TRAC`, + ); + + // Verify rolling rewards were transferred to stakeBase + expect(d1RollingAfter).to.equal( + 0n, + 'D1 rolling rewards should be 0 after final claim', + ); + expect(d2RollingAfter).to.equal( + 0n, + 'D2 rolling rewards should be 0 after final claim', + ); + expect(d3RollingAfter).to.equal( + 0n, + 'D3 rolling rewards should be 0 after final claim', + ); + expect(d4RollingAfter).to.equal( + 0n, + 'D4 rolling rewards should be 0 after final claim', + ); + + // Calculate total rewards transferred + const d1TotalRewards = d1StakeBaseAfter - d1StakeBaseBefore; + const d2TotalRewards = d2StakeBaseAfter - d2StakeBaseBefore; + const d3TotalRewards = d3StakeBaseAfter - d3StakeBaseBefore; + const d4TotalRewards = d4StakeBaseAfter - d4StakeBaseBefore; + + console.log( + ` 🎁 D1 total rewards transferred: ${hre.ethers.formatUnits(d1TotalRewards, 18)} TRAC`, + ); + console.log( + ` 🎁 D2 total rewards transferred: ${hre.ethers.formatUnits(d2TotalRewards, 18)} TRAC`, + ); + console.log( + ` 🎁 D3 total rewards transferred: ${hre.ethers.formatUnits(d3TotalRewards, 18)} TRAC`, + ); + console.log( + ` 🎁 D4 total rewards transferred: ${hre.ethers.formatUnits(d4TotalRewards, 18)} TRAC`, + ); + + // Verify proportional final rewards (allow small rounding differences) + const d2ToD1FinalRewardsRatio = + Number(d2TotalRewards) / Number(d1TotalRewards); + const d4ToD3FinalRewardsRatio = + Number(d4TotalRewards) / Number(d3TotalRewards); + + expect(d2ToD1FinalRewardsRatio).to.be.closeTo( + 2.0, + 0.001, + 'D2 should get approximately double D1 total rewards', + ); + expect(d4ToD3FinalRewardsRatio).to.be.closeTo( + 2.0, + 0.001, + 'D4 should get approximately double D3 total rewards', + ); + expect(d1TotalRewards).to.equal( + d3TotalRewards, + 'D1 and D3 should get equal total rewards', + ); + expect(d2TotalRewards).to.equal( + d4TotalRewards, + 'D2 and D4 should get equal total rewards', + ); + + // Verify final stakeBase proportions + const d1FinalStake = d1StakeBaseAfter; + const d2FinalStake = d2StakeBaseAfter; + const d3FinalStake = d3StakeBaseAfter; + const d4FinalStake = d4StakeBaseAfter; + + // Since D2 started with 2x D1 stake and got 2x rewards, final ratio should be maintained + // But exact 2x ratio might not hold due to rounding, so we check approximate ratios + const d2ToD1Ratio = Number(d2FinalStake) / Number(d1FinalStake); + const d4ToD3Ratio = Number(d4FinalStake) / Number(d3FinalStake); + + console.log( + ` 📊 Final D2/D1 stakeBase ratio: ${d2ToD1Ratio.toFixed(6)}`, + ); + console.log( + ` 📊 Final D4/D3 stakeBase ratio: ${d4ToD3Ratio.toFixed(6)}`, + ); + + // Ratios should be close to 2.0 but might have small deviations due to rounding + expect(d2ToD1Ratio).to.be.closeTo( + 2.0, + 0.01, + 'D2/D1 final stakeBase ratio should be close to 2.0', + ); + expect(d4ToD3Ratio).to.be.closeTo( + 2.0, + 0.01, + 'D4/D3 final stakeBase ratio should be close to 2.0', + ); + + console.log(' ✅ PROPORTIONAL REWARDS SYSTEM VERIFIED:'); + console.log( + ' 📈 Double stake consistently resulted in double rewards across all epochs', + ); + console.log(' 💰 Final stakeBase maintains proportional relationships'); + console.log(' 🎯 Reward system is fair and predictable'); + console.log( + ' 📝 Note: Proportional rewards successfully transferred to permanent stakeBase', + ); + }); +}); diff --git a/test/integration/randomSampling.integrtion.test.ts b/test/integration/randomSampling.integrtion.test.ts new file mode 100644 index 00000000..a59a6533 --- /dev/null +++ b/test/integration/randomSampling.integrtion.test.ts @@ -0,0 +1,2681 @@ +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +// @ts-expect-error: No type definitions available for assertion-tools +import { kcTools } from 'assertion-tools'; +import { expect } from 'chai'; +import hre, { ethers } from 'hardhat'; + +import { + RandomSampling, + RandomSamplingStorage, + IdentityStorage, + StakingStorage, + KnowledgeCollectionStorage, + ProfileStorage, + EpochStorage, + Chronos, + AskStorage, + DelegatorsInfo, + Profile, + Hub, + Token, + KnowledgeCollection, + ParanetKnowledgeMinersRegistry, + ParanetKnowledgeCollectionsRegistry, + Staking, + ShardingTableStorage, + ShardingTable, + ParametersStorage, + Ask, +} from '../../typechain'; +import { createKnowledgeCollection } from '../helpers/kc-helpers'; +import { createProfile, createProfiles } from '../helpers/profile-helpers'; +import { + getDefaultKCCreator, + getDefaultReceivingNodes, + getDefaultPublishingNode, + setupNodeWithStakeAndAsk, + setNodeStake, +} from '../helpers/setup-helpers'; + +// Sample values for tests +const avgBlockTimeInSeconds = 1; // Average block time +const SCALING_FACTOR = 10n ** 18n; +const quads = [ + ' "468.9 sq mi" .', + ' "New York" .', + ' "8,336,817" .', + ' "New York" .', + ' .', + ' "0xaac2a420672a1eb77506c544ff01beed2be58c0ee3576fe037c846f97481cefd" .', + ' .', + ' .', + ' .', +]; +// Generate the Merkle tree and get the root +const merkleRoot = kcTools.calculateMerkleRoot(quads, 32); + +// Fixture containing all contracts and accounts needed to test RandomSampling +type RandomSamplingFixture = { + accounts: SignerWithAddress[]; + RandomSampling: RandomSampling; + RandomSamplingStorage: RandomSamplingStorage; + IdentityStorage: IdentityStorage; + StakingStorage: StakingStorage; + KnowledgeCollectionStorage: KnowledgeCollectionStorage; + ProfileStorage: ProfileStorage; + EpochStorage: EpochStorage; + Chronos: Chronos; + AskStorage: AskStorage; + DelegatorsInfo: DelegatorsInfo; + Profile: Profile; + Hub: Hub; + KnowledgeCollection: KnowledgeCollection; + Token: Token; + ParanetKnowledgeMinersRegistry: ParanetKnowledgeMinersRegistry; + ParanetKnowledgeCollectionsRegistry: ParanetKnowledgeCollectionsRegistry; + Staking: Staking; + ShardingTableStorage: ShardingTableStorage; + ShardingTable: ShardingTable; + ParametersStorage: ParametersStorage; + Ask: Ask; +}; + +async function calculateExpectedNodeScore( + identityId: bigint, + nodeStake: bigint, + deps: { + ParametersStorage: ParametersStorage; + ProfileStorage: ProfileStorage; + AskStorage: AskStorage; + EpochStorage: EpochStorage; + }, +): Promise { + const { ParametersStorage, ProfileStorage, AskStorage, EpochStorage } = deps; + // Cap stake at maximum + const maximumStake = await ParametersStorage.maximumStake(); + const cappedStake = nodeStake > maximumStake ? maximumStake : nodeStake; + + // 1. Stake Factor + const stakeDivisor = 2000000n; // Magic number from contract + const stakeRatio = cappedStake / stakeDivisor; + const nodeStakeFactor = (2n * stakeRatio ** 2n) / SCALING_FACTOR; + + // 2. Ask Factor + const nodeAsk = await ProfileStorage.getAsk(identityId); + const nodeAskScaled = nodeAsk * SCALING_FACTOR; + const [askLowerBound, askUpperBound] = await AskStorage.getAskBounds(); + let nodeAskFactor = 0n; + + if (nodeAskScaled <= askUpperBound && nodeAskScaled >= askLowerBound) { + const askBoundsDiff = askUpperBound - askLowerBound; + if (askBoundsDiff > 0n) { + // Prevent division by zero + const askDiffRatio = + ((askUpperBound - nodeAskScaled) * SCALING_FACTOR) / askBoundsDiff; + // Ensure intermediate multiplication doesn't overflow - use SCALING_FACTOR**2n directly + nodeAskFactor = + (stakeRatio * askDiffRatio ** 2n) / (SCALING_FACTOR * SCALING_FACTOR); + } else { + // If bounds are equal and ask matches, ratio is effectively 0 or 1 depending on perspective, + // but safer to assign 0 as boundsDiff is 0. + nodeAskFactor = 0n; + } + } + + // 3. Publishing Factor + // Assuming this test runs in an epoch where production has happened + const nodePubFactor = + await EpochStorage.getNodeCurrentEpochProducedKnowledgeValue(identityId); + const maxNodePubFactor = + await EpochStorage.getCurrentEpochNodeMaxProducedKnowledgeValue(); + let nodePublishingFactor = 0n; + if (maxNodePubFactor > 0n) { + // Prevent division by zero + const pubRatio = (nodePubFactor * SCALING_FACTOR) / maxNodePubFactor; + nodePublishingFactor = (nodeStakeFactor * pubRatio) / SCALING_FACTOR; + } + + return nodeStakeFactor + nodePublishingFactor + nodeAskFactor; +} + +describe('@integration RandomSampling', () => { + let accounts: SignerWithAddress[]; + let RandomSampling: RandomSampling; + let RandomSamplingStorage: RandomSamplingStorage; + let IdentityStorage: IdentityStorage; + let StakingStorage: StakingStorage; + let KnowledgeCollectionStorage: KnowledgeCollectionStorage; + let ProfileStorage: ProfileStorage; + let EpochStorage: EpochStorage; + let Chronos: Chronos; + let AskStorage: AskStorage; + let Ask: Ask; + let DelegatorsInfo: DelegatorsInfo; + let Profile: Profile; + let Hub: Hub; + let KnowledgeCollection: KnowledgeCollection; + let Token: Token; + let Staking: Staking; + let ShardingTableStorage: ShardingTableStorage; + let ShardingTable: ShardingTable; + let ParametersStorage: ParametersStorage; + let ParanetKnowledgeMinersRegistry: ParanetKnowledgeMinersRegistry; + let ParanetKnowledgeCollectionsRegistry: ParanetKnowledgeCollectionsRegistry; + + // Deploy all contracts, set the HubOwner and necessary accounts. Returns the RandomSamplingFixture + async function deployRandomSamplingFixture(): Promise { + await hre.deployments.fixture([ + 'KnowledgeCollection', + 'Token', + 'IdentityStorage', + 'StakingStorage', + 'ProfileStorage', + 'EpochStorage', + 'Chronos', + 'AskStorage', + 'DelegatorsInfo', + 'Profile', + 'RandomSamplingStorage', + 'RandomSampling', + 'ParanetKnowledgeMinersRegistry', + 'ParanetKnowledgeCollectionsRegistry', + 'Staking', + 'Ask', + ]); + + accounts = await hre.ethers.getSigners(); + Hub = await hre.ethers.getContract('Hub'); + + // Set hub owner + await Hub.setContractAddress('HubOwner', accounts[0].address); + + // Get contract instances + KnowledgeCollection = await hre.ethers.getContract( + 'KnowledgeCollection', + ); + Token = await hre.ethers.getContract('Token'); + ParanetKnowledgeMinersRegistry = + await hre.ethers.getContract( + 'ParanetKnowledgeMinersRegistry', + ); + ParanetKnowledgeCollectionsRegistry = + await hre.ethers.getContract( + 'ParanetKnowledgeCollectionsRegistry', + ); + IdentityStorage = + await hre.ethers.getContract('IdentityStorage'); + StakingStorage = + await hre.ethers.getContract('StakingStorage'); + KnowledgeCollectionStorage = + await hre.ethers.getContract( + 'KnowledgeCollectionStorage', + ); + ProfileStorage = + await hre.ethers.getContract('ProfileStorage'); + EpochStorage = await hre.ethers.getContract('EpochStorageV8'); + Chronos = await hre.ethers.getContract('Chronos'); + AskStorage = await hre.ethers.getContract('AskStorage'); + DelegatorsInfo = + await hre.ethers.getContract('DelegatorsInfo'); + Profile = await hre.ethers.getContract('Profile'); + Staking = await hre.ethers.getContract('Staking'); + ShardingTableStorage = await hre.ethers.getContract( + 'ShardingTableStorage', + ); + ShardingTable = + await hre.ethers.getContract('ShardingTable'); + ParametersStorage = + await hre.ethers.getContract('ParametersStorage'); + Ask = await hre.ethers.getContract('Ask'); + + // Get RandomSampling contract after all others are registered + RandomSampling = + await hre.ethers.getContract('RandomSampling'); + RandomSamplingStorage = await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + + // Now initialize RandomSampling manually if needed + // This might not be necessary if initialization happens automatically in the deployment + + return { + accounts, + RandomSampling, + RandomSamplingStorage, + IdentityStorage, + StakingStorage, + KnowledgeCollectionStorage, + ProfileStorage, + EpochStorage, + Chronos, + AskStorage, + DelegatorsInfo, + Profile, + Hub, + KnowledgeCollection, + Token, + ParanetKnowledgeMinersRegistry, + ParanetKnowledgeCollectionsRegistry, + Staking, + ShardingTableStorage, + ShardingTable, + ParametersStorage, + Ask, + }; + } + + // Before each test, deploy all contracts and necessary accounts. These variables can be used in the tests + beforeEach(async () => { + ({ + accounts, + IdentityStorage, + StakingStorage, + KnowledgeCollectionStorage, + ProfileStorage, + EpochStorage, + Chronos, + AskStorage, + DelegatorsInfo, + Profile, + Hub, + RandomSampling, + RandomSamplingStorage, + ParanetKnowledgeMinersRegistry, + ParanetKnowledgeCollectionsRegistry, + Staking, + ShardingTableStorage, + ShardingTable, + ParametersStorage, + Ask, + } = await loadFixture(deployRandomSamplingFixture)); + }); + + describe('Contract Initialization', () => { + it('Should return the correct name and version of the RandomSampling contract', async () => { + const name = await RandomSampling.name(); + const version = await RandomSampling.version(); + expect(name).to.equal('RandomSampling'); + expect(version).to.equal('1.0.0'); + }); + + it('Should have the correct avgBlockTimeInSeconds after initialization', async () => { + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + expect(avgBlockTime).to.equal(avgBlockTimeInSeconds); + }); + + it('Should have the correct W1 after initialization', async () => { + const W1 = await RandomSampling.w1(); + expect(W1).to.equal(0); + }); + + it('Should have the correct W2 after initialization', async () => { + const W2 = await RandomSampling.w2(); + expect(W2).to.equal(2); + }); + + it('Should successfully initialize with all dependent contracts', async () => { + // Verify that all contract references are set correctly + expect(await RandomSampling.identityStorage()).to.equal( + await IdentityStorage.getAddress(), + ); + expect(await RandomSampling.randomSamplingStorage()).to.equal( + await RandomSamplingStorage.getAddress(), + ); + expect(await RandomSampling.knowledgeCollectionStorage()).to.equal( + await KnowledgeCollectionStorage.getAddress(), + ); + expect(await RandomSampling.stakingStorage()).to.equal( + await StakingStorage.getAddress(), + ); + expect(await RandomSampling.profileStorage()).to.equal( + await ProfileStorage.getAddress(), + ); + expect(await RandomSampling.epochStorage()).to.equal( + await EpochStorage.getAddress(), + ); + expect(await RandomSampling.chronos()).to.equal( + await Chronos.getAddress(), + ); + expect(await RandomSampling.askStorage()).to.equal( + await AskStorage.getAddress(), + ); + expect(await RandomSampling.delegatorsInfo()).to.equal( + await DelegatorsInfo.getAddress(), + ); + }); + }); + + describe('Proofing Period Duration Management', () => { + it('Should add proofing period duration if none is pending', async () => { + // Setup + const currentEpoch = await Chronos.getCurrentEpoch(); + const initialDuration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + const newDuration = initialDuration + 10n; + const expectedEffectiveEpoch = currentEpoch + 1n; + const hubOwner = accounts[0]; + + // Ensure no pending change initially + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.isPendingProofingPeriodDuration(), + 'Should be no pending duration initially', + ).to.be.false; + + // Action + const setDurationTx = + await RandomSampling.connect( + hubOwner, + ).setProofingPeriodDurationInBlocks(newDuration); + + // Verification + // 1. Event Emission + await expect(setDurationTx) + .to.emit(RandomSamplingStorage, 'ProofingPeriodDurationAdded') + .withArgs(newDuration, expectedEffectiveEpoch); + + // 2. Pending state updated + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.isPendingProofingPeriodDuration(), + 'Should be a pending duration after setting', + ).to.be.true; + + // 3. Active duration remains unchanged in the current epoch + expect( + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(), + 'Active duration should remain unchanged in current epoch', + ).to.equal(initialDuration); + }); + + it('Should replace pending proofing period duration if one exists', async () => { + // Setup + const currentEpoch = await Chronos.getCurrentEpoch(); + const avgBlockTimeInSeconds = + await RandomSampling.avgBlockTimeInSeconds(); + const initialDuration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + const firstNewDuration = initialDuration + 10n; + const secondNewDuration = firstNewDuration + 10n; + const expectedEffectiveEpoch = currentEpoch + 1n; + const hubOwner = accounts[0]; + + // Add the first pending change + await RandomSampling.connect(hubOwner).setProofingPeriodDurationInBlocks( + firstNewDuration, + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.isPendingProofingPeriodDuration(), + 'Should have pending duration after first set', + ).to.be.true; + + // Action: Replace the pending change + const replaceDurationTx = + await RandomSampling.connect( + hubOwner, + ).setProofingPeriodDurationInBlocks(secondNewDuration); + + // Verification + // 1. Event Emission + await expect(replaceDurationTx) + .to.emit(RandomSamplingStorage, 'PendingProofingPeriodDurationReplaced') + .withArgs(firstNewDuration, secondNewDuration, expectedEffectiveEpoch); + + // 2. Pending state remains true + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.isPendingProofingPeriodDuration(), + 'Should still have pending duration after replace', + ).to.be.true; + + // 3. Active duration remains unchanged in the current epoch + expect( + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(), + 'Active duration should remain unchanged', + ).to.equal(initialDuration); + + // 4. Check the actual pending value + // Advance to the effective epoch + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksUntilNextEpoch = + Number(timeUntilNextEpoch) / Number(avgBlockTimeInSeconds) + 10; + for (let i = 0; i < blocksUntilNextEpoch; i++) { + await hre.network.provider.send('evm_mine'); + } + + expect( + await Chronos.getCurrentEpoch(), + 'Should be in the next epoch', + ).to.equal(expectedEffectiveEpoch); + expect( + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(), + 'Active duration should be updated in effective epoch', + ).to.equal(secondNewDuration); + }); + + it('Should correctly apply the new duration only in the effective epoch', async () => { + // Setup + const currentEpoch = await Chronos.getCurrentEpoch(); + const effectiveEpoch = currentEpoch + 1n; + const initialDuration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + const newDuration = initialDuration + 20n; // Different new duration + const hubOwner = accounts[0]; + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + + // Schedule change for next epoch + await RandomSampling.connect(hubOwner).setProofingPeriodDurationInBlocks( + newDuration, + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.isPendingProofingPeriodDuration(), + 'Duration change should be pending', + ).to.be.true; + + // Ensure activeProofPeriodStartBlock is initialized if needed + let initialStartBlockE = ( + await RandomSamplingStorage.getActiveProofPeriodStatus() + ).activeProofPeriodStartBlock; + if (initialStartBlockE === 0n) { + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + initialStartBlockE = ( + await RandomSamplingStorage.getActiveProofPeriodStatus() + ).activeProofPeriodStartBlock; + } + expect(initialStartBlockE).to.be.greaterThan( + 0n, + 'Initial start block should be > 0', + ); + + // --- Verification in Current Epoch (Epoch E) --- + expect( + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(), + 'Active duration should be initial in Epoch E', + ).to.equal(initialDuration); + + // Advance blocks within Epoch E by the initial duration + for (let i = 0; i < Number(initialDuration); i++) { + await hre.network.provider.send('evm_mine'); + } + + // Update period and check if it used the initial duration + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + const updatedStartBlockE = ( + await RandomSamplingStorage.getActiveProofPeriodStatus() + ).activeProofPeriodStartBlock; + expect(updatedStartBlockE).to.equal( + initialStartBlockE + initialDuration, + 'Start block should advance by initial duration in Epoch E', + ); + + // --- Advance to Next Epoch (Epoch E+1) --- + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksUntilNextEpoch = + timeUntilNextEpoch > 0n + ? Number(timeUntilNextEpoch / avgBlockTime) + 1 // Ensure we pass the epoch boundary + : 1; // If already at boundary, just mine one block + for (let i = 0; i < blocksUntilNextEpoch; i++) { + await hre.network.provider.send('evm_mine'); + } + + expect( + await Chronos.getCurrentEpoch(), + 'Should now be in the effective epoch', + ).to.equal(effectiveEpoch); + + // --- Verification in Effective Epoch (Epoch E+1) --- + expect( + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(), + 'Active duration should be new in Epoch E+1', + ).to.equal(newDuration); + + // Get the start block relevant for this new epoch + // It might have carried over or been updated by the block advance + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + const startBlockE1 = ( + await RandomSamplingStorage.getActiveProofPeriodStatus() + ).activeProofPeriodStartBlock; + + // Advance blocks within Epoch E+1 by the *new* duration + for (let i = 0; i < Number(newDuration); i++) { + await hre.network.provider.send('evm_mine'); + } + + // Update period and check if it used the new duration + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + const updatedStartBlockE1 = ( + await RandomSamplingStorage.getActiveProofPeriodStatus() + ).activeProofPeriodStartBlock; + expect(updatedStartBlockE1).to.equal( + startBlockE1 + newDuration, + 'Start block should advance by new duration in Epoch E+1', + ); + }); + }); + + describe('Challenge Creation', () => { + it('Should revert if an unsolved challenge already exists for this node in the current proof period', async () => { + // creator of the KC + const kcCreator = getDefaultKCCreator(accounts); + // create a publishing node with stake and ask + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const minStake = await ParametersStorage.minimumStake(); + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, 100n, deps); + // create receiving nodes + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Create first challenge + const tx1 = await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await tx1.wait(); + const challenge1 = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Mine some blocks but stay within the period + const duration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + if (Number(duration) > 2) { + await hre.network.provider.send('evm_mine'); + } + + // Attempt to create second challenge - should revert + const tx2 = RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await expect(tx2).to.be.revertedWith( + 'An unsolved challenge already exists for this node in the current proof period', + ); + + // Verify stored challenge hasn't changed + const challenge2 = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + expect(challenge2.knowledgeCollectionId).to.equal( + challenge1.knowledgeCollectionId, + ); + expect(challenge2.chunkId).to.equal(challenge1.chunkId); + expect(challenge2.epoch).to.equal(challenge1.epoch); + expect(challenge2.activeProofPeriodStartBlock).to.equal( + challenge1.activeProofPeriodStartBlock, + ); + expect(challenge2.proofingPeriodDurationInBlocks).to.equal( + challenge1.proofingPeriodDurationInBlocks, + ); + expect(challenge2.solved).to.equal(challenge1.solved); // Both false + }); + + it('Should revert if the challenge for this proof period has already been solved', async () => { + // Create profile and identity first + const kcCreator = getDefaultKCCreator(accounts); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const minStake = await ParametersStorage.minimumStake(); + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Create first challenge + const tx1 = await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await tx1.wait(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Mark the challenge as solved + const solvedChallenge = { + knowledgeCollectionId: challenge.knowledgeCollectionId, + chunkId: challenge.chunkId, + knowledgeCollectionStorageContract: + challenge.knowledgeCollectionStorageContract, + epoch: challenge.epoch, + activeProofPeriodStartBlock: challenge.activeProofPeriodStartBlock, + proofingPeriodDurationInBlocks: + challenge.proofingPeriodDurationInBlocks, + solved: true, + }; + + // Store the mock challenge in the storage contract + await RandomSamplingStorage.setNodeChallenge( + publishingNodeIdentityId, + solvedChallenge, + ); + + // Try to create a new challenge for the same period - should revert + const challengeTx = RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await expect(challengeTx).to.be.revertedWith( + 'The challenge for this proof period has already been solved', + ); + }); + + it('Should revert if no Knowledge Collections exist in the system', async () => { + // Setup a node profile + const publishingNode = getDefaultPublishingNode(accounts); + + const { identityId: publishingNodeIdentityId } = await createProfile( + Profile, + publishingNode, + ); + + const minStake = await ParametersStorage.minimumStake(); + await setNodeStake( + publishingNode, + BigInt(publishingNodeIdentityId), + BigInt(minStake), + { + Token, + Staking, + Ask, + }, + ); + await Profile.connect(publishingNode.operational).updateAsk( + BigInt(publishingNodeIdentityId), + 100n, + ); + await Ask.connect(accounts[0]).recalculateActiveSet(); + + // Ensure no KCs are created or they are expired (by default none are created here) + + // Attempt to create challenge + const createTx = RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Verification + await expect(createTx).to.be.revertedWith( + 'No knowledge collections exist', + ); + }); + + it('Should set the node challenge successfully and emit ChallengeCreated event', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const publishingNode = getDefaultPublishingNode(accounts); + const receivingNodes = getDefaultReceivingNodes(accounts); + + // Create profiles and KC first + const contracts = { + Profile, + KnowledgeCollection, + Token, + }; + + const { identityId: publishingNodeIdentityId } = await createProfile( + contracts.Profile, + publishingNode, + ); + const receivingNodesIdentityIds = ( + await createProfiles(contracts.Profile, receivingNodes) + ).map((p) => p.identityId); + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + contracts, + ); + + // Update and get the new active proof period + const tx = + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + await tx.wait(); + + const proofPeriodStatus = + await RandomSamplingStorage.getActiveProofPeriodStatus(); + const proofPeriodStartBlock = + proofPeriodStatus.activeProofPeriodStartBlock; + // Create challenge + const challengeTx = await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const receipt = await challengeTx.wait(); + + // Get the challenge from storage to verify it + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + await expect(receipt) + .to.emit(RandomSampling, 'ChallengeCreated') + .withArgs( + publishingNodeIdentityId, + challenge.epoch, + challenge.knowledgeCollectionId, + challenge.chunkId, + proofPeriodStartBlock, + challenge.proofingPeriodDurationInBlocks, + ); + + const proofPeriodDuration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + + // Verify challenge properties + expect(challenge.knowledgeCollectionId) + .to.be.a('bigint') + .and.to.be.equal(1n); + expect(challenge.chunkId).to.be.a('bigint').and.to.be.greaterThan(0n); + expect(challenge.epoch).to.be.a('bigint').and.to.be.equal(1n); + expect(challenge.activeProofPeriodStartBlock) + .to.be.a('bigint') + .and.to.be.equal(proofPeriodStartBlock); + expect(challenge.proofingPeriodDurationInBlocks) + .to.be.a('bigint') + .and.to.be.equal(proofPeriodDuration); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(challenge.solved).to.be.false; + }); + + it('Should revert if it fails to find a Knowledge Collection that is active in the current epoch', async () => { + // Setup: create node profile/stake/ask + const nodeAsk = 200000000000000000n; + const minStake = await ParametersStorage.minimumStake(); + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + // Create a KC but set its endEpoch to be in the past (e.g., epoch 0) + const kcCreator = getDefaultKCCreator(accounts); + const receivingNodes = getDefaultReceivingNodes(accounts); + const receivingNodesIdentityIds = ( + await createProfiles(Profile, receivingNodes) + ).map((p) => p.identityId); + const currentEpoch = await Chronos.getCurrentEpoch(); + expect(currentEpoch).to.be.greaterThan( + 0n, + 'Test requires current epoch > 0', + ); // Ensure test premise is valid + + // Use createKnowledgeCollection helper, setting endEpoch manually if possible, + // or directly interact with KnowledgeCollection contract + + const epochs = 1; + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + ); + + // Advance to epochs + 10 + for (let i = 0; i < epochs + 10; i++) { + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksUntilNextEpoch = + Number(timeUntilNextEpoch) / Number(avgBlockTimeInSeconds) + 5; + for (let i = 0; i < blocksUntilNextEpoch; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + // Action: Call createChallenge + const createTx = RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Verification: Expect revert with the specific message + await expect(createTx).to.be.revertedWith( + 'Failed to find a knowledge collection that is active in the current epoch', + ); + }); + }); + + describe('Proof Submission', () => { + it('Should revert if challenge is no longer active', async () => { + // Setup + const kcCreator = getDefaultKCCreator(accounts); + const publishingNode = getDefaultPublishingNode(accounts); + const receivingNodes = getDefaultReceivingNodes(accounts); + const contracts = { + Profile, + KnowledgeCollection, + Token, + }; + + const { identityId: publishingNodeIdentityId } = await createProfile( + contracts.Profile, + publishingNode, + ); + const receivingNodesIdentityIds = ( + await createProfiles(contracts.Profile, receivingNodes) + ).map((p) => p.identityId); + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + contracts, + ); + const minStake = await ParametersStorage.minimumStake(); + await setNodeStake( + publishingNode, + BigInt(publishingNodeIdentityId), + BigInt(minStake), + { Token, Staking, Ask }, + ); + await Profile.connect(publishingNode.operational).updateAsk( + BigInt(publishingNodeIdentityId), + 100n, + ); + await Ask.connect(accounts[0]).recalculateActiveSet(); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Advance time past the end of the proof period + const duration = challenge.proofingPeriodDurationInBlocks; + // Move past the end block + for (let i = 0; i < Number(duration) + 1; i++) { + await hre.network.provider.send('evm_mine'); + } + + // Try to submit proof + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + const submitProofTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(challengeChunk, proof); + + // We check the return value if the function signature allows, or check state hasn't changed. + // In this case, checking the stored challenge state: + await expect(submitProofTx).to.be.revertedWith( + 'This challenge is no longer active', + ); + }); + + it("Should revert with MerkleRootMismatchError if merkle roots don't match", async () => { + // Setup + const kcCreator = getDefaultKCCreator(accounts); + const publishingNode = getDefaultPublishingNode(accounts); + const receivingNodes = getDefaultReceivingNodes(accounts); + const contracts = { + Profile, + KnowledgeCollection, + Token, + }; + + const { identityId: publishingNodeIdentityId } = await createProfile( + contracts.Profile, + publishingNode, + ); + const receivingNodesIdentityIds = ( + await createProfiles(contracts.Profile, receivingNodes) + ).map((p) => p.identityId); + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + contracts, + ); + const minStake = await ParametersStorage.minimumStake(); + await setNodeStake( + publishingNode, + BigInt(publishingNodeIdentityId), + BigInt(minStake), + { Token, Staking, Ask }, + ); + await Profile.connect(publishingNode.operational).updateAsk( + publishingNodeIdentityId, + 100n, + ); + await Ask.connect(accounts[0]).recalculateActiveSet(); + + // Create challenge + const challengeTx = await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await challengeTx.wait(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Generate invalid proof data + const chunks = kcTools.splitIntoChunks(quads, 32); + const correctChunk = chunks[challenge.chunkId]; + const wrongChunk = + challenge.chunkId + 1n < chunks.length + ? chunks[challenge.chunkId + 1n] + : challenge.chunkId > 0 + ? chunks[challenge.chunkId - 1n] + : 'invalid chunk data'; + const { proof: correctProof } = kcTools.calculateMerkleProof( + quads, + 32, + challenge.chunkId, + ); + const wrongProof: string[] = []; + + // Try submitting correct proof with wrong chunk + const submitWrongChunkTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(wrongChunk, correctProof); + await expect(submitWrongChunkTx).to.be.revertedWithCustomError( + RandomSampling, + 'MerkleRootMismatchError', + ); + let finalChallenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(finalChallenge.solved).to.be.false; // Ensure state unchanged + + // Try submitting correct chunk with wrong proof + const submitWrongProofTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(correctChunk, wrongProof); + await expect(submitWrongProofTx).to.be.revertedWithCustomError( + RandomSampling, + 'MerkleRootMismatchError', + ); + finalChallenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(finalChallenge.solved).to.be.false; // Ensure state unchanged + }); + + it('Should submit a valid proof and successfully update challenge state (solved=true)', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Update and get the new active proof period + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + + // Create challenge + const challengeTx = await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + await challengeTx.wait(); + + // Get the challenge from storage to verify it + let challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Get chunk from quads + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + + // Generate a proof for our challenge chunk + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + // Submit proof + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof, + ); + + // Get the challenge from storage to verify it + challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Since the challenge was solved, the challenge should be marked as solved + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(challenge.solved).to.be.true; + }); + + it('Should submit a valid proof and successfully increment epochNodeValidProofsCount', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Update and get the new active proof period + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Get the challenge from storage to verify it + let challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Get chunk from quads + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + + // Generate a proof for our challenge chunk + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + // Submit proof + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof, + ); + + // Get the challenge from storage to verify it + challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Verify that epochNodeValidProofsCount was incremented + const epochNodeValidProofsCount = + await RandomSamplingStorage.getEpochNodeValidProofsCount( + challenge.epoch, + publishingNodeIdentityId, + ); + expect(epochNodeValidProofsCount).to.equal(1n); + }); + + it('Should submit a valid proof and successfully emit ValidProofSubmitted event with correct parameters', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Update and get the new active proof period + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Get the challenge from storage to verify it + let challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Get chunk from quads + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + + // Generate a proof for our challenge chunk + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + // Submit proof + const receipt = await RandomSampling.connect( + publishingNode.operational, + ).submitProof(challengeChunk, proof); + + // Get the challenge from storage to verify it + challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Verify that epochNodeValidProofsCount was incremented + await expect(receipt) + .to.emit(RandomSampling, 'ValidProofSubmitted') + .withArgs( + publishingNodeIdentityId, + challenge.epoch, + (score: bigint) => score > 0, + ); + }); + + it('Should submit a valid proof and successfully and add score to nodeEpochProofPeriodScore and allNodesEpochProofPeriodScore', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Update and get the new active proof period + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Get the challenge from storage to verify it + let challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Get chunk from quads + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + + // Generate a proof for our challenge chunk + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + // Submit proof + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof, + ); + + // Get the challenge from storage to verify it + challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + const expectedScore = await calculateExpectedNodeScore( + BigInt(publishingNodeIdentityId), + BigInt(minStake), + { + ParametersStorage, + ProfileStorage, + AskStorage, + EpochStorage, + }, + ); + + expect( + await RandomSamplingStorage.getNodeEpochProofPeriodScore( + publishingNodeIdentityId, + challenge.epoch, + challenge.activeProofPeriodStartBlock, + ), + ).to.equal(expectedScore); + + expect( + await RandomSamplingStorage.getEpochAllNodesProofPeriodScore( + challenge.epoch, + + challenge.activeProofPeriodStartBlock, + ), + ).to.equal(expectedScore); + }); + + it('Should succeed if submitting proof exactly on the last block of the period', async () => { + // Setup + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + const startBlock = challenge.activeProofPeriodStartBlock; + const duration = challenge.proofingPeriodDurationInBlocks; + const targetBlock = startBlock + duration - 2n; + const currentBlock = BigInt(await hre.ethers.provider.getBlockNumber()); + + // Advance blocks to exactly S + D - 1 + if (targetBlock > currentBlock) { + const blocksToMine = Number(targetBlock - currentBlock); + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + expect(BigInt(await hre.ethers.provider.getBlockNumber())).to.equal( + targetBlock, + ); + + // Action: Prepare and submit proof + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + const submitTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(challengeChunk, proof); + + // Verification + await expect(submitTx).to.not.be.reverted; + + const updatedChallenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(updatedChallenge.solved).to.be.true; + + const expectedScore = await calculateExpectedNodeScore( + BigInt(publishingNodeIdentityId), + BigInt(minStake), + { + ParametersStorage, + ProfileStorage, + AskStorage, + EpochStorage, + }, + ); + expect( + await RandomSamplingStorage.getNodeEpochProofPeriodScore( + publishingNodeIdentityId, + challenge.epoch, + startBlock, + ), + ).to.equal(expectedScore); + + await expect(submitTx) + .to.emit(RandomSampling, 'ValidProofSubmitted') + .withArgs( + publishingNodeIdentityId, + challenge.epoch, + (score: bigint) => score.toString() === expectedScore.toString(), + ); + }); + + it('Should revert if submitting proof exactly on the first block of the next period', async () => { + // Setup + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + const startBlock = challenge.activeProofPeriodStartBlock; + const duration = challenge.proofingPeriodDurationInBlocks; + const targetBlock = startBlock + duration - 1n; + const currentBlock = BigInt(await hre.ethers.provider.getBlockNumber()); + + // Advance blocks to exactly S + D + if (targetBlock > currentBlock) { + const blocksToMine = Number(targetBlock - currentBlock); + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + expect(BigInt(await hre.ethers.provider.getBlockNumber())).to.equal( + targetBlock, + ); + + // Action: Prepare and submit proof for the previous period + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + const submitTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(challengeChunk, proof); + + // Verification + await expect(submitTx).to.be.revertedWith( + 'This challenge is no longer active', + ); + }); + + it('Should revert if proof for the same challenge is submitted twice', async () => { + const kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // Same as 0.2 ETH + const deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + const { node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + ); + + // Update and get the new active proof period + await RandomSamplingStorage.updateAndGetActiveProofPeriodStartBlock(); + + // Create challenge + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + + // Get the challenge from storage to verify it + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Get chunk from quads + const chunks = kcTools.splitIntoChunks(quads, 32); + const challengeChunk = chunks[challenge.chunkId]; + + // Generate a proof for our challenge chunk + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + + // Submit proof + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof, + ); + + // Submit proof again + const submitTx = RandomSampling.connect( + publishingNode.operational, + ).submitProof(challengeChunk, proof); + + // Expect revert + await expect(submitTx).to.be.revertedWith( + 'This challenge has already been solved', + ); + }); + }); + + describe('Admin Functions', () => { + it('Should allow only hub owner to update average block time', async () => { + const newAvgBlockTime = 15; + + // Non-hub owner should fail + await expect( + RandomSampling.connect(accounts[1]).setAvgBlockTimeInSeconds( + newAvgBlockTime, + ), + ).to.be.reverted; + + // Hub owner should succeed + await RandomSampling.connect(accounts[0]).setAvgBlockTimeInSeconds( + newAvgBlockTime, + ); + expect(await RandomSampling.avgBlockTimeInSeconds()).to.equal( + newAvgBlockTime, + ); + }); + }); + + describe('Reward Claiming', () => { + let publishingNode: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }; + let publishingNodeIdentityId: number; + let delegatorAccount: SignerWithAddress; + let delegatorKey: string; + let epochToClaim: bigint; + let deps: { + accounts: SignerWithAddress[]; + Profile: Profile; + Token: Token; + Staking: Staking; + Ask: Ask; + KnowledgeCollection: KnowledgeCollection; + ParametersStorage: ParametersStorage; + RandomSampling: RandomSampling; + RandomSamplingStorage: RandomSamplingStorage; + EpochStorage: EpochStorage; + Chronos: Chronos; + StakingStorage: StakingStorage; + IdentityStorage: IdentityStorage; + ShardingTableStorage: ShardingTableStorage; + }; + + // Helper function to advance to the next epoch + const advanceToNextEpoch = async () => { + const timeUntil = await Chronos.timeUntilNextEpoch(); + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + const blocksToMine = + timeUntil > 0n ? Number(timeUntil / BigInt(avgBlockTime)) + 2 : 2; // Add buffer + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + }; + + // Setup common scenario for reward claiming tests + beforeEach(async () => { + delegatorAccount = accounts[1]; + delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [delegatorAccount.address]), + ); + + // Dependencies for setup + deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + ParametersStorage, + RandomSampling, + RandomSamplingStorage, + EpochStorage, + Chronos, + StakingStorage, + IdentityStorage, + ShardingTableStorage, + }; + + const nodeAsk = 200000000000000000n; // 0.2 TRAC ask + const nodeStake = (await ParametersStorage.minimumStake()) * 2n; // Stake more than min + const delegatorStake = nodeStake / 10n; // Delegate a tenth of node's stake + + // 1. Setup Node + ({ node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk( + 2, // Account index offset for setup helper + nodeStake, + nodeAsk, + deps, + )); + + // 2. Setup Receiving Nodes + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 3, + await ParametersStorage.minimumStake(), + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + // 3. Setup Delegator + await Token.mint(delegatorAccount.address, delegatorStake * 2n); + await Token.connect(delegatorAccount).approve( + await Staking.getAddress(), + delegatorStake, + ); + await Staking.connect(delegatorAccount).stake( + publishingNodeIdentityId, + delegatorStake, + ); + + // Verify delegation + expect( + await StakingStorage.getDelegatorTotalStake( + publishingNodeIdentityId, + delegatorKey, + ), + ).to.equal(delegatorStake); + + const stakingStorageAmount = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + + const tokenAmount = ethers.parseEther('100'); + + // 3. Create Knowledge Collection (generates fees for rewards) + const kcCreator = getDefaultKCCreator(accounts); + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, // Use predefined merkleRoot + 'test-operation-id', + 10, + 1000, + 10, // epochsDuration + tokenAmount, + ); + + const stakingStorageAmountAfter = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + + // Verify that the staking storage received the token amount + expect(stakingStorageAmountAfter).to.equal( + stakingStorageAmount + tokenAmount, + ); + + // 4. Node submits a proof in the current epoch + epochToClaim = await Chronos.getCurrentEpoch(); + + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + let challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + let chunks = kcTools.splitIntoChunks(quads, 32); + let challengeChunk = chunks[challenge.chunkId]; + const { proof } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof, + ); + + // Verify proof was counted + expect( + await RandomSamplingStorage.getEpochNodeValidProofsCount( + epochToClaim, + publishingNodeIdentityId, + ), + ).to.equal(1n); + + // Advance to the next proofing period + const proofingPeriodDurationInBlocks = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + for (let i = 0; i < Number(proofingPeriodDurationInBlocks); i++) { + await hre.network.provider.send('evm_mine'); + } + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + chunks = kcTools.splitIntoChunks(quads, 32); + challengeChunk = chunks[challenge.chunkId]; + const { proof: proof2 } = kcTools.calculateMerkleProof( + quads, + 32, + Number(challenge.chunkId), + ); + await RandomSampling.connect(publishingNode.operational).submitProof( + challengeChunk, + proof2, + ); + + // 5. Advance time past the epoch and finalize it + await advanceToNextEpoch(); // Advance to epoch + 1 + await advanceToNextEpoch(); // Advance to epoch + 2 to ensure epoch is finalizable + + await expect( + RandomSampling.connect(delegatorAccount).claimRewards( + publishingNodeIdentityId, + epochToClaim, + ), + ).to.be.revertedWith('Epoch is not finalized yet'); + + // Create another KC to initialize the lazy finalization + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + ); + + // Check finalization (using pool 1 as per RandomSampling logic) + expect( + await EpochStorage.lastFinalizedEpoch(1), + 'Epoch should be finalized', + ).to.be.gte(epochToClaim); + }); + + it('Should allow a delegator to successfully claim their rewards', async () => { + // Arrange + const expectedReward = await RandomSampling.connect( + delegatorAccount, + ).getDelegatorEpochRewardsAmount( + publishingNodeIdentityId, + epochToClaim, + delegatorAccount.address, + ); + expect(expectedReward).to.be.greaterThan( + 0n, + 'Expected reward should be positive', + ); + + const delegatorInitialBalance = await Token.balanceOf( + delegatorAccount.address, + ); + const stakingStorageInitialBalance = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + + // Act + const claimTx = await RandomSampling.connect( + delegatorAccount, + ).claimRewards(publishingNodeIdentityId, epochToClaim); + await claimTx.wait(); + + // Assert + // 1. Event Emission + await expect(claimTx) + .to.emit(RandomSampling, 'RewardsClaimed') + .withArgs( + publishingNodeIdentityId, + epochToClaim, + delegatorAccount.address, + expectedReward, + ); + + // 2. Balance Check + const delegatorFinalBalance = await Token.balanceOf( + delegatorAccount.address, + ); + const stakingStorageFinalBalance = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + expect(delegatorFinalBalance).to.equal( + delegatorInitialBalance + expectedReward, + ); + expect(stakingStorageFinalBalance).to.equal( + stakingStorageInitialBalance - expectedReward, + ); + + // 3. Claim Status Update + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.getEpochNodeDelegatorRewardsClaimed( + epochToClaim, + publishingNodeIdentityId, + delegatorKey, + ), + ).to.be.true; + }); + + it('Should revert if the epoch to claim is not yet over', async () => { + // Arrange: Need a scenario where epoch is *not* over. Let's setup again without advancing time. + await hre.deployments.fixture(['RandomSampling']); // Redeploy for clean state relative to time + ({ + accounts, + IdentityStorage, + StakingStorage, + ProfileStorage, + EpochStorage, + Chronos, + AskStorage, + DelegatorsInfo, + Profile, + Hub, + RandomSampling, + RandomSamplingStorage, + ParanetKnowledgeMinersRegistry, + ParanetKnowledgeCollectionsRegistry, + Staking, + ShardingTableStorage, + ShardingTable, + ParametersStorage, + Ask, + Token, + KnowledgeCollection, + } = await loadFixture(deployRandomSamplingFixture)); // Reload all contracts + + // Redo minimal setup for node and KC within the current epoch + publishingNode = { operational: accounts[1], admin: accounts[1] }; + delegatorAccount = accounts[2]; + deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + ParametersStorage, + RandomSampling, + RandomSamplingStorage, + EpochStorage, + Chronos, + StakingStorage, + IdentityStorage, + ShardingTableStorage, + }; + const nodeStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // 0.2 TRAC ask + + ({ identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, nodeStake, nodeAsk, deps)); + + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 3, + await ParametersStorage.minimumStake(), + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + const kcCreator = getDefaultKCCreator(accounts); + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + 'test-operation-id', + 10, + 1000, + 10, + ); + const currentEpoch = await Chronos.getCurrentEpoch(); + + // Act & Assert + await expect( + RandomSampling.connect(delegatorAccount).claimRewards( + publishingNodeIdentityId, + currentEpoch, // Try claiming for the *current*, not-yet-over epoch + ), + ).to.be.revertedWith('Epoch is not over yet'); + }); + + it('Should revert if rewards have already been claimed', async () => { + // Arrange: Claim rewards once successfully first + const expectedReward = await RandomSampling.connect( + delegatorAccount, + ).getDelegatorEpochRewardsAmount( + publishingNodeIdentityId, + epochToClaim, + delegatorAccount.address, + ); + expect(expectedReward).to.be.greaterThan(0n); + await RandomSampling.connect(delegatorAccount).claimRewards( + publishingNodeIdentityId, + epochToClaim, + ); + + // Verify claimed status + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect( + await RandomSamplingStorage.getEpochNodeDelegatorRewardsClaimed( + epochToClaim, + publishingNodeIdentityId, + delegatorKey, + ), + ).to.be.true; + + // Act & Assert: Try claiming again + await expect( + RandomSampling.connect(delegatorAccount).claimRewards( + publishingNodeIdentityId, + epochToClaim, + ), + ).to.be.revertedWith('Rewards already claimed'); + }); + + it('Should revert if the delegator has no score for the given epoch (e.g., node submitted no proofs)', async () => { + const nodeStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // 0.2 TRAC ask + const delegatorStake = nodeStake / 2n; + ({ node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(15, nodeStake, nodeAsk, deps)); + + // Setup receiving nodes + const receivingNodes = []; + const receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 20, + await ParametersStorage.minimumStake(), + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + + await Token.connect(accounts[0]).transfer( + delegatorAccount.address, + delegatorStake * 2n, + ); + await Token.connect(delegatorAccount).approve( + await Staking.getAddress(), + delegatorStake, + ); + await Staking.connect(delegatorAccount).stake( + publishingNodeIdentityId, + delegatorStake, + ); + const kcCreator = getDefaultKCCreator(accounts); + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + 'test-operation-id', + 10, + 1000, + 10, + ); + epochToClaim = await Chronos.getCurrentEpoch(); + + // *** Crucially, DO NOT submit proof *** + + // Advance past epoch and finalize + await advanceToNextEpoch(); + await advanceToNextEpoch(); + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + 'test-operation-id', + 10, + 1000, + 10, + ); + + // Verify no proofs were counted + expect( + await RandomSamplingStorage.getEpochNodeValidProofsCount( + epochToClaim, + publishingNodeIdentityId, + ), + ).to.equal(0n); + + // Check expected reward is zero + const expectedReward = await RandomSampling.connect( + delegatorAccount, + ).getDelegatorEpochRewardsAmount( + publishingNodeIdentityId, + epochToClaim, + delegatorAccount.address, + ); + expect(expectedReward).to.equal(0n); + + // Act & Assert + await expect( + RandomSampling.connect(delegatorAccount).claimRewards( + publishingNodeIdentityId, + epochToClaim, + ), + ).to.be.revertedWith('Delegator has no score for the given epoch'); + }); + }); + + describe('Delegator rewards', () => { + it('Should revert if allNodesEpochScore is 0 for the given epoch', async () => { + const testIdentityId = 1; + const testEpoch = 1; + const testDelegator = accounts[1].address; + + await expect( + RandomSampling.getDelegatorEpochRewardsAmount( + testIdentityId, + testEpoch, + testDelegator, + ), + ).to.be.revertedWith( + 'None of the nodes have any score for the given epoch. Cannot calculate rewards.', + ); + }); + }); + + describe('Optimized Knowledge Collection Search', () => { + let publishingNode: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }; + let publishingNodeIdentityId: number; + let receivingNodes: { + operational: SignerWithAddress; + admin: SignerWithAddress; + }[]; + let receivingNodesIdentityIds: number[]; + let kcCreator: SignerWithAddress; + let deps: { + accounts: SignerWithAddress[]; + Profile: Profile; + Token: Token; + Staking: Staking; + Ask: Ask; + KnowledgeCollection: KnowledgeCollection; + }; + + beforeEach(async () => { + // Setup nodes + kcCreator = getDefaultKCCreator(accounts); + const minStake = await ParametersStorage.minimumStake(); + const nodeAsk = 200000000000000000n; // 0.2 ETH + + deps = { + accounts, + Profile, + Token, + Staking, + Ask, + KnowledgeCollection, + }; + + ({ node: publishingNode, identityId: publishingNodeIdentityId } = + await setupNodeWithStakeAndAsk(1, minStake, nodeAsk, deps)); + + receivingNodes = []; + receivingNodesIdentityIds = []; + for (let i = 0; i < 5; i++) { + const { node, identityId } = await setupNodeWithStakeAndAsk( + i + 10, + minStake, + nodeAsk, + deps, + ); + receivingNodes.push(node); + receivingNodesIdentityIds.push(identityId); + } + }); + + it('Should find active knowledge collections when mix of active and expired collections exist', async () => { + const initialEpoch = await Chronos.getCurrentEpoch(); + + // Create 20 knowledge collections with different expiration epochs + const collections = []; + + // Create 15 collections that expire in epoch 1 (will be expired) + for (let i = 0; i < 15; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `expired-operation-${i}`, + 10, + 1000, + 1, // epochsDuration = 1, so expires after current epoch + duration + 1 + ); + collections.push({ id: i + 1, active: false }); + } + + // Create 5 collections that expire in epoch 10 (will be active) + for (let i = 15; i < 20; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `active-operation-${i}`, + 10, + 1000, + 10, // epochsDuration = 10, so expires after current epoch + duration + 1 + ); + collections.push({ id: i + 1, active: true }); + } + + // Advance to epoch 5 (so first 15 collections are expired, last 5 are active) + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + for (let epoch = Number(initialEpoch); epoch < 5; epoch++) { + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksToMine = + Number(timeUntilNextEpoch / BigInt(avgBlockTime)) + 2; + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + const currentEpoch = await Chronos.getCurrentEpoch(); + expect(currentEpoch).to.be.gte(5n, 'Should be in epoch 5 or later'); + + // Verify which collections are active/expired + for (const collection of collections) { + const endEpoch = await KnowledgeCollectionStorage.getEndEpoch( + collection.id, + ); + const isActive = currentEpoch <= endEpoch; + if (collection.active) { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(isActive).to.be.true; + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(isActive).to.be.false; + } + } + + // Create challenge multiple times to verify it finds active collections + const foundCollections = new Set(); + const maxAttempts = 20; // Try multiple times to test randomness and consistency + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Move to next proof period to allow new challenge + const duration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + for (let i = 0; i < Number(duration); i++) { + await hre.network.provider.send('evm_mine'); + } + + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // Verify the found collection is one of the active ones + expect([16, 17, 18, 19, 20]).to.include( + Number(challenge.knowledgeCollectionId), + ); + foundCollections.add(Number(challenge.knowledgeCollectionId)); + + // Mark challenge as solved to allow next challenge + const solvedChallenge = { + knowledgeCollectionId: challenge.knowledgeCollectionId, + chunkId: challenge.chunkId, + knowledgeCollectionStorageContract: + challenge.knowledgeCollectionStorageContract, + epoch: challenge.epoch, + activeProofPeriodStartBlock: challenge.activeProofPeriodStartBlock, + proofingPeriodDurationInBlocks: + challenge.proofingPeriodDurationInBlocks, + solved: true, + }; + await RandomSamplingStorage.setNodeChallenge( + publishingNodeIdentityId, + solvedChallenge, + ); + } + + // Verify that the algorithm found at least 2 different active collections (shows it's working properly) + expect(foundCollections.size).to.be.gte( + 2, + 'Should find multiple different active collections', + ); + }); + + it('Should revert when all knowledge collections are expired', async () => { + // Create 3 knowledge collections that expire in epoch 1 + for (let i = 0; i < 3; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `expired-operation-${i}`, + 10, + 1000, + 1, // epochsDuration = 1, expires after epoch 1 + ); + } + + // Advance to epoch 5 (all collections expired) + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + for (let epoch = 1; epoch < 5; epoch++) { + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksToMine = + Number(timeUntilNextEpoch / BigInt(avgBlockTime)) + 2; + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + // Verify all collections are expired + const currentEpoch = await Chronos.getCurrentEpoch(); + for (let kcId = 1; kcId <= 3; kcId++) { + const endEpoch = await KnowledgeCollectionStorage.getEndEpoch(kcId); + expect(currentEpoch).to.be.gt(endEpoch, `KC ${kcId} should be expired`); + } + + // Attempt to create challenge should revert + await expect( + RandomSampling.connect(publishingNode.operational).createChallenge(), + ).to.be.revertedWith( + 'Failed to find a knowledge collection that is active in the current epoch', + ); + }); + + it('Should work efficiently with single active collection among many expired ones', async () => { + // Create 9 expired collections + for (let i = 0; i < 9; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `expired-operation-${i}`, + 10, + 1000, + 1, // epochsDuration = 1, expires after epoch 1 + ); + } + + // Create 1 active collection (will be KC #10) + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + 'active-operation', + 10, + 1000, + 10, // epochsDuration = 10, active for longer + ); + + // Advance to epoch 4 (first 9 expired, last 1 active) + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + for (let epoch = 1; epoch < 4; epoch++) { + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksToMine = + Number(timeUntilNextEpoch / BigInt(avgBlockTime)) + 2; + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + // Verify only the last collection is active + const currentEpoch = await Chronos.getCurrentEpoch(); + for (let kcId = 1; kcId <= 9; kcId++) { + const endEpoch = await KnowledgeCollectionStorage.getEndEpoch(kcId); + expect(currentEpoch).to.be.gt(endEpoch, `KC ${kcId} should be expired`); + } + + const activeKcEndEpoch = await KnowledgeCollectionStorage.getEndEpoch(10); + expect(currentEpoch).to.be.lte( + activeKcEndEpoch, + 'KC 10 should be active', + ); + + // Create challenge should find the active collection + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + expect(challenge.knowledgeCollectionId).to.equal( + 10n, + 'Should find the only active collection', + ); + }); + + it('Should demonstrate randomness by finding different collections over multiple attempts', async () => { + // Create 5 active knowledge collections + for (let i = 0; i < 5; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `active-operation-${i}`, + 10, + 1000, + 10, // All active for 10 epochs + ); + } + + const foundCollections = new Set(); + const maxAttempts = 15; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Move to next proof period + const duration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + for (let i = 0; i < Number(duration); i++) { + await hre.network.provider.send('evm_mine'); + } + + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + // All collections should be active + expect(challenge.knowledgeCollectionId).to.be.gte(1n); + expect(challenge.knowledgeCollectionId).to.be.lte(5n); + foundCollections.add(Number(challenge.knowledgeCollectionId)); + + // Mark as solved for next iteration + const solvedChallenge = { + knowledgeCollectionId: challenge.knowledgeCollectionId, + chunkId: challenge.chunkId, + knowledgeCollectionStorageContract: + challenge.knowledgeCollectionStorageContract, + epoch: challenge.epoch, + activeProofPeriodStartBlock: challenge.activeProofPeriodStartBlock, + proofingPeriodDurationInBlocks: + challenge.proofingPeriodDurationInBlocks, + solved: true, + }; + await RandomSamplingStorage.setNodeChallenge( + publishingNodeIdentityId, + solvedChallenge, + ); + } + + // Should find at least 3 different collections (demonstrates randomness) + expect(foundCollections.size).to.be.gte( + 3, + `Should find multiple collections for randomness. Found: ${Array.from(foundCollections)}`, + ); + }); + + it('Should handle edge case with collections at different positions in the range', async () => { + // Create specific pattern: active-expired-active-expired-active + const patterns = [ + { active: true, duration: 10 }, // KC 1: active + { active: false, duration: 1 }, // KC 2: expired + { active: true, duration: 10 }, // KC 3: active + { active: false, duration: 1 }, // KC 4: expired + { active: true, duration: 10 }, // KC 5: active + ]; + + for (let i = 0; i < patterns.length; i++) { + await createKnowledgeCollection( + kcCreator, + publishingNode, + publishingNodeIdentityId, + receivingNodes, + receivingNodesIdentityIds, + deps, + merkleRoot, + `pattern-operation-${i}`, + 10, + 1000, + patterns[i].duration, + ); + } + + // Advance to epoch 4 (expired ones are expired, active ones are active) + const avgBlockTime = await RandomSampling.avgBlockTimeInSeconds(); + for (let epoch = 1; epoch < 4; epoch++) { + const timeUntilNextEpoch = await Chronos.timeUntilNextEpoch(); + const blocksToMine = + Number(timeUntilNextEpoch / BigInt(avgBlockTime)) + 2; + for (let i = 0; i < blocksToMine; i++) { + await hre.network.provider.send('evm_mine'); + } + } + + // Test multiple challenges to ensure it finds active collections (1, 3, 5) + const foundCollections = new Set(); + + for (let attempt = 0; attempt < 10; attempt++) { + const duration = + await RandomSamplingStorage.getActiveProofingPeriodDurationInBlocks(); + for (let i = 0; i < Number(duration); i++) { + await hre.network.provider.send('evm_mine'); + } + + await RandomSampling.connect( + publishingNode.operational, + ).createChallenge(); + const challenge = await RandomSamplingStorage.getNodeChallenge( + publishingNodeIdentityId, + ); + + foundCollections.add(Number(challenge.knowledgeCollectionId)); + + // Should only find active collections (1, 3, 5) + expect([1, 3, 5]).to.include(Number(challenge.knowledgeCollectionId)); + + // Mark as solved for next iteration + const solvedChallenge = { + knowledgeCollectionId: challenge.knowledgeCollectionId, + chunkId: challenge.chunkId, + knowledgeCollectionStorageContract: + challenge.knowledgeCollectionStorageContract, + epoch: challenge.epoch, + activeProofPeriodStartBlock: challenge.activeProofPeriodStartBlock, + proofingPeriodDurationInBlocks: + challenge.proofingPeriodDurationInBlocks, + solved: true, + }; + await RandomSamplingStorage.setNodeChallenge( + publishingNodeIdentityId, + solvedChallenge, + ); + } + + // Should find multiple active collections from the pattern + expect(foundCollections.size).to.be.gte( + 2, + 'Should find multiple active collections from the alternating pattern', + ); + + // Verify it never found expired collections (2, 4) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(foundCollections.has(2)).to.be.false; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(foundCollections.has(4)).to.be.false; + }); + }); +}); diff --git a/test/unit/Staking.test.ts b/test/unit/Staking.test.ts index c0182088..3f4a2814 100644 --- a/test/unit/Staking.test.ts +++ b/test/unit/Staking.test.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { randomBytes } from 'crypto'; import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; @@ -16,6 +17,10 @@ import { AskStorage, Hub, Staking, + Chronos, + RandomSamplingStorage, + EpochStorage, + DelegatorsInfo, } from '../../typechain'; type StakingFixture = { @@ -30,10 +35,21 @@ type StakingFixture = { ProfileStorage: ProfileStorage; AskStorage: AskStorage; Hub: Hub; + Chronos: Chronos; + RandomSamplingStorage: RandomSamplingStorage; + EpochStorage: EpochStorage; + DelegatorsInfo: DelegatorsInfo; }; async function deployStakingFixture(): Promise { - await hre.deployments.fixture(['Profile', 'Staking']); + await hre.deployments.fixture([ + 'Profile', + 'Staking', + 'EpochStorage', + 'Chronos', + 'RandomSamplingStorage', + 'DelegatorsInfo', + ]); const Staking = await hre.ethers.getContract('Staking'); const Profile = await hre.ethers.getContract('Profile'); const Token = await hre.ethers.getContract('Token'); @@ -49,6 +65,15 @@ async function deployStakingFixture(): Promise { await hre.ethers.getContract('ProfileStorage'); const AskStorage = await hre.ethers.getContract('AskStorage'); const Hub = await hre.ethers.getContract('Hub'); + const Chronos = await hre.ethers.getContract('Chronos'); + const RandomSamplingStorage = + await hre.ethers.getContract( + 'RandomSamplingStorage', + ); + const EpochStorage = + await hre.ethers.getContract('EpochStorageV8'); + const DelegatorsInfo = + await hre.ethers.getContract('DelegatorsInfo'); const accounts = await hre.ethers.getSigners(); await Hub.setContractAddress('HubOwner', accounts[0].address); @@ -65,6 +90,10 @@ async function deployStakingFixture(): Promise { ProfileStorage, AskStorage, Hub, + Chronos, + RandomSamplingStorage, + EpochStorage, + DelegatorsInfo, }; } @@ -76,6 +105,10 @@ describe('Staking contract', function () { let StakingStorage: StakingStorage; let ShardingTableStorage: ShardingTableStorage; let ParametersStorage: ParametersStorage; + let RandomSamplingStorage: RandomSamplingStorage; + let Chronos: Chronos; + let EpochStorage: EpochStorage; + let DelegatorsInfo: DelegatorsInfo; const createProfile = async ( admin?: SignerWithAddress, @@ -104,14 +137,25 @@ describe('Staking contract', function () { StakingStorage, ShardingTableStorage, ParametersStorage, + RandomSamplingStorage, + Chronos, + EpochStorage, + DelegatorsInfo, } = await loadFixture(deployStakingFixture)); }); + /********************************************************************** + * Sanity checks * + **********************************************************************/ it('Should have correct name and version', async () => { expect(await Staking.name()).to.equal('Staking'); expect(await Staking.version()).to.equal('1.0.1'); }); + /********************************************************************** + * Staking tests * + **********************************************************************/ + it('Should revert if staking 0 tokens', async () => { const { identityId } = await createProfile(); await expect(Staking.stake(identityId, 0)).to.be.revertedWithCustomError( @@ -162,6 +206,42 @@ describe('Staking contract', function () { }); it('Should stake successfully and reflect on node stake', async () => { + const { identityId } = await createProfile(); + const amount = hre.ethers.parseEther('100'); + await Token.mint(accounts[0].address, amount); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + amount, + ); + const stakingBalanceBefore = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + await Staking.stake(identityId, amount); + const stakingBalanceAfter = await Token.balanceOf( + await StakingStorage.getAddress(), + ); + expect(stakingBalanceAfter - stakingBalanceBefore).to.equal( + amount, + 'StakingStorage contract balance increase by staked amount', + ); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + amount, + 'Node stake should be equal to the staked amount', + ); + expect( + await StakingStorage.getDelegatorStakeBase( + identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ), + ).to.equal(amount, 'Delegator stake should be equal to the staked amount'); + }); + /********************************************************************** + * Sharding table tests * + **********************************************************************/ + + it('Should NOT add the node to the sharding table when below minimum stake', async () => { const { identityId } = await createProfile(); const amount = hre.ethers.parseEther('100'); await Token.mint(accounts[0].address, amount); @@ -170,7 +250,10 @@ describe('Staking contract', function () { amount, ); await Staking.stake(identityId, amount); - expect(await StakingStorage.getNodeStake(identityId)).to.equal(amount); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + false, + 'Node should not be added to the sharding table', + ); }); it('Should add node to sharding table when above minimum stake', async () => { @@ -182,25 +265,137 @@ describe('Staking contract', function () { minStake, ); await Staking.stake(identityId, minStake); - expect(await ShardingTableStorage.nodeExists(identityId)).to.equal(true); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + true, + 'Node should be added to the sharding table', + ); + }); + + it('Should have node enter / exit sharding table when stake changes below / above minimum stake', async () => { + const { identityId } = await createProfile(); + const minStake = await ParametersStorage.minimumStake(); + // stake minStake - 1 → should not enter table + const almost = minStake - 1n; + await Token.mint(accounts[0].address, almost + 1n); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + almost + 1n, + ); + await Staking.stake(identityId, almost); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + false, + 'Node should not be added to the sharding table', + ); + + // add +1 wei to reach exact minStake → should enter table + await Staking.stake(identityId, 1n); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + true, + 'Node should be added to the sharding table', + ); + + // Request withdrawal of 1 wei to fall below minStake + await Staking.requestWithdrawal(identityId, 1n); + const req = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ); + await time.increaseTo(req[2]); + await Staking.finalizeWithdrawal(identityId); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + false, + 'Node should not be added to the sharding table', + ); + }); + + it('Should have node be removed then re-enter sharding table after drop below / above threshold', async () => { + const { identityId } = await createProfile(); + const minStake = await ParametersStorage.minimumStake(); + + // Stake exactly minimum → node enters table + await Token.mint(accounts[0].address, minStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + minStake, + ); + await Staking.stake(identityId, minStake); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + true, + 'Node should be added to the sharding table', + ); + + // Request withdrawal of 1 wei so node drops below minimum + await Staking.requestWithdrawal(identityId, 1n); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const [, , ts] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKey, + ); + await time.increaseTo(ts); + await Staking.finalizeWithdrawal(identityId); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + false, + 'Node should not be added to the sharding table', + ); + + // Restake 1 wei to cross back above minimum + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await Staking.stake(identityId, 1n); + expect(await ShardingTableStorage.nodeExists(identityId)).to.equal( + true, + 'Node should be added to the sharding table', + ); }); + /********************************************************************** + * Redelegation tests * + **********************************************************************/ + it('Should redelegate stake to another identity', async () => { const node1 = await createProfile(); const node2 = await createProfile(accounts[0], accounts[2]); - - const initialStake = hre.ethers.parseEther('1000'); + const initialStake = hre.ethers.parseEther('100000'); await Token.mint(accounts[0].address, initialStake); await Token.connect(accounts[0]).approve( await Staking.getAddress(), initialStake, ); await Staking.stake(node1.identityId, initialStake); - // redelegate half const halfStake = initialStake / 2n; await Staking.redelegate(node1.identityId, node2.identityId, halfStake); + // check that the stake is correctly updated + expect(await StakingStorage.getNodeStake(node1.identityId)).to.equal( + halfStake, + 'Node1 stake should be equal to the half stake', + ); + expect(await StakingStorage.getNodeStake(node2.identityId)).to.equal( + halfStake, + 'Node2 stake should be equal to the half stake', + ); + expect( + await StakingStorage.getDelegatorStakeBase( + node1.identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ), + ).to.equal(halfStake, 'Delegator1 stake should be equal to the half stake'); + expect( + await StakingStorage.getDelegatorStakeBase( + node2.identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ), + ).to.equal(halfStake, 'Delegator2 stake should be equal to the half stake'); + // Additional tests for redelegation: // 1) Redelegate zero tokens await expect( @@ -216,6 +411,137 @@ describe('Staking contract', function () { ).to.be.revertedWithCustomError(Staking, 'ProfileDoesntExist'); }); + it('Should NOT BE ABLE to redelegate more stake to another identity than the delegator has', async () => { + const node1 = await createProfile(); + const node2 = await createProfile(accounts[0], accounts[2]); + const initialStake = hre.ethers.parseEther('100000'); + await Token.mint(accounts[0].address, initialStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + initialStake, + ); + await Staking.stake(node1.identityId, initialStake); + await expect( + Staking.redelegate(node1.identityId, node2.identityId, initialStake + 1n), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalExceedsStake'); + }); + /********************************************************************** + * Redelegation Nuances + **********************************************************************/ + + it('⛔️ Redelegate to the SAME node should revert', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('10'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + await expect( + Staking.redelegate(identityId, identityId, stakeAmt), + ).to.be.revertedWith('Cannot redelegate to the same node'); + }); + + it('⛔️ Redelegate that would overflow maximumStake on destination node should revert', async () => { + const max = await ParametersStorage.maximumStake(); + // create two nodes + const nodeSrc = await createProfile(); + const nodeDst = await createProfile(accounts[0], accounts[2]); + + // Stake maxStake – 5 to destination node via admin for simplicity + const nearMax = max - 5n; + await Token.mint(accounts[0].address, nearMax); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + nearMax, + ); + await Staking.stake(nodeDst.identityId, nearMax); + + // Stake 10 on source node (so redelegating 10 would exceed by 5) + const stakeSrc = 10n; + await Token.mint(accounts[0].address, stakeSrc); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeSrc, + ); + await Staking.stake(nodeSrc.identityId, stakeSrc); + await expect( + Staking.redelegate(nodeSrc.identityId, nodeDst.identityId, stakeSrc), + ).to.be.revertedWithCustomError(Staking, 'MaximumStakeExceeded'); + }); + + it('✅ Redelegating full stake removes delegator from source node and adds to destination node', async () => { + const node1 = await createProfile(); + const node2 = await createProfile(accounts[0], accounts[2]); + + const amount = hre.ethers.parseEther('100'); + await Token.mint(accounts[0].address, amount); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + amount, + ); + await Staking.stake(node1.identityId, amount); + + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + + // Preconditions + expect( + await DelegatorsInfo.isNodeDelegator( + node1.identityId, + accounts[0].address, + ), + ).to.equal(true, 'Delegator should be present on node1'); + expect( + await DelegatorsInfo.isNodeDelegator( + node2.identityId, + accounts[0].address, + ), + ).to.equal(false, 'Delegator should not be present on node2'); + expect( + await StakingStorage.getDelegatorStakeBase(node1.identityId, dKey), + ).to.equal(amount, 'Delegator stake should be equal to the amount'); + expect( + await StakingStorage.getDelegatorStakeBase(node2.identityId, dKey), + ).to.equal(0n, 'Delegator stake should be equal to 0'); + + // Redelegation + await Staking.redelegate(node1.identityId, node2.identityId, amount); + // Source node: delegator removed and stake zero + expect( + await StakingStorage.getDelegatorStakeBase(node1.identityId, dKey), + ).to.equal(0n, 'Delegator stake should be equal to 0'); + expect( + await DelegatorsInfo.isNodeDelegator( + node1.identityId, + accounts[0].address, + ), + ).to.equal(false, 'Delegator should not be present on node1'); + + // Destination node: delegator present and stake amount + expect( + await StakingStorage.getDelegatorStakeBase(node2.identityId, dKey), + ).to.equal(amount, 'Delegator stake should be equal to the amount '); + expect( + await DelegatorsInfo.isNodeDelegator( + node2.identityId, + accounts[0].address, + ), + ).to.equal(true, 'Delegator should be present on node2'); + }); + /********************************************************************** + * Withdrawal tests * + **********************************************************************/ + + it('Should revert finalizeWithdrawal if not requested', async () => { + const { identityId } = await createProfile(); + await expect( + Staking.finalizeWithdrawal(identityId), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); + }); + it('Should handle requestWithdrawal with zero tokens', async () => { const { identityId } = await createProfile(); await expect( @@ -237,7 +563,7 @@ describe('Staking contract', function () { ).to.be.revertedWithCustomError(Staking, 'WithdrawalExceedsStake'); }); - it('Should create a withdrawal request', async () => { + it('Should create a withdrawal request, and not be able to finalize it immediately', async () => { const { identityId } = await createProfile(); const amount = hre.ethers.parseEther('100'); await Token.mint(accounts[0].address, amount); @@ -247,17 +573,35 @@ describe('Staking contract', function () { ); await Staking.stake(identityId, amount); const delay = await ParametersStorage.stakeWithdrawalDelay(); - await Staking.requestWithdrawal(identityId, amount / 2n); + const tx = await Staking.requestWithdrawal(identityId, amount / 2n); + const block = await hre.ethers.provider.getBlock( + (await tx.wait())!.blockNumber, + ); + const requestTimestamp = block!.timestamp; const req = await StakingStorage.getDelegatorWithdrawalRequest( identityId, hre.ethers.keccak256( hre.ethers.solidityPacked(['address'], [accounts[0].address]), ), ); - expect(req[0]).to.equal(amount / 2n); - expect(req[2]).to.be.gte( - BigInt((await hre.ethers.provider.getBlock('latest'))!.timestamp) + delay, + expect(req[0]).to.equal( + amount / 2n, + 'Withdrawal amount should be equal to the half of the staked amount', + ); + expect(req[2]).to.equal( + BigInt(requestTimestamp) + delay, + 'Withdrawal request timestamp should be correct', ); + await expect( + Staking.finalizeWithdrawal(identityId), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalPeriodPending'); + }); + + it('should not be able to finalize withdrawal if the request was not initiated', async () => { + const { identityId } = await createProfile(); + await expect( + Staking.finalizeWithdrawal(identityId), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); }); it('Should finalize withdrawal after delay', async () => { @@ -280,14 +624,10 @@ describe('Staking contract', function () { const balanceBefore = await Token.balanceOf(accounts[0].address); await Staking.finalizeWithdrawal(identityId); const balanceAfter = await Token.balanceOf(accounts[0].address); - expect(balanceAfter - balanceBefore).to.equal(amount / 2n); - }); - - it('Should revert finalizeWithdrawal if not requested', async () => { - const { identityId } = await createProfile(); - await expect( - Staking.finalizeWithdrawal(identityId), - ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); + expect(balanceAfter - balanceBefore).to.equal( + amount / 2n, + 'Balance should be equal to the half of the staked amount', + ); }); it('Should revert finalizeWithdrawal if too early', async () => { @@ -300,6 +640,13 @@ describe('Staking contract', function () { ); await Staking.stake(identityId, amount); await Staking.requestWithdrawal(identityId, amount / 2n); + const req = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ); + await time.increaseTo(req[2] - 10n); await expect( Staking.finalizeWithdrawal(identityId), ).to.be.revertedWithCustomError(Staking, 'WithdrawalPeriodPending'); @@ -322,7 +669,7 @@ describe('Staking contract', function () { hre.ethers.solidityPacked(['address'], [accounts[0].address]), ), ); - expect(req[0]).to.equal(0); + expect(req[0]).to.equal(0, 'Withdrawal amount should be equal to 0'); }); it('Should revert cancelWithdrawal if no request', async () => { @@ -332,745 +679,1886 @@ describe('Staking contract', function () { ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); }); - it('Should distribute rewards correctly', async () => { - const { identityId } = await createProfile(); - const amount = hre.ethers.parseEther('1000'); - await Token.mint(StakingStorage.getAddress(), amount); - await StakingStorage.setNodeStake(identityId, amount); - await expect( - Staking.distributeRewards(identityId, 0), - ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); - await expect( - Staking.connect(accounts[1]).distributeRewards(identityId, 100), - ).to.be.reverted; // onlyContracts test depends on setup; skipping - }); + /********************************************************************** + * Operator fee tests * + **********************************************************************/ - it('Should restake operator fee', async () => { + it('Should revert if restake operator fee called with 0 tokens', async () => { const { identityId } = await createProfile(); await expect( Staking.restakeOperatorFee(identityId, 0), ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); - // Additional logic for operator fee testing requires operator fee accumulation. - // Skipping a full operator fee integration test due to complexity. }); - it('Should request operator fee withdrawal', async () => { + it('Should revert restakeOperatorFee when amount exceeds operator fee balance', async () => { const { identityId } = await createProfile(); + // Seed small operator fee balance + await StakingStorage.setOperatorFeeBalance(identityId, 10n); + // Attempt to restake more than balance await expect( - Staking.requestOperatorFeeWithdrawal(identityId, 0), - ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); - // Additional operator fee tests require accumulated fees; skipping full scenario. - }); - - it('Should finalize operator fee withdrawal', async () => { - const { identityId } = await createProfile(); - await expect( - Staking.finalizeOperatorFeeWithdrawal(identityId), - ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); + Staking.restakeOperatorFee(identityId, 11n), + ).to.be.revertedWithCustomError(Staking, 'AmountExceedsOperatorFeeBalance'); }); - it('Should cancel operator fee withdrawal', async () => { + it('Should restake operator fee successfully', async () => { + // 1. Create a node profile where msg.sender (accounts[0]) is admin const { identityId } = await createProfile(); - await expect( - Staking.cancelOperatorFeeWithdrawal(identityId), - ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); - }); - - it('Should revert if non-admin tries to restake operator fee or request operator fee withdrawal', async () => { - const { identityId } = await createProfile(); - await expect( - Staking.connect(accounts[1]).restakeOperatorFee(identityId, 100), - ).to.be.reverted; - await expect( - Staking.connect(accounts[1]).requestOperatorFeeWithdrawal( - identityId, - 100, - ), - ).to.be.reverted; - }); - - it('Should revert stake, requestWithdrawal, etc. on a non-existent profile', async () => { - await expect(Staking.stake(9999, 100)).to.be.revertedWithCustomError( - Staking, - 'ProfileDoesntExist', - ); - await expect( - Staking.requestWithdrawal(9999, 50), - ).to.be.revertedWithCustomError(Staking, 'ProfileDoesntExist'); - await expect( - Staking.finalizeWithdrawal(9999), - ).to.be.revertedWithCustomError(Staking, 'ProfileDoesntExist'); - await expect(Staking.cancelWithdrawal(9999)).to.be.revertedWithCustomError( - Staking, - 'ProfileDoesntExist', + // 2. Manually set operator fee balance via the Hub owner (onlyContracts passes for hub owner) + const feeBalance = hre.ethers.parseEther('100'); + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + feeBalance, ); - }); - it('Should simulateStakeInfoUpdate correctly', async () => { - const { identityId } = await createProfile(); - const amount = hre.ethers.parseEther('100'); + // 3. Stake 50000 tokens to the node + const amount = hre.ethers.parseEther('50000'); await Token.mint(accounts[0].address, amount); await Token.connect(accounts[0]).approve( await Staking.getAddress(), amount, ); await Staking.stake(identityId, amount); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + amount, + 'Node stake should be equal to the staked amount', + ); + expect( + await StakingStorage.getDelegatorStakeBase( + identityId, + hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ), + ), + ).to.equal(amount, 'Delegator stake should be equal to the staked amount'); + + // 4. Snapshot before restake + const nodeStakeBefore = await StakingStorage.getNodeStake(identityId); const delegatorKey = hre.ethers.keccak256( hre.ethers.solidityPacked(['address'], [accounts[0].address]), ); - const result = await Staking.simulateStakeInfoUpdate( + const delegatorStakeBefore = await StakingStorage.getDelegatorStakeBase( identityId, delegatorKey, ); - expect(result[0] + result[1]).to.be.gt(0); - }); - - it('Full scenario: Multiple nodes, delegators, operator fees, redelegations, partial withdrawals, cancellations, rewards', async () => { - console.log('--- START FULL SCENARIO TEST ---'); - - const admin1 = accounts[0]; - const op1 = accounts[1]; - const admin2 = accounts[2]; - const op2 = accounts[3]; - - console.log('Creating Node A with operator fee = 20%'); - const NodeA = await createProfile(admin1, op1, 20n); - console.log(`NodeA ID: ${NodeA.identityId}, operator fee: 20%`); - - console.log('Creating Node B with operator fee = 50%'); - const NodeB = await createProfile(admin2, op2, 50n); - console.log(`NodeB ID: ${NodeB.identityId}, operator fee: 50%`); - - const nodes = { - [NodeA.identityId]: 'A', - [NodeB.identityId]: 'B', - }; - - const d1 = accounts[4]; - const d2 = accounts[5]; - const d3 = accounts[6]; - const d4 = accounts[7]; - - const delegators = { - [d1.address]: 'd1', - [d2.address]: 'd2', - [d3.address]: 'd3', - [d4.address]: 'd4', - }; - - const delay = await ParametersStorage.stakeWithdrawalDelay(); - const minStake = await ParametersStorage.minimumStake(); - const maxStake = await ParametersStorage.maximumStake(); - const toEth = (amount: bigint) => Number(amount) / 1e18; - - const logNodeData = async (identityId: number) => { - const [ - stake, - rewardIndex, - cEarned, - cPaid, - opFeeBal, - opFeeEarned, - opFeePaid, - dCount, - ] = await StakingStorage.getNodeData(identityId); - console.log('----------------------------------------------------------'); - console.log(`Node${nodes[identityId]} Data: - Stake: ${toEth(BigInt(stake))} ETH, - RewardIndex: ${rewardIndex}, - CumulativeEarnedRewards: ${toEth(BigInt(cEarned))} ETH, - CumulativePaidOutRewards: ${toEth(BigInt(cPaid))} ETH, - OperatorFeeBalance: ${toEth(BigInt(opFeeBal))} ETH, - OperatorFeeCumulativeEarnedRewards: ${toEth(BigInt(opFeeEarned))} ETH, - OperatorFeeCumulativePaidOutRewards: ${toEth(BigInt(opFeePaid))} ETH, - DelegatorCount: ${dCount}`); - console.log('----------------------------------------------------------'); - }; - - const logDelegatorData = async ( - identityId: number, - delegator: SignerWithAddress, - ) => { - const dKey = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [delegator.address]), - ); - const [dBase, dIndexed, dLastIndex, dEarned, dPaid] = - await StakingStorage.getDelegatorData(identityId, dKey); - console.log('----------------------------------------------------------'); - console.log(`Delegator ${delegators[delegator.address]} on Node${nodes[identityId]}: - BaseStake: ${toEth(BigInt(dBase))} ETH, - IndexedStake: ${toEth(BigInt(dIndexed))} ETH, - LastRewardIndex: ${dLastIndex}, - CumulativeEarnedRewards: ${toEth(BigInt(dEarned))} ETH, - CumulativePaidOutRewards: ${toEth(BigInt(dPaid))} ETH`); - console.log('----------------------------------------------------------'); - }; - - console.log( - `Minimum stake: ${toEth(minStake)} ETH, Maximum stake: ${toEth(maxStake)} ETH, Delay: ${delay}s`, - ); - - const stakeA = minStake * 2n; - console.log(`Minting ${toEth(stakeA)} ETH to d1 and staking on NodeA`); - await Token.mint(d1.address, stakeA); - await Token.connect(d1).approve(Staking.getAddress(), stakeA); - console.log(`d1 stakes ${toEth(stakeA)} ETH on NodeA`); - await Staking.connect(d1).stake(NodeA.identityId, stakeA); - - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); - - const stakeB = minStake - 1n; - console.log( - `Minting ${toEth(stakeB)} ETH to d3 and staking on NodeB (just below min)`, + const opFeeBalanceBefore = + await StakingStorage.getOperatorFeeBalance(identityId); + const stakingBalanceBefore = await Token.balanceOf( + await StakingStorage.getAddress(), ); - await Token.mint(d3.address, stakeB); - await Token.connect(d3).approve(Staking.getAddress(), stakeB); - console.log(`d3 stakes ${toEth(stakeB)} ETH on NodeB`); - await Staking.connect(d3).stake(NodeB.identityId, stakeB); - await logNodeData(NodeB.identityId); - await logDelegatorData(NodeB.identityId, d3); + // 4. Restake a portion of the operator fee + const restakeAmount = hre.ethers.parseEther('40'); + await Staking.restakeOperatorFee(identityId, restakeAmount); - console.log('Distributing 100 tokens reward to NodeA'); - const rewardA1 = hre.ethers.parseEther('100'); - await Token.mint(StakingStorage.getAddress(), rewardA1); - console.log(`Distributing reward: ${toEth(rewardA1)} ETH to NodeA`); - await Staking.distributeRewards(NodeA.identityId, rewardA1); - - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); - - const partialWithdraw = hre.ethers.parseEther('10'); - console.log( - `d1 requests withdrawal of ${toEth(partialWithdraw)} ETH from NodeA`, + // 5. Snapshot after restake + const nodeStakeAfter = await StakingStorage.getNodeStake(identityId); + const delegatorStakeAfter = await StakingStorage.getDelegatorStakeBase( + identityId, + delegatorKey, ); - await Staking.connect(d1).requestWithdrawal( - NodeA.identityId, - partialWithdraw, + const opFeeBalanceAfter = + await StakingStorage.getOperatorFeeBalance(identityId); + const stakingBalanceAfter = await Token.balanceOf( + await StakingStorage.getAddress(), ); - await time.increase(Number(delay)); - const d1BalanceBefore = await Token.balanceOf(d1.address); - console.log( - `Finalizing withdrawal for d1. d1 balance before: ${toEth(d1BalanceBefore)} ETH`, - ); - await Staking.connect(d1).finalizeWithdrawal(NodeA.identityId); - const d1BalanceAfter = await Token.balanceOf(d1.address); - console.log( - `Withdrawal finalized. d1 balance after: ${toEth(d1BalanceAfter)} ETH, diff: ${toEth(d1BalanceAfter - d1BalanceBefore)} ETH`, + // 6. Assertions + expect(opFeeBalanceAfter).to.equal( + opFeeBalanceBefore - restakeAmount, + 'Operator fee balance should be reduced by the restaked amount', ); - - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); - - console.log('d2 increases stake on NodeA so it has more to redelegate'); - await Token.mint(d2.address, minStake); - await Token.connect(d2).approve(Staking.getAddress(), minStake); - console.log(`d2 stakes ${toEth(minStake)} ETH on NodeA`); - await Staking.connect(d2).stake(NodeA.identityId, minStake); - - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d2); - - const nodeBCurrentStake = await StakingStorage.getNodeStake( - NodeB.identityId, + expect(nodeStakeAfter).to.equal( + nodeStakeBefore + restakeAmount, + 'Node stake should be increased by the restaked amount', ); - const neededForMin = minStake - nodeBCurrentStake; - console.log( - `Redelegating from NodeA to NodeB by d2, needed for min: ${toEth(neededForMin)} ETH`, + expect(delegatorStakeAfter).to.equal( + delegatorStakeBefore + restakeAmount, + 'Delegator stake should be increased by the restaked amount', ); - await Staking.connect(d2).redelegate( - NodeA.identityId, - NodeB.identityId, - neededForMin, + expect(stakingBalanceAfter - stakingBalanceBefore).to.equal( + 0, + 'StakingStorage contract balance should not change', ); + }); - await logNodeData(NodeA.identityId); - await logNodeData(NodeB.identityId); - await logDelegatorData(NodeA.identityId, d2); - // For brevity, not logging all delegators after every single operation, - // but in practice, do it for all delegators to ensure full overview. - - console.log('Distributing 200 tokens reward to NodeB with 50% fee'); - const rewardB1 = hre.ethers.parseEther('200'); - await Token.mint(StakingStorage.getAddress(), rewardB1); - console.log(`Distributing reward: ${toEth(rewardB1)} ETH to NodeB`); - await Staking.distributeRewards(NodeB.identityId, rewardB1); + /********************************************************************** + * Operator fee withdrawal tests + **********************************************************************/ - await logNodeData(NodeB.identityId); + it('Should request and finalize operator fee withdrawal successfully', async () => { + const { identityId } = await createProfile(); - const d3Key = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [d3.address]), - ); - const [d3BaseB, d3IndexedB] = await StakingStorage.getDelegatorStakeInfo( - NodeB.identityId, - d3Key, - ); - const d3TotalB = d3BaseB + d3IndexedB; - const largeWithdraw = d3TotalB / 2n; - console.log( - `d3 tries to withdraw large amount: ${toEth(largeWithdraw)} ETH from NodeB`, - ); - await Staking.connect(d3).requestWithdrawal( - NodeB.identityId, - largeWithdraw, + // Seed operator fee balance directly + const initialFeeBalance = hre.ethers.parseEther('100'); + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + initialFeeBalance, ); + // Also mint equivalent tokens to the StakingStorage contract so it can transfer them out + await Token.mint(await StakingStorage.getAddress(), initialFeeBalance); - await logDelegatorData(NodeB.identityId, d3); + // Request withdrawal for half of the balance + const withdrawalAmount = hre.ethers.parseEther('50'); + const delay = await ParametersStorage.stakeWithdrawalDelay(); + const tx = await Staking.requestOperatorFeeWithdrawal( + identityId, + withdrawalAmount, + ); + // @ts-ignore – provider getBlock returns null only for future blocks which cannot happen here + const tsReq = (await hre.ethers.provider.getBlock( + (await tx.wait()).blockNumber, + ))!.timestamp; - console.log('d3 cancels withdrawal before finalizing'); - await Staking.connect(d3).cancelWithdrawal(NodeB.identityId); - const canceledReq = await StakingStorage.getDelegatorWithdrawalRequest( - NodeB.identityId, - d3Key, + const [storedAmount, , releaseTs] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(storedAmount).to.equal( + withdrawalAmount, + 'Stored amount should be equal to the withdrawal amount', ); - console.log( - `Withdrawal request for d3 after cancel: ${canceledReq[0]} (should be 0)`, + expect(releaseTs).to.equal( + tsReq + Number(delay), + 'Release timestamp should be equal to the request timestamp plus the delay', ); - await logDelegatorData(NodeB.identityId, d3); - - console.log('Operator fee withdrawal on NodeA'); - const operatorFeeBalanceBefore = await StakingStorage.getOperatorFeeBalance( - NodeA.identityId, - ); - const opFeeWithdrawAmount = operatorFeeBalanceBefore / 2n; - console.log( - `Requesting operator fee withdrawal on NodeA: ${toEth(opFeeWithdrawAmount)} ETH from total ${toEth(operatorFeeBalanceBefore)} ETH`, + // Advance time and finalize + await time.increaseTo(BigInt(releaseTs)); + const balanceBefore = await Token.balanceOf(accounts[0].address); + const stakingStorageBalanceBefore = await Token.balanceOf( + await StakingStorage.getAddress(), ); - await Staking.connect(admin1).requestOperatorFeeWithdrawal( - NodeA.identityId, - opFeeWithdrawAmount, + await Staking.finalizeOperatorFeeWithdrawal(identityId); + const balanceAfter = await Token.balanceOf(accounts[0].address); + const stakingStorageBalanceAfter = await Token.balanceOf( + await StakingStorage.getAddress(), ); - await time.increase(Number(delay)); - const admin1BalanceBefore = await Token.balanceOf(admin1.address); - console.log( - `Finalizing operator fee withdrawal for NodeA. admin1 balance before: ${toEth(admin1BalanceBefore)} ETH`, + expect(balanceAfter - balanceBefore).to.equal( + withdrawalAmount, + 'Balance should be equal to the withdrawal amount', ); - await Staking.connect(admin1).finalizeOperatorFeeWithdrawal( - NodeA.identityId, + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + initialFeeBalance - withdrawalAmount, + 'Operator fee balance should be reduced by the withdrawal amount', ); - const admin1BalanceAfter = await Token.balanceOf(admin1.address); - console.log( - `Operator fee withdrawal finalized. admin1 balance after: ${toEth(admin1BalanceAfter)} ETH, diff: ${toEth(admin1BalanceAfter - admin1BalanceBefore)} ETH`, + expect(stakingStorageBalanceBefore - stakingStorageBalanceAfter).to.equal( + withdrawalAmount, + 'StakingStorage contract balance should be lowered by the withdrawal amount', ); + }); + + it('Should revert finalizeOperatorFeeWithdrawal if called too early', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('20'); + // set operator fee balance + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + feeBal, + ); + // request withdrawal + const withdrawAmt = hre.ethers.parseEther('10'); + await Staking.requestOperatorFeeWithdrawal(identityId, withdrawAmt); + await expect( + Staking.finalizeOperatorFeeWithdrawal(identityId), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalPeriodPending'); + }); + + it('Should revert requestOperatorFeeWithdrawal when amount exceeds balance', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('30'); + // set operator fee balance + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + feeBal, + ); + // mint tokens to the StakingStorage contract + await Token.mint(await StakingStorage.getAddress(), feeBal); + // attempt to request withdrawal with amount exceeding balance -> should revert + await expect( + Staking.requestOperatorFeeWithdrawal(identityId, feeBal + 1n), + ).to.be.revertedWithCustomError(Staking, 'AmountExceedsOperatorFeeBalance'); + }); + + it('Should revert requestOperatorFeeWithdrawal with zero amount', async () => { + const { identityId } = await createProfile(); + // attempt to request withdrawal with zero amount -> should revert + await expect( + Staking.requestOperatorFeeWithdrawal(identityId, 0), + ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); + }); + + it('Should allow restake of remaining operator fee while a withdrawal is pending', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('60'); + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + feeBal, + ); + await Token.mint(await StakingStorage.getAddress(), feeBal); + // initiate withdrawal of 40 + const withdrawFirst = hre.ethers.parseEther('40'); + await Staking.requestOperatorFeeWithdrawal(identityId, withdrawFirst); + + // Remaining operator fee balance is 20; restake 10 + const restakeAmt = hre.ethers.parseEther('10'); + const nodeStakeBefore = await StakingStorage.getNodeStake(identityId); + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const delegatorStakeBefore = await StakingStorage.getDelegatorStakeBase( + identityId, + delegatorKey, + ); + + // restake + await Staking.restakeOperatorFee(identityId, restakeAmt); + + // assertions + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal - withdrawFirst - restakeAmt, + 'Operator fee balance should be reduced by the withdrawal amount and the restaked amount', + ); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + nodeStakeBefore + restakeAmt, + 'Node stake should be increased by the restaked amount', + ); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, delegatorKey), + ).to.equal( + delegatorStakeBefore + restakeAmt, + 'Delegator stake should be increased by the restaked amount', + ); + }); + + //TODO: Fix contracts to behave like this! + it('finalizeOperatorFeeWithdrawal second call reverts', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('20'); + // set operator fee balance + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + feeBal, + ); + // request withdrawal + const withdrawAmt = hre.ethers.parseEther('10'); + await Staking.requestOperatorFeeWithdrawal(identityId, withdrawAmt); + const [, , ts] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + await Token.mint(await StakingStorage.getAddress(), feeBal); + await time.increaseTo(BigInt(ts)); + await Staking.finalizeOperatorFeeWithdrawal(identityId); + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal - withdrawAmt, + 'Balance should be equal to feeBal - withdrawAmt', + ); + const [reqAmount] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(reqAmount).to.equal(0n, 'Withdrawal request should be cleared'); + + await expect( + Staking.finalizeOperatorFeeWithdrawal(identityId), + ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); + }); + + /********************************************************************** + * Staking before claiming rewards tests * + **********************************************************************/ + + it('Should revert additional staking if previous epoch rewards not claimed', async () => { + // create profile + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('1000'); + // mint and stake + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + // move to next epoch + const Chronos = await hre.ethers.getContract('Chronos'); + const ttn = await Chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); + const currentEpoch = await Chronos.getCurrentEpoch(); + const prevEpoch = currentEpoch - 1n; + + // Add delegator score to simulate earnings in prev epoch + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + prevEpoch, + identityId, + delegatorKey, + 1, + ); + + // attempt to stake more -> should revert + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await expect(Staking.stake(identityId, 1n)).to.be.revertedWith( + 'Must claim the previous epoch rewards before changing stake', + ); + }); + + it('Should revert withdrawal request if previous epoch rewards not claimed', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('1000'); + // mint and stake + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + // move to next epoch + const Chronos = await hre.ethers.getContract('Chronos'); + const ttn = await Chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); + const currentEpoch = await Chronos.getCurrentEpoch(); + const prevEpoch = currentEpoch - 1n; + + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + prevEpoch, + identityId, + delegatorKey, + 1, + ); + // attempt to request withdrawal -> should revert + await expect(Staking.requestWithdrawal(identityId, 1n)).to.be.revertedWith( + 'Must claim the previous epoch rewards before changing stake', + ); + }); + + /********************************************************************** + * Epoch-claim dependency tests + **********************************************************************/ + + it('⛔️ Should revert stake change when previous epochs rewards are unclaimed', async () => { + const { identityId } = await createProfile(); + const initialStake = hre.ethers.parseEther('500'); + // mint and stake + await Token.mint(accounts[0].address, initialStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + initialStake, + ); + await Staking.stake(identityId, initialStake); + let currentEpoch = await Chronos.getCurrentEpoch(); + + // Add delegator score to simulate earnings in prev epoch + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + currentEpoch, + identityId, + delegatorKey, + 1, + ); + + // Fast-forward 3 epochs ➡️ currentEpoch = 4 + const tlen = await Chronos.epochLength(); + await time.increase(tlen * 3n + 3n); + // @ts-ignore + currentEpoch = await Chronos.getCurrentEpoch(); + + // Attempt to stake +1 wei without having claimed + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await expect(Staking.stake(identityId, 1n)).to.be.revertedWith( + 'Must claim all previous epoch rewards before changing stake', + ); + }); + + it('⛔️ Should revert adding stake when only some epochs are claimed', async () => { + const { identityId } = await createProfile(); + const amount = hre.ethers.parseEther('500'); + let initialEpoch = await Chronos.getCurrentEpoch(); + // mint and stake + await Token.mint(accounts[0].address, amount); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + amount, + ); + await Staking.stake(identityId, amount); + + // Add delegator score to simulate earnings in initial epoch and next epoch + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + initialEpoch, + identityId, + delegatorKey, + 1, + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + initialEpoch + 1n, + identityId, + delegatorKey, + 2, + ); + const delegatorsInfo = await hre.ethers.getContract('DelegatorsInfo'); + + // Fast-forward 3 epochs + const len = await Chronos.epochLength(); + await time.increase(len * 3n + 3n); + // @ts-ignore + const curEp = await Chronos.getCurrentEpoch(); + const prev = curEp - 1n; // 3 + + // Claim rewards for initial epoch + await Staking.claimDelegatorRewards( + identityId, + initialEpoch, + accounts[0].address, + ); + + // Try to stake — should still revert + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await expect(Staking.stake(identityId, 1n)).to.be.revertedWith( + 'Must claim all previous epoch rewards before changing stake', + ); + + // Claim all rewards, then staking should succeed + await Staking.claimDelegatorRewards( + identityId, + initialEpoch + 1n, + accounts[0].address, + ); + + // Try to stake — should still revert + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await Staking.stake(identityId, 1n); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + amount + 1n, + 'staking successful after claiming all rewards', + ); + }); + + it('⛔️ Should revert restaking operator fee when only some epochs are claimed', async () => { + const { identityId } = await createProfile(); + const { identityId: destId } = await createProfile(undefined, accounts[3]); + const stakeBase = hre.ethers.parseEther('500'); + // Stake base so node exists + await Token.mint(accounts[0].address, stakeBase); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeBase, + ); + await Staking.stake(identityId, stakeBase); + + // create rewards in epoch0 and epoch1 + const startEpoch = await Chronos.getCurrentEpoch(); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + startEpoch, + identityId, + dKey, + 1, + ); + await RandomSamplingStorage.addToNodeEpochScore(startEpoch, identityId, 1); + await RandomSamplingStorage.addToAllNodesEpochScore(startEpoch, 1); + await EpochStorage.addTokensToEpochRange( + 1, + startEpoch, + startEpoch, + hre.ethers.parseEther('10'), + ); + + // advance one epoch and add more rewards + const len = await Chronos.epochLength(); + await time.increase(len + 2n); + const secondEpoch = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + secondEpoch, + identityId, + dKey, + 2, + ); + await RandomSamplingStorage.addToNodeEpochScore(secondEpoch, identityId, 2); + await RandomSamplingStorage.addToAllNodesEpochScore(secondEpoch, 2); + await EpochStorage.addTokensToEpochRange( + 1, + secondEpoch, + secondEpoch, + hre.ethers.parseEther('10'), + ); + + // advance another epoch so both epochs are claimable + await time.increase(len + 2n); + + // Claim only first epoch + await Staking.claimDelegatorRewards( + identityId, + startEpoch, + accounts[0].address, + ); + + // Attempt to redelegate 1 wei – expect revert due to unclaimed second epoch + await expect(Staking.redelegate(identityId, destId, 1n)).to.be.revertedWith( + 'Must claim the previous epoch rewards before changing stake', + ); + + // Claim remaining epoch and then redelegation should succeed + await Staking.claimDelegatorRewards( + identityId, + secondEpoch, + accounts[0].address, + ); + + const sourceBefore = await StakingStorage.getNodeStake(identityId); + const destBefore = await StakingStorage.getNodeStake(destId); + + await expect(Staking.redelegate(identityId, destId, 1n)).to.not.be.reverted; + + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + sourceBefore - 1n, + ); + expect(await StakingStorage.getNodeStake(destId)).to.equal(destBefore + 1n); + }); + + it('✅ Should allow stake change after all previous rewards claimed', async () => { + const { identityId } = await createProfile(); + const baseStake = hre.ethers.parseEther('500'); + await Token.mint(accounts[0].address, baseStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + baseStake, + ); + // stake + await Staking.stake(identityId, baseStake); + const initialEpoch = await Chronos.getCurrentEpoch(); + + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + initialEpoch + 1n, + identityId, + delegatorKey, + 1, + ); + + const epochLen = await Chronos.epochLength(); + // fast-forward 3 epochs + await time.increase(epochLen * 3n + 3n); + // @ts-ignore + const curEpoch = await Chronos.getCurrentEpoch(); + const prevEp = curEpoch - 1n; // 3 + + // Pretend all rewards claimed up to prevEp + await Staking.claimDelegatorRewards( + identityId, + initialEpoch, + accounts[0].address, + ); // TODO: check! claiming even though there's no rewards in first epoch! + await Staking.claimDelegatorRewards( + identityId, + initialEpoch + 1n, + accounts[0].address, + ); + + // Stake additional 1 wei – should pass + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await expect(Staking.stake(identityId, 1n)).to.not.be.reverted; + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + baseStake + 1n, + ); + }); + + /********************************************************************** + * Boundary & Limit tests + **********************************************************************/ + + it('✅ Stake exactly maximumStake should succeed, +1 wei should revert', async () => { + const { identityId } = await createProfile(); + const maxStake = await ParametersStorage.maximumStake(); + + await Token.mint(accounts[0].address, maxStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + maxStake, + ); + await expect(Staking.stake(identityId, maxStake)).to.not.be.reverted; + expect(await StakingStorage.getNodeStake(identityId)).to.equal(maxStake); + + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await expect(Staking.stake(identityId, 1n)).to.be.revertedWithCustomError( + Staking, + 'MaximumStakeExceeded', + ); + }); + + it('⛔️ Restake operator fee that pushes above maximumStake should revert', async () => { + const { identityId } = await createProfile(); + const maxStake = await ParametersStorage.maximumStake(); + + // stake maxStake - 5 + const base = maxStake - 5n; + await Token.mint(accounts[0].address, base); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), base); + await Staking.stake(identityId, base); + // set operator fee balance to 10 TRAC + const opFee = 10n; + await StakingStorage.connect(accounts[0]).setOperatorFeeBalance( + identityId, + opFee, + ); + + await expect( + Staking.restakeOperatorFee(identityId, 6n), + ).to.be.revertedWithCustomError(Staking, 'MaximumStakeExceeded'); + }); + + /********************************************************************** + * rollingRewards & cumulativeEarned / cumulativePaidOut + **********************************************************************/ + + //TODO: update this test to check exact rolling rewards. Leaving failing in this PR to make sure we don't miss it + + it('📊 rollingRewards accumulate & auto-restake; earned / paidOut updated', async () => { + const { identityId } = await createProfile(); + const SCALE18 = hre.ethers.parseUnits('1', 18); + + /* 1️⃣ Stake once in epoch-1 */ + const stakeBase = hre.ethers.parseEther('1000'); + await Token.mint(accounts[0].address, stakeBase); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeBase, + ); + await Staking.stake(identityId, stakeBase); + + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + + /* helper to create "rewards" for an epoch */ + // Inject rewards for epoch-1 immediately so first claim yields rolling rewards + const epoch1 = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore( + epoch1, + identityId, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch1, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch1, + identityId, + dKey, + SCALE18, + ); + const tokenRewards1 = hre.ethers.parseEther('20'); + await EpochStorage.addTokensToEpochRange(1, epoch1, epoch1, tokenRewards1); + + /* 2️⃣ Produce rewards for epoch-2 and epoch-3 */ + // @ts-ignore + const len = await Chronos.epochLength(); + await time.increase(len + 2n); // -> epoch-2 + // @ts-ignore + const epoch2 = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore( + epoch2, + identityId, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch2, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch2, + identityId, + dKey, + SCALE18, + ); + const tokenRewards2 = hre.ethers.parseEther('30'); + await EpochStorage.addTokensToEpochRange(1, epoch2, epoch2, tokenRewards2); + + await time.increase(len + 2n); // -> epoch-3 + // @ts-ignore + const epoch3 = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToNodeEpochScore( + epoch3, + identityId, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(epoch3, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch3, + identityId, + dKey, + SCALE18, + ); + const tokenRewards3 = hre.ethers.parseEther('40'); + await EpochStorage.addTokensToEpochRange(1, epoch3, epoch3, tokenRewards3); + + /* 3️⃣ Move to epoch-4 so gap > 1 and claim epoch-3-1 (= epoch-3?) Actually previousEpoch will be 3 */ + await time.increase(len + 2n); // -> epoch-4 + // @ts-ignore + const epoch4 = await Chronos.getCurrentEpoch(); // claim epoch-1 first (oldest unclaimed) + + await Staking.claimDelegatorRewards( + identityId, + epoch1, + accounts[0].address, + ); + const rolling1 = await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + accounts[0].address, + ); + + expect(rolling1).to.equal( + tokenRewards1, + 'Rolling rewards should be equal to tokenRewards1', + ); + + /* cumulativeEarned should have grown, cumulativePaidOut unchanged (0) */ + const [earned1, paid1] = await StakingStorage.getDelegatorRewardsInfo( + identityId, + dKey, + ); + expect(earned1).to.equal( + tokenRewards1, + 'Earned rewards should equal rewards from epoch 1 after first claim', + ); + expect(paid1).to.equal(0n, 'Paid should be 0'); + + /* 4️⃣ Claim next epoch (gap ≤ 1) – rollingRewards should auto-restake */ + const secondClaimEpoch = epoch2; // claim epoch-2 next (gap ≤1) + await Staking.claimDelegatorRewards( + identityId, + secondClaimEpoch, + accounts[0].address, + ); + + const rolling2 = await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + accounts[0].address, + ); + expect(rolling2).to.be.gt( + 0n, + 'rollingRewards should still be accumulating', + ); + console.log(' Rolling rewards still accumulating'); + + /* 5️⃣ Claim epoch3 now (prev epoch) — triggers auto-restake */ + const thirdClaimEpoch = epoch4 - 1n; // == epoch3 + console.log( + '🌱 Claiming epoch', + thirdClaimEpoch, + 'to trigger auto-restake', + ); + await Staking.claimDelegatorRewards( + identityId, + thirdClaimEpoch, + accounts[0].address, + ); + + const rolling3 = await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + accounts[0].address, + ); + expect(rolling3).to.equal( + 0n, + 'rollingRewards must be zero after auto-restake', + ); + console.log(' Rolling rewards reset to zero'); + + const finalStake = await StakingStorage.getDelegatorStakeBase( + identityId, + dKey, + ); + expect(finalStake).to.be.gt( + stakeBase, + 'Final stake should grow after auto-restake', + ); + + const [earned2, paid2] = await StakingStorage.getDelegatorRewardsInfo( + identityId, + dKey, + ); + expect(earned2).to.be.gt(earned1, 'Earned should grow'); + expect(paid2).to.equal(0n, 'Paid stays zero'); + + /* 6️⃣ restakeOperatorFee does NOT touch earned/paidOut */ + console.log(' Setting operator fee balance to 10 TRAC'); + await StakingStorage.setOperatorFeeBalance(identityId, 10n); + console.log(' Restaking operator fee'); + await Staking.restakeOperatorFee(identityId, 5n); + const [earned3, paid3] = await StakingStorage.getDelegatorRewardsInfo( + identityId, + dKey, + ); + console.log(' Earned assertion passed'); + expect(earned3).to.equal(earned2, 'Earned should be equal to earned2'); + console.log(' Paid assertion passed'); + expect(paid3).to.equal(paid2, 'Paid should be equal to paid2'); + console.log(' Paid assertion passed'); + }); + + /******************* rolling rewards accumulate then flush ***********/ + it('rolling rewards accumulate over multiple epochs then flush into stake', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('100'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const SCALE18 = hre.ethers.parseUnits('1', 18); + // epoch1 & epoch2 add scores + const ep1 = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + ep1, + identityId, + dKey, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(ep1, SCALE18); + await RandomSamplingStorage.addToNodeEpochScore(ep1, identityId, SCALE18); + await EpochStorage.addTokensToEpochRange( + 1, + ep1, + ep1, + hre.ethers.parseEther('10'), + ); + // advance epoch + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + const ep2 = await Chronos.getCurrentEpoch(); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + ep2, + identityId, + dKey, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(ep2, SCALE18); + await RandomSamplingStorage.addToNodeEpochScore(ep2, identityId, SCALE18); + await EpochStorage.addTokensToEpochRange( + 1, + ep2, + ep2, + hre.ethers.parseEther('10'), + ); + await time.increase((await Chronos.timeUntilNextEpoch()) + 1n); + + // Claim epoch2 only (epoch1 older) should revert first + await expect( + Staking.claimDelegatorRewards(identityId, ep2, accounts[0].address), + ).to.be.revertedWith('Must claim older epochs first'); + + // Claim epoch1 → rolling should accumulate (but not restake) + await Staking.claimDelegatorRewards(identityId, ep1, accounts[0].address); + expect( + await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + accounts[0].address, + ), + ).to.be.gt(0); + + // Claim epoch2 now – rolling flushed to stake + await Staking.claimDelegatorRewards(identityId, ep2, accounts[0].address); + expect( + await DelegatorsInfo.getDelegatorRollingRewards( + identityId, + accounts[0].address, + ), + ).to.equal(0); + }); + + /********************************************************************** + * Operator-Fee Corner-Case tests + **********************************************************************/ + it('🔄 Two consecutive operator-fee withdrawal requests accumulate', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('100'); + // seed operator-fee balance + await StakingStorage.setOperatorFeeBalance(identityId, feeBal); + + // 1️⃣ first request 40 TRAC + const first = hre.ethers.parseEther('40'); + await Staking.requestOperatorFeeWithdrawal(identityId, first); + + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal - first, + 'Balance should be equal to feeBal - first', + ); + + // 2️⃣ second request 30 TRAC – should accumulate + const second = hre.ethers.parseEther('30'); + await Staking.requestOperatorFeeWithdrawal(identityId, second); + + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal - first - second, + 'Balance should reflect both deductions', + ); + const [amount, , release] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(amount).to.equal( + first + second, + 'Second request should be added to the first one', + ); + expect(release).to.be.gt(0, 'Release timestamp set'); + }); + + it('cancelOperatorFeeWithdrawal replenishes balance and deletes request', async () => { + const { identityId } = await createProfile(); + await StakingStorage.setOperatorFeeBalance(identityId, 40n); + await Staking.requestOperatorFeeWithdrawal(identityId, 30n); + await Staking.cancelOperatorFeeWithdrawal(identityId); + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + 40n, + 'Balance should be equal to 40', + ); + const [amt] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(amt).to.equal(0n, 'Amount should be 0'); + }); + + it('✅ Cancel withdrawal then restake pending operator fee', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('50'); + await StakingStorage.setOperatorFeeBalance(identityId, feeBal); + const withdrawAmt = hre.ethers.parseEther('20'); + await Staking.requestOperatorFeeWithdrawal(identityId, withdrawAmt); + + // Cancel it + await Staking.cancelOperatorFeeWithdrawal(identityId); + const [amountAfter] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(amountAfter).to.equal( + 0, + 'Withdrawal request should be cleared after cancel', + ); + // balance back to original + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal, + ); + + // Restake the same 20 + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const stakeBefore = await StakingStorage.getNodeStake(identityId); + await Staking.restakeOperatorFee(identityId, withdrawAmt); + expect(await StakingStorage.getOperatorFeeBalance(identityId)).to.equal( + feeBal - withdrawAmt, + 'Balance should be equal to feeBal - withdrawAmt', + ); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + stakeBefore + withdrawAmt, + 'Stake should be equal to stakeBefore + withdrawAmt', + ); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, delegatorKey), + ).to.equal(withdrawAmt, 'Delegator stake should be equal to withdrawAmt'); + }); + + it('⛔️ Restake 0 operator-fee while withdrawal pending should revert', async () => { + const { identityId } = await createProfile(); + const feeBal = hre.ethers.parseEther('10'); + await StakingStorage.setOperatorFeeBalance(identityId, feeBal); + const withdrawAmt = hre.ethers.parseEther('5'); + await Staking.requestOperatorFeeWithdrawal(identityId, withdrawAmt); + await expect( + Staking.restakeOperatorFee(identityId, 0), + ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); + }); + + it('🔒 Only profile admin can access operator-fee functions', async () => { + // admin = accounts[0], operational = accounts[1] + const { identityId } = await createProfile(accounts[0], accounts[1]); + await StakingStorage.setOperatorFeeBalance( + identityId, + hre.ethers.parseEther('10'), + ); + const other = accounts[2]; + + const expectOnlyAdmin = async (promise: Promise) => + expect(promise).to.be.revertedWithCustomError( + Staking, + 'OnlyProfileAdminFunction', + ); + + await expectOnlyAdmin( + Staking.connect(other).requestOperatorFeeWithdrawal(identityId, 1), + ); + await expectOnlyAdmin( + Staking.connect(other).cancelOperatorFeeWithdrawal(identityId), + ); + await expectOnlyAdmin( + Staking.connect(other).restakeOperatorFee(identityId, 1), + ); + }); + + it('📈 Operator-fee percentage path: fee credited once and only once', async () => { + // 0️⃣ setup profile with 10% operator fee + const { identityId } = await createProfile(); + // update operator fee to 10% (1000 ‱) + await Profile.updateOperatorFee(identityId, 1000); + + // Delegator addresses + const deleg1 = accounts[0]; + const deleg2 = accounts[1]; + + // 1️⃣ both delegators stake 1 000 TRAC each + const stakeAmt = hre.ethers.parseEther('1000'); + for (const d of [deleg1, deleg2]) { + await Token.mint(d.address, stakeAmt); + await Token.connect(d).approve(await Staking.getAddress(), stakeAmt); + await Staking.connect(d).stake(identityId, stakeAmt); + } + // 2️⃣ create rewards for current epoch + const SCALE18 = hre.ethers.parseUnits('1', 18); + // @ts-ignore + const epochNow = await Chronos.getCurrentEpoch(); + const dKey1 = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg1.address]), + ); + const dKey2 = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg2.address]), + ); + const rewardPool = hre.ethers.parseEther('100'); + + await RandomSamplingStorage.addToNodeEpochScore( + epochNow, + identityId, + SCALE18, + ); + await RandomSamplingStorage.addToAllNodesEpochScore(epochNow, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epochNow, + identityId, + dKey1, + SCALE18 / 2n, + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epochNow, + identityId, + dKey2, + SCALE18 / 2n, + ); + await EpochStorage.addTokensToEpochRange(1, epochNow, epochNow, rewardPool); + + // 3️⃣ advance to next epoch so epochNow < currentEpoch + const ttn = await Chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); + // operator fee balance before claims + const opBalBefore = await StakingStorage.getOperatorFeeBalance(identityId); + // 4️⃣ first delegator claims → fee credited once + await Staking.connect(deleg1).claimDelegatorRewards( + identityId, + epochNow, + deleg1.address, + ); + const opBalAfterFirst = + await StakingStorage.getOperatorFeeBalance(identityId); + const expectedFee = rewardPool / 10n; // 10% + expect(opBalAfterFirst - opBalBefore).to.equal( + expectedFee, + 'Operator-fee balance should increase by 10% of pool', + ); + // flag must be set + expect( + await DelegatorsInfo.isOperatorFeeClaimedForEpoch(identityId, epochNow), + ).to.be.true; + // 5️⃣ second delegator claims → fee NOT added again + await Staking.connect(deleg2).claimDelegatorRewards( + identityId, + epochNow, + deleg2.address, + ); + const opBalAfterSecond = + await StakingStorage.getOperatorFeeBalance(identityId); + expect(opBalAfterSecond).to.equal( + opBalAfterFirst, + 'Operator-fee balance should not change on second claim', + ); + // Leftover rewards recorded for delegators should be 90% of pool + const leftover = await DelegatorsInfo.getNetNodeEpochRewards( + identityId, + epochNow, + ); + expect(leftover).to.equal( + rewardPool - expectedFee, + 'Leftover rewards for delegators should be 90% of pool', + ); + }); - await logNodeData(NodeA.identityId); + /********************************************************************** + * Claim guards & batch claiming + **********************************************************************/ - console.log('Operator tries to restake operator fee on NodeB'); - const operatorFeeBalanceB = await StakingStorage.getOperatorFeeBalance( - NodeB.identityId, + it('⛔️ Double-claim guard: second claim reverts', async () => { + const { identityId } = await createProfile(); + const deleg = accounts[0]; + const stake = hre.ethers.parseEther('100'); + await Token.mint(deleg.address, stake); + await Token.connect(deleg).approve(await Staking.getAddress(), stake); + await Staking.connect(deleg).stake(identityId, stake); + + // produce rewards for epoch E + const SCALE18 = hre.ethers.parseUnits('1', 18); + // @ts-ignore + const epochE = await Chronos.getCurrentEpoch(); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [deleg.address]), + ); + await RandomSamplingStorage.addToNodeEpochScore( + epochE, + identityId, + SCALE18, ); - console.log( - `Operator fee balance on NodeB: ${toEth(operatorFeeBalanceB)} ETH`, - ); - if (operatorFeeBalanceB > 0n) { - const restakeAmount = - operatorFeeBalanceB < hre.ethers.parseEther('10') - ? operatorFeeBalanceB - : hre.ethers.parseEther('10'); - console.log( - `Restaking ${toEth(restakeAmount)} ETH operator fee on NodeB`, - ); - await Staking.connect(admin2).restakeOperatorFee( - NodeB.identityId, - restakeAmount, + await RandomSamplingStorage.addToAllNodesEpochScore(epochE, SCALE18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epochE, + identityId, + dKey, + SCALE18, + ); + + await EpochStorage.addTokensToEpochRange( + 1, + epochE, + epochE, + hre.ethers.parseEther('10'), + ); + + // advance to next epoch so claims are allowed + const ttn = await Chronos.timeUntilNextEpoch(); + await time.increase(ttn + 1n); + + // first claim succeeds + await Staking.connect(deleg).claimDelegatorRewards( + identityId, + epochE, + deleg.address, + ); + // second claim should revert + await expect( + Staking.connect(deleg).claimDelegatorRewards( + identityId, + epochE, + deleg.address, + ), + ).to.be.revertedWith('Already claimed all finalised epochs'); + }); + + it('✅ batchClaimDelegatorRewards happy-path (2 epochs × 2 delegators)', async () => { + const { identityId } = await createProfile(); + const deleg1 = accounts[0]; + const deleg2 = accounts[1]; + + const stake = hre.ethers.parseEther('50'); + for (const d of [deleg1, deleg2]) { + await Token.mint(d.address, stake); + await Token.connect(d).approve(await Staking.getAddress(), stake); + await Staking.connect(d).stake(identityId, stake); + } + const SCALE18 = hre.ethers.parseUnits('1', 18); + // epochs E1 and E2 + // @ts-ignore + const epoch1 = await Chronos.getCurrentEpoch(); + const epoch2 = epoch1 + 1n; + + const makeRewards = async (ep: bigint) => { + for (const d of [deleg1, deleg2]) { + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [d.address]), + ); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + ep, + identityId, + dKey, + SCALE18 / 2n, + ); + } + await RandomSamplingStorage.addToNodeEpochScore(ep, identityId, SCALE18); + await RandomSamplingStorage.addToAllNodesEpochScore(ep, SCALE18); + await EpochStorage.addTokensToEpochRange( + 1, + ep, + ep, + hre.ethers.parseEther('10'), ); - const nodeBStakeAfterRestake = await StakingStorage.getNodeStake( - NodeB.identityId, + }; + + await makeRewards(epoch1); + // advance to epoch2 + let inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + await makeRewards(epoch2); + // advance to epoch3 so both epochs are claimable + inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + // batch claim + await Staking.batchClaimDelegatorRewards( + identityId, + [epoch1, epoch2], + [deleg1.address, deleg2.address], + ); + // verify lastClaimedEpoch for both delegators == epoch2 + for (const d of [deleg1, deleg2]) { + const last = await DelegatorsInfo.getLastClaimedEpoch( + identityId, + d.address, ); - console.log( - `NodeB stake after restake: ${toEth(nodeBStakeAfterRestake)} ETH, should be >= minStake`, + expect(last).to.equal( + epoch2, + 'Last claimed epoch should be equal to epoch2', ); } + }); - console.log('d4 stakes on NodeB to push it close to max'); - const nodeBStakeNow = await StakingStorage.getNodeStake(NodeB.identityId); - const pushToMax = maxStake - nodeBStakeNow; - console.log(`Amount to push NodeB close to max: ${toEth(pushToMax)} ETH`); - if (pushToMax > 0n) { - const stakeD4 = pushToMax / 2n; - console.log(`Minting ${toEth(stakeD4)} ETH to d4`); - await Token.mint(d4.address, stakeD4); - await Token.connect(d4).approve(Staking.getAddress(), stakeD4); - console.log(`d4 stakes ${toEth(stakeD4)} ETH on NodeB`); - await Staking.connect(d4).stake(NodeB.identityId, stakeD4); - console.log( - `NodeB stake after d4 stakes: ${toEth(await StakingStorage.getNodeStake(NodeB.identityId))} ETH`, - ); - } + /********************************************************************** + * Withdrawal corner-cases + **********************************************************************/ + + it('🔀 Multiple withdrawal requests merge amounts', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('1000'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); - const d1Key = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [d1.address]), + // first request 300 + const req1 = hre.ethers.parseEther('300'); + await Staking.requestWithdrawal(identityId, req1); + // second request 200 + const req2 = hre.ethers.parseEther('200'); + await Staking.requestWithdrawal(identityId, req2); + + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), ); - const [d1BaseA, d1IndexedA] = await StakingStorage.getDelegatorStakeInfo( - NodeA.identityId, - d1Key, + const [pending, ,] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKey, ); - const d1TotalA = d1BaseA + d1IndexedA; - const bigRedelegateAmount = d1TotalA > minStake ? minStake : d1TotalA; - console.log( - `d1 tries big redelegation from NodeA to NodeB: ${toEth(bigRedelegateAmount)} ETH`, - ); - const nodeBStakeFinalCheck = await StakingStorage.getNodeStake( - NodeB.identityId, - ); - if (nodeBStakeFinalCheck + bigRedelegateAmount > maxStake) { - console.log('This would exceed maximum stake on NodeB, expecting revert'); - await expect( - Staking.connect(d1).redelegate( - NodeA.identityId, - NodeB.identityId, - bigRedelegateAmount, - ), - ).to.be.reverted; - } + expect(pending).to.equal(req1 + req2, 'Request amount should accumulate'); - console.log('d1 does a small redelegation below max limit'); - const safeAmount = - (maxStake - (await StakingStorage.getNodeStake(NodeB.identityId))) / 2n; - if (safeAmount > 0n && safeAmount <= d1TotalA) { - console.log( - `Redelegating ${toEth(safeAmount)} ETH from NodeA to NodeB by d1`, - ); - await Staking.connect(d1).redelegate( - NodeA.identityId, - NodeB.identityId, - safeAmount, - ); - const nodeBStakeFinal = await StakingStorage.getNodeStake( - NodeB.identityId, - ); - console.log( - `NodeB stake after safe redelegation: ${toEth(nodeBStakeFinal)} ETH, should be < maxStake`, - ); - } + // node stake reduced by 500 + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + stakeAmt - req1 - req2, + ); + }); + + it('↩️ cancelWithdrawal partially restakes when near maximumStake', async () => { + // shrink maximumStake for easier maths + const smallMax = hre.ethers.parseEther('20'); + await ParametersStorage.setMaximumStake(smallMax); + + const { identityId } = await createProfile(); - // Validate that no negative values appear, node stakes are consistent: - const nodeAStakeFinal = await StakingStorage.getNodeStake(NodeA.identityId); - const nodeBStakeFinal2 = await StakingStorage.getNodeStake( - NodeB.identityId, + // Delegator A stakes maxStake (20) + await Token.mint(accounts[0].address, smallMax); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + smallMax, ); - console.log( - `Final NodeA stake: ${toEth(nodeAStakeFinal)} ETH, Final NodeB stake: ${toEth(nodeBStakeFinal2)} ETH`, + await Staking.stake(identityId, smallMax); + + // Delegator A requests withdrawal 10 → node stake becomes 10 + const withdrawAmt = hre.ethers.parseEther('10'); + await Staking.requestWithdrawal(identityId, withdrawAmt); + + // Delegator B stakes 8 so node stake becomes 18 + await Token.mint(accounts[2].address, hre.ethers.parseEther('8')); + await Token.connect(accounts[2]).approve( + await Staking.getAddress(), + hre.ethers.parseEther('8'), + ); + await Staking.connect(accounts[2]).stake( + identityId, + hre.ethers.parseEther('8'), ); - expect(nodeAStakeFinal).to.be.at.least(0n); - expect(nodeBStakeFinal2).to.be.at.least(0n); - console.log('--- END FULL SCENARIO TEST ---'); + // Snapshot before cancel + const dKeyA = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const dKeyB = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[2].address]), + ); + const [, , tsBefore] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKeyA, + ); + const stakeBefore = await StakingStorage.getNodeStake(identityId); // should be 18 + + await Staking.cancelWithdrawal(identityId); + + // After cancel: 2 restaked, 8 pending + const [pendingAfter, , tsAfter] = + await StakingStorage.getDelegatorWithdrawalRequest(identityId, dKeyA); + expect(pendingAfter).to.equal( + hre.ethers.parseEther('8'), + 'Delegator 1 still has 8 pending in withdrawal', + ); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + stakeBefore + hre.ethers.parseEther('2'), + 'Node stake should be 18 + 2 = 20', + ); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, dKeyA), + ).to.equal(hre.ethers.parseEther('12'), 'Delegator A stake should be 12'); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, dKeyB), + ).to.equal(hre.ethers.parseEther('8'), 'Delegator B stake should be 8'); + expect(tsAfter).to.equal(tsBefore, 'Release timestamp must stay unchanged'); }); - it('Stress test: Rapid stake/un-stake cycles, redelegations, operator fee changes, trying to break invariants', async () => { - console.log('--- START STRESS TEST SCENARIO ---'); - // Track user balances and stakes - let d1Balance = 0n; - let d2Balance = 0n; - let opFeeBalanceB = 0n; + /********************************************************************** + * Maximum / Minimum stake edge behaviours + **********************************************************************/ - const admin1 = accounts[0]; - const op1 = accounts[1]; - const admin2 = accounts[2]; - const op2 = accounts[3]; + it('🎯 Restake operator-fee hits maximumStake exactly, +1 wei reverts', async () => { + const { identityId } = await createProfile(); + const maxStake = await ParametersStorage.maximumStake(); - const NodeA = await createProfile(admin1, op1, 10n); - console.log(`NodeA ID: ${NodeA.identityId}, fee: 10%`); - const NodeB = await createProfile(admin2, op2, 90n); - console.log(`NodeB ID: ${NodeB.identityId}, fee: 90%`); + // Stake maxStake - 2 + const base = maxStake - 2n; + await Token.mint(accounts[0].address, base); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), base); + await Staking.stake(identityId, base); - const nodes = { - [NodeA.identityId]: 'A', - [NodeB.identityId]: 'B', - }; + // Put operator-fee balance 3 wei (2 to reach max, 1 to exceed) + await StakingStorage.setOperatorFeeBalance(identityId, 3n); - const d1 = accounts[4]; - const d2 = accounts[5]; - const d3 = accounts[6]; + // Restake 2 – should succeed and hit the cap exactly + await expect(Staking.restakeOperatorFee(identityId, 2n)).to.not.be.reverted; + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + maxStake, + 'Node stake should be equal to maxStake', + ); - const delegators = { - [d1.address]: 'd1', - [d2.address]: 'd2', - [d3.address]: 'd3', - }; + // Restake +1 wei – should revert + await expect( + Staking.restakeOperatorFee(identityId, 1n), + ).to.be.revertedWithCustomError(Staking, 'MaximumStakeExceeded'); + }); - const delay = await ParametersStorage.stakeWithdrawalDelay(); - const minStake = await ParametersStorage.minimumStake(); + it('↩️ cancelWithdrawal fully restakes when node well below maximumStake', async () => { + const { identityId } = await createProfile(); + + // Use current maximumStake as cap reference const maxStake = await ParametersStorage.maximumStake(); - const toEth = (amount: bigint) => Number(amount) / 1e18; - - const logNodeData = async (identityId: number) => { - const [ - stake, - rewardIndex, - cEarned, - cPaid, - opFeeBal, - opFeeEarned, - opFeePaid, - dCount, - ] = await StakingStorage.getNodeData(identityId); - console.log('----------------------------------------------------------'); - console.log(`Node${nodes[identityId]} Data: - Stake: ${toEth(BigInt(stake))} ETH, - RewardIndex: ${rewardIndex}, - CumulativeEarnedRewards: ${toEth(BigInt(cEarned))} ETH, - CumulativePaidOutRewards: ${toEth(BigInt(cPaid))} ETH, - OperatorFeeBalance: ${toEth(BigInt(opFeeBal))} ETH, - OperatorFeeCumulativeEarnedRewards: ${toEth(BigInt(opFeeEarned))} ETH, - OperatorFeeCumulativePaidOutRewards: ${toEth(BigInt(opFeePaid))} ETH, - DelegatorCount: ${dCount}`); - console.log('----------------------------------------------------------'); - }; - const logDelegatorData = async ( - identityId: number, - delegator: SignerWithAddress, - ) => { - const dKey = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [delegator.address]), - ); - const [dBase, dIndexed, dLastIndex, dEarned, dPaid] = - await StakingStorage.getDelegatorData(identityId, dKey); - console.log('----------------------------------------------------------'); - console.log(`Delegator ${delegators[delegator.address]} on Node${nodes[identityId]}: - BaseStake: ${toEth(BigInt(dBase))} ETH, - IndexedStake: ${toEth(BigInt(dIndexed))} ETH, - LastRewardIndex: ${dLastIndex}, - CumulativeEarnedRewards: ${toEth(BigInt(dEarned))} ETH, - CumulativePaidOutRewards: ${toEth(BigInt(dPaid))} ETH`); - console.log('----------------------------------------------------------'); - }; + // Stake a small fraction of the cap (10%) so there is ample head-room + const baseStake = maxStake / 10n; + await Token.mint(accounts[0].address, baseStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + baseStake, + ); + await Staking.stake(identityId, baseStake); - console.log( - `MinStake: ${toEth(minStake)} ETH, MaxStake: ${toEth(maxStake)} ETH, Delay: ${delay}s`, - ); - - const mintAmountD1 = maxStake * 2n; - await Token.mint(d1.address, mintAmountD1); - d1Balance += mintAmountD1; - console.log(`d1 initial minted balance: ${toEth(d1Balance)} ETH`); - await Token.connect(d1).approve(Staking.getAddress(), mintAmountD1); - - const stakeA = minStake + 10n; - console.log(`d1 stakes ${toEth(stakeA)} ETH on NodeA`); - await Staking.connect(d1).stake(NodeA.identityId, stakeA); - d1Balance -= stakeA; - - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); - - console.log(`d1 requests full withdrawal: ${toEth(stakeA)} ETH from NodeA`); - await Staking.connect(d1).requestWithdrawal(NodeA.identityId, stakeA); - await expect(Staking.connect(d1).finalizeWithdrawal(NodeA.identityId)).to.be - .reverted; - console.log('Early finalize reverted as expected'); - await time.increase(Number(delay)); - const d1BeforeFinal = await Token.balanceOf(d1.address); - console.log(`d1 before finalizing: ${toEth(d1BeforeFinal)} ETH`); - await Staking.connect(d1).finalizeWithdrawal(NodeA.identityId); - const d1AfterFinal = await Token.balanceOf(d1.address); - const withdrawn = d1AfterFinal - d1BeforeFinal; - d1Balance += withdrawn; - console.log( - `d1 got back ${toEth(withdrawn)} ETH, balance now: ${toEth(d1Balance)} ETH`, + // Request withdrawal of half that stake + const withdrawAmt = baseStake / 2n; + await Staking.requestWithdrawal(identityId, withdrawAmt); + + // Snapshot state before cancel + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const [pendingBefore] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKey, + ); + const stakeBefore = await StakingStorage.getNodeStake(identityId); + const totalBefore = await StakingStorage.getTotalStake(); + + // Sanity – pending equals requested amount + expect(pendingBefore).to.equal( + withdrawAmt, + 'Pending should be equal to requested amount', + ); + + // Cancel the withdrawal – should restake full amount (pending becomes 0) + await Staking.cancelWithdrawal(identityId); + + // After cancel + const [pendingAfter] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKey, + ); + expect(pendingAfter).to.equal( + 0n, + 'All pending amount should have been restaked', + ); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + baseStake, + 'Node stake should be equal to stakeBefore + withdrawAmt', + ); + expect(await StakingStorage.getTotalStake()).to.equal( + baseStake, + 'Total stake should be equal to totalBefore + withdrawAmt', ); - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); + // Delegator should still be registered for the node + expect( + await DelegatorsInfo.isNodeDelegator(identityId, accounts[0].address), + ).to.equal(true, 'Delegator should still be registered for the node'); + }); - if (minStake > d1Balance) - throw new Error('Not enough balance for d1 to restake'); - console.log(`d1 stakes again ${toEth(minStake)} ETH on NodeA`); - await Staking.connect(d1).stake(NodeA.identityId, minStake); - d1Balance -= minStake; + it('↩️ cancelWithdrawal does NOT restake when node already at maximumStake', async () => { + // Shrink maximumStake for deterministic maths + const smallMax = hre.ethers.parseEther('20'); + await ParametersStorage.setMaximumStake(smallMax); - await logNodeData(NodeA.identityId); - await logDelegatorData(NodeA.identityId, d1); + const { identityId } = await createProfile(); - const d1Key = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [d1.address]), + // Delegator A stakes the full max + await Token.mint(accounts[0].address, smallMax); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + smallMax, ); - let [d1BaseA, d1IndexedA] = await StakingStorage.getDelegatorStakeInfo( - NodeA.identityId, - d1Key, + await Staking.stake(identityId, smallMax); + + // Delegator A requests withdrawal of 10 + const withdrawAmt = hre.ethers.parseEther('10'); + await Staking.requestWithdrawal(identityId, withdrawAmt); + + // Delegator B fills the gap so node stake is back at the cap + await Token.mint(accounts[2].address, withdrawAmt); + await Token.connect(accounts[2]).approve( + await Staking.getAddress(), + withdrawAmt, ); - let d1TotalA = d1BaseA + d1IndexedA; - console.log(`d1 total stake on NodeA: ${toEth(d1TotalA)} ETH`); - await expect( - Staking.connect(d1).redelegate( - NodeA.identityId, - NodeB.identityId, - d1TotalA + 1n, - ), - ).to.be.reverted; - console.log('Redelegation exceeding stake reverted as expected'); + await Staking.connect(accounts[2]).stake(identityId, withdrawAmt); - const nodeBStakeBefore = await StakingStorage.getNodeStake( - NodeB.identityId, + // Ensure node stake equals the maximum before cancel + expect(await StakingStorage.getNodeStake(identityId)).to.equal(smallMax); + + // Snapshot state + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), ); - const redelegateAmount = minStake - nodeBStakeBefore - 1n; - const safeRedelegate = - redelegateAmount > d1TotalA ? d1TotalA : redelegateAmount; - console.log( - `d1 redelegates ${toEth(safeRedelegate)} ETH from NodeA to NodeB`, - ); - await Staking.connect(d1).redelegate( - NodeA.identityId, - NodeB.identityId, - safeRedelegate, - ); - await logNodeData(NodeB.identityId); - await logDelegatorData(NodeB.identityId, d1); - - await Token.mint(d2.address, minStake); - d2Balance += minStake; - console.log(`d2 minted balance: ${toEth(d2Balance)} ETH`); - await Token.connect(d2).approve(Staking.getAddress(), minStake); - console.log(`d2 stakes ${toEth(minStake)} ETH on NodeB`); - await Staking.connect(d2).stake(NodeB.identityId, minStake); - d2Balance -= minStake; - - await logNodeData(NodeB.identityId); - await logDelegatorData(NodeB.identityId, d2); - - const hugeRewardB = hre.ethers.parseEther('5000000'); - console.log(`Distributing huge reward: ${toEth(hugeRewardB)} ETH to NodeB`); - await Token.mint(StakingStorage.getAddress(), hugeRewardB); - await Staking.distributeRewards(NodeB.identityId, hugeRewardB); - opFeeBalanceB = await StakingStorage.getOperatorFeeBalance( - NodeB.identityId, - ); - console.log(`Operator fee on NodeB: ${toEth(opFeeBalanceB)} ETH`); - await expect( - Staking.connect(admin2).restakeOperatorFee( - NodeB.identityId, - opFeeBalanceB, - ), - ).to.be.reverted; - console.log('Exceed max restake reverted as expected'); - - const nodeBStake = await StakingStorage.getNodeStake(NodeB.identityId); - const partialRestake = - maxStake > nodeBStake ? (maxStake - nodeBStake) / 2n : 0n; - const safePartialRestake = - partialRestake > opFeeBalanceB ? opFeeBalanceB : partialRestake; - if (safePartialRestake > 0n) { - console.log(`Restaking partial fee: ${toEth(safePartialRestake)} ETH`); - await Staking.connect(admin2).restakeOperatorFee( - NodeB.identityId, - safePartialRestake, - ); - opFeeBalanceB -= safePartialRestake; - } + const [pendingBefore, , tsBefore] = + await StakingStorage.getDelegatorWithdrawalRequest(identityId, dKey); + const totalBefore = await StakingStorage.getTotalStake(); + + // Cancel withdrawal – should NOT restake anything + await Staking.cancelWithdrawal(identityId); - console.log(`d1 stakes again minStake+10 ETH on NodeA`); - if (minStake + 10n > d1Balance) - throw new Error('Not enough balance for d1'); - await Staking.connect(d1).stake(NodeA.identityId, minStake + 10n); - d1Balance -= minStake + 10n; - [d1BaseA, d1IndexedA] = await StakingStorage.getDelegatorStakeInfo( - NodeA.identityId, - d1Key, + const [pendingAfter, , tsAfter] = + await StakingStorage.getDelegatorWithdrawalRequest(identityId, dKey); + expect(pendingAfter).to.equal( + pendingBefore, + 'Pending amount should remain unchanged', ); - d1TotalA = d1BaseA + d1IndexedA; - console.log( - `d1 total stake now: ${toEth(d1TotalA)} ETH, balance: ${toEth(d1Balance)} ETH`, + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + smallMax, + 'Node stake should remain at maximum', + ); + expect(await StakingStorage.getTotalStake()).to.equal( + totalBefore, + 'Total stake should be unchanged', + ); + expect(tsAfter).to.equal( + tsBefore, + 'Release timestamp should stay the same', ); - const firstReq = d1TotalA / 2n; - console.log(`d1 requests withdrawal: ${toEth(firstReq)} ETH from NodeA`); - await Staking.connect(d1).requestWithdrawal(NodeA.identityId, firstReq); + // Delegator remains registered + expect( + await DelegatorsInfo.isNodeDelegator(identityId, accounts[0].address), + ).to.equal(true, 'Delegator should still be registered for the node'); + }); - const secondReq = d1TotalA / 4n; - console.log( - `d1 requests another withdrawal: ${toEth(secondReq)} ETH from NodeA`, + /********************************************************************** + * Claim-pointer auto-advance & delegator-removal behaviours + **********************************************************************/ + + it('🚦 _validateDelegatorEpochClaims auto-advances when previous epoch has no score', async () => { + const { identityId } = await createProfile(); + // Stake some amount in the current epoch + const amount = hre.ethers.parseEther('100'); + await Token.mint(accounts[0].address, amount); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + amount, ); - await expect( - Staking.connect(d1).requestWithdrawal(NodeA.identityId, secondReq), - ).to.not.be.reverted; - console.log('Multiple overlapping requests done'); + await Staking.stake(identityId, amount); - console.log('No operator fee request on NodeA, try finalize anyway'); - await expect( - Staking.connect(admin1).finalizeOperatorFeeWithdrawal(NodeA.identityId), - ).to.be.reverted; - console.log('Reverted as expected'); + // Snapshot lastClaimedEpoch after initial stake + let lastClaimedBefore = await DelegatorsInfo.getLastClaimedEpoch( + identityId, + accounts[0].address, + ); - const d2Key = hre.ethers.keccak256( - hre.ethers.solidityPacked(['address'], [d2.address]), + // Advance to the next epoch WITHOUT adding any scores + let inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + // Current and previous epoch values + // @ts-ignore – getCurrentEpoch returns bigint + const currentEpoch = await Chronos.getCurrentEpoch(); + const prevEpoch = currentEpoch - 1n; + + // Attempt to change stake (add 10) – should NOT revert because helper auto-advances + const addAmt = hre.ethers.parseEther('10'); + await Token.mint(accounts[0].address, addAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + addAmt, ); - const [d2BaseB, d2IndexedB] = await StakingStorage.getDelegatorStakeInfo( - NodeB.identityId, - d2Key, + await expect(Staking.stake(identityId, addAmt)).to.not.be.reverted; + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + amount + addAmt, + 'Node stake should be equal to amount + addAmt', ); - const d2TotalStakeOnB = d2BaseB + d2IndexedB; - const d2WithdrawReq = d2TotalStakeOnB / 4n; - console.log( - `d2 requests ${toEth(d2WithdrawReq)} ETH withdrawal from NodeB`, + + // Verify the claim pointer was auto-advanced to prevEpoch + const lastClaimedAfter = await DelegatorsInfo.getLastClaimedEpoch( + identityId, + accounts[0].address, + ); + expect(lastClaimedAfter).to.equal( + prevEpoch, + 'Last claimed epoch should auto-advance to previous epoch', + ); + expect(lastClaimedAfter).to.be.gt( + lastClaimedBefore, + 'Pointer must advance', + ); + }); + + it('👤 _handleDelegatorRemovalOnZeroStake keeps delegator when they earned score in epoch', async () => { + const { identityId } = await createProfile(); + // Stake + const stakeAmt = hre.ethers.parseEther('50'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + // Add some score for the current epoch so delegatorEpochScore18 > 0 + // @ts-ignore – getCurrentEpoch returns bigint + const epoch = await Chronos.getCurrentEpoch(); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const SCALE18 = hre.ethers.parseUnits('1', 18); + await RandomSamplingStorage.addToEpochNodeDelegatorScore( + epoch, + identityId, + dKey, + SCALE18, + ); + + // Withdraw ALL stake + await Staking.requestWithdrawal(identityId, stakeAmt); + + // Delegator should still be registered & lastStakeHeldEpoch == currentEpoch + const lastStakeHeld = await DelegatorsInfo.getLastStakeHeldEpoch( + identityId, + accounts[0].address, + ); + expect(lastStakeHeld).to.equal( + epoch, + 'lastStakeHeldEpoch must be set to current epoch', + ); + expect( + await DelegatorsInfo.isNodeDelegator(identityId, accounts[0].address), + ).to.equal(true, 'Delegator should still be registered for the node'); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, dKey), + ).to.equal(0n, 'Delegator stake should be 0'); + }); + + it('👥 _handleDelegatorRemovalOnZeroStake removes delegator when no score earned in epoch', async () => { + const { identityId } = await createProfile(); + // Stake + const stakeAmt = hre.ethers.parseEther('40'); + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + // No score added for current epoch + + // Withdraw ALL stake + await Staking.requestWithdrawal(identityId, stakeAmt); + + // Delegator should be removed immediately (isNodeDelegator == false) and lastStakeHeldEpoch == 0 + const lastStakeHeld = await DelegatorsInfo.getLastStakeHeldEpoch( + identityId, + accounts[0].address, + ); + expect(lastStakeHeld).to.equal(0n, 'lastStakeHeldEpoch must stay zero'); + expect( + await DelegatorsInfo.isNodeDelegator(identityId, accounts[0].address), + ).to.equal(false, 'Delegator should be removed'); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, dKey), + ).to.equal(0n, 'Delegator stake should be 0'); + }); + + it('🪫 zero-stake restake bumps index without adding score', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('10'); + // Stake initial amount + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + // Withdraw full stake and finalise so stakeBase becomes 0 + await Staking.requestWithdrawal(identityId, stakeAmt); + // advance time to let withdrawal finalise + const dKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const [, , ts] = await StakingStorage.getDelegatorWithdrawalRequest( + identityId, + dKey, + ); + await time.increaseTo(BigInt(ts)); + await Staking.finalizeWithdrawal(identityId); + expect(await StakingStorage.getNodeStake(identityId)).to.equal( + 0n, + 'Node stake should be 0', + ); + expect( + await StakingStorage.getDelegatorStakeBase(identityId, dKey), + ).to.equal(0n, 'Delegator stake should be 0'); + + // Move to next epoch + let inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + // @ts-ignore + const epoch = await Chronos.getCurrentEpoch(); + + // Manually set a non-zero nodeScorePerStake so prepare branches + const perStake = 123n; + await RandomSamplingStorage.addToNodeEpochScorePerStake( + epoch, + identityId, + perStake, + ); + + // Sanity: delegator score for epoch should be zero + expect( + await RandomSamplingStorage.getEpochNodeDelegatorScore( + epoch, + identityId, + dKey, + ), + ).to.equal(0n); + + // Restake 1 wei (will invoke _prepareForStakeChange with stakeBase==0) + await Token.mint(accounts[0].address, 1n); + await Token.connect(accounts[0]).approve(await Staking.getAddress(), 1n); + await Staking.stake(identityId, 1n); + + // Index should now equal perStake and score still zero + expect( + await RandomSamplingStorage.getDelegatorLastSettledNodeEpochScorePerStake( + epoch, + identityId, + dKey, + ), + ).to.equal( + perStake, + 'Delegator last settled node epoch score per stake should be equal to perStake', + ); + expect( + await RandomSamplingStorage.getEpochNodeDelegatorScore( + epoch, + identityId, + dKey, + ), + ).to.equal(0n, 'Delegator score for epoch should be 0'); + }); + + it('💸 finalizeOperatorFeeWithdrawal happy-path updates cumulatives and transfers tokens', async () => { + const { identityId } = await createProfile(); + const initialFeeBalance = hre.ethers.parseEther('150'); + await StakingStorage.setOperatorFeeBalance(identityId, initialFeeBalance); + + // Request withdrawal of 60 + const withdrawAmt = hre.ethers.parseEther('60'); + const delay = await ParametersStorage.stakeWithdrawalDelay(); + const tx = await Staking.requestOperatorFeeWithdrawal( + identityId, + withdrawAmt, + ); + // capture request release timestamp from storage + // @ts-ignore + const [, , releaseTs] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + + // Mint tokens to the StakingStorage contract so withdrawal can succeed + await Token.mint(await StakingStorage.getAddress(), withdrawAmt); + + // Snapshot cumulative paid-out before + const paidOutBefore = + await StakingStorage.getOperatorFeeCumulativePaidOutRewards(identityId); + const balBefore = await Token.balanceOf(accounts[0].address); + + // Advance time to release + await time.increaseTo(BigInt(releaseTs)); + await Staking.finalizeOperatorFeeWithdrawal(identityId); + + const paidOutAfter = + await StakingStorage.getOperatorFeeCumulativePaidOutRewards(identityId); + const balAfter = await Token.balanceOf(accounts[0].address); + + expect(paidOutAfter).to.equal( + paidOutBefore + withdrawAmt, + 'Cumulative paid-out should increase', + ); + expect(balAfter - balBefore).to.equal( + withdrawAmt, + 'Admin balance should increase by withdrawn amount', ); - await Staking.connect(d2).requestWithdrawal( - NodeB.identityId, - d2WithdrawReq, + // Request should be cleared after finalization + const [reqAmtAfter] = + await StakingStorage.getOperatorFeeWithdrawalRequest(identityId); + expect(reqAmtAfter).to.equal( + 0, + 'Withdrawal request amount should be zero after finalization', ); + }); + + it('🚫 batchClaimDelegatorRewards reverts when older epochs unclaimed', async () => { + const { identityId } = await createProfile(); + const stakeAmt = hre.ethers.parseEther('30'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, + ); + await Staking.stake(identityId, stakeAmt); + + // Get current epoch E1 + // @ts-ignore + const epoch1 = await Chronos.getCurrentEpoch(); + // Move to next epoch E2 + let inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + // @ts-ignore + const epoch2 = await Chronos.getCurrentEpoch(); + // Advance to E3 so both previous epochs are claimable + inc = await Chronos.timeUntilNextEpoch(); + await time.increase(inc + 1n); + + // Attempt batch claim skipping epoch1 – expect revert await expect( - Staking.connect(d2).finalizeWithdrawal(NodeB.identityId), - ).to.be.revertedWithCustomError(Staking, 'WithdrawalPeriodPending'); - console.log('Too early finalize reverted'); - console.log('d2 cancels withdrawal'); - await Staking.connect(d2).cancelWithdrawal(NodeB.identityId); + Staking.batchClaimDelegatorRewards( + identityId, + [epoch2], + [accounts[0].address], + ), + ).to.be.revertedWith('Must claim older epochs first'); + }); + + /********************************************************************** + * Token/Allowance & sharding-table guards + **********************************************************************/ + + it('⛔️ redelegate with 0 amount reverts ZeroTokenAmount', async () => { + const { identityId: fromId } = await createProfile(undefined, accounts[3]); + const { identityId: toId } = await createProfile(undefined, accounts[4]); await expect( - Staking.connect(d2).cancelWithdrawal(NodeB.identityId), - ).to.be.revertedWithCustomError(Staking, 'WithdrawalWasntInitiated'); - console.log('Second cancel reverted as expected'); + Staking.redelegate(fromId, toId, 0n), + ).to.be.revertedWithCustomError(Staking, 'ZeroTokenAmount'); + }); - console.log('d3 never staked on NodeA, tries withdrawing 100 ETH'); - await expect(Staking.connect(d3).requestWithdrawal(NodeA.identityId, 100n)) - .to.be.reverted; - console.log('No stake withdrawal reverted as expected'); + it('📈 ShardingTableIsFull guard triggers when limit reached', async () => { + // Reduce table size for test speed + await ParametersStorage.setShardingTableSizeLimit(2); + const minStake = await ParametersStorage.minimumStake(); - const halfD2StakeB = - (await StakingStorage.getNodeStake(NodeB.identityId)) / 5n; - console.log( - `d2 requests 20% stake withdrawal on NodeB: ${toEth(halfD2StakeB)} ETH`, + let opIndex = 3; + const stakeNode = async () => { + const { identityId } = await createProfile( + undefined, + accounts[opIndex++], + ); + await Token.mint(accounts[0].address, minStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + minStake, + ); + await Staking.stake(identityId, minStake); + }; + + await stakeNode(); // node 1 + await stakeNode(); // node 2 – at limit + + const { identityId: overflowId } = await createProfile( + undefined, + accounts[opIndex++], ); - await Staking.connect(d2).requestWithdrawal(NodeB.identityId, halfD2StakeB); - await time.increase(Number(delay)); - const d2BeforeFinal = await Token.balanceOf(d2.address); - console.log( - `Finalizing large d2 withdrawal. Before: ${toEth(d2BeforeFinal)} ETH`, + await Token.mint(accounts[0].address, minStake); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + minStake, ); - await Staking.connect(d2).finalizeWithdrawal(NodeB.identityId); - const d2AfterFinal = await Token.balanceOf(d2.address); - d2Balance += d2AfterFinal - d2BeforeFinal; - console.log( - `d2 after final: ${toEth(d2AfterFinal)} ETH, gained: ${toEth(d2AfterFinal - d2BeforeFinal)} ETH, d2Balance: ${toEth(d2Balance)} ETH`, + await expect( + Staking.stake(overflowId, minStake), + ).to.be.revertedWithCustomError(Staking, 'ShardingTableIsFull'); + }); + + it('📣 emits OperatorFeeBalanceUpdated and DelegatorBaseStakeUpdated on restakeOperatorFee', async () => { + const { identityId } = await createProfile(); + // Seed operator fee balance + const initialFee = 50n; + await StakingStorage.setOperatorFeeBalance(identityId, initialFee); + + // ensure node has stake + const stakeAmt = hre.ethers.parseEther('100'); + await Token.mint(accounts[0].address, stakeAmt); + await Token.connect(accounts[0]).approve( + await Staking.getAddress(), + stakeAmt, ); + await Staking.stake(identityId, stakeAmt); - if ((await ShardingTableStorage.nodeExists(NodeB.identityId)) === false) { - const [d2BaseB2, d2IndexedB2] = - await StakingStorage.getDelegatorStakeInfo(NodeB.identityId, d2Key); - const d2TotalB = d2BaseB2 + d2IndexedB2; - console.log(`NodeB out of table, d2 totalB: ${toEth(d2TotalB)} ETH`); - if (d2TotalB > 0) { - console.log( - `d2 redelegates ${toEth(d2TotalB)} ETH from NodeB to NodeA`, - ); - await expect( - Staking.connect(d2).redelegate( - NodeB.identityId, - NodeA.identityId, - d2TotalB, - ), - ).to.not.be.reverted; - } - } + const restakeAmt = 20n; + const newFeeBal = initialFee - restakeAmt; + const delegatorKey = hre.ethers.keccak256( + hre.ethers.solidityPacked(['address'], [accounts[0].address]), + ); + const baseBefore = await StakingStorage.getDelegatorStakeBase( + identityId, + delegatorKey, + ); + + const tx = await Staking.restakeOperatorFee(identityId, restakeAmt); - // Validate no negative values: - const finalNodeAStake = await StakingStorage.getNodeStake(NodeA.identityId); - const finalNodeBStake = await StakingStorage.getNodeStake(NodeB.identityId); - expect(finalNodeAStake).to.be.gte(0n); - expect(finalNodeBStake).to.be.gte(0n); + await expect(tx) + .to.emit(StakingStorage, 'OperatorFeeBalanceUpdated') + .withArgs(identityId, newFeeBal); - console.log('--- END STRESS TEST SCENARIO ---'); + await expect(tx) + .to.emit(StakingStorage, 'DelegatorBaseStakeUpdated') + .withArgs(identityId, delegatorKey, baseBefore + restakeAmt); }); });