From afbe3c06bae4a20e2a9d902edd197bf8d9d1bdee Mon Sep 17 00:00:00 2001 From: Uladzislau Hubar Date: Fri, 2 Feb 2024 03:11:47 +0100 Subject: [PATCH] Added new events and unit tests for CommitManagerV2U1/StakingV2 --- abi/Profile.json | 6 + abi/StakingV2.json | 72 ++ contracts/v1/Profile.sol | 4 +- contracts/v2/Staking.sol | 20 +- contracts/v2/errors/StakingErrors.sol | 4 +- test/v1/unit/Profile.test.ts | 7 +- test/v2/unit/CommitManagerV2U1.test.ts | 871 +++++++++++++++++++++++++ test/v2/unit/StakingV2.test.ts | 533 +++++++++++++++ 8 files changed, 1508 insertions(+), 9 deletions(-) create mode 100644 test/v2/unit/CommitManagerV2U1.test.ts create mode 100644 test/v2/unit/StakingV2.test.ts diff --git a/abi/Profile.json b/abi/Profile.json index 3c599ab3..ccf5205d 100644 --- a/abi/Profile.json +++ b/abi/Profile.json @@ -49,6 +49,12 @@ "internalType": "bytes", "name": "nodeId", "type": "bytes" + }, + { + "indexed": false, + "internalType": "address", + "name": "sharesContractAddress", + "type": "address" } ], "name": "ProfileCreated", diff --git a/abi/StakingV2.json b/abi/StakingV2.json index 949d7381..a16a9665 100644 --- a/abi/StakingV2.json +++ b/abi/StakingV2.json @@ -39,6 +39,11 @@ }, { "inputs": [ + { + "internalType": "uint256", + "name": "nowTimestamp", + "type": "uint256" + }, { "internalType": "uint256", "name": "endTimestamp", @@ -93,6 +98,11 @@ }, { "inputs": [ + { + "internalType": "uint256", + "name": "nowTimestamp", + "type": "uint256" + }, { "internalType": "uint256", "name": "endTimestamp", @@ -236,6 +246,68 @@ "name": "RewardCollected", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sharesContractAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "delegator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sharesBurnedAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newTotalSupply", + "type": "uint256" + } + ], + "name": "SharesBurned", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sharesContractAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "delegator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "sharesMintedAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newTotalSupply", + "type": "uint256" + } + ], + "name": "SharesMinted", + "type": "event" + }, { "anonymous": false, "inputs": [ diff --git a/contracts/v1/Profile.sol b/contracts/v1/Profile.sol index d41011a5..1b5f321d 100644 --- a/contracts/v1/Profile.sol +++ b/contracts/v1/Profile.sol @@ -18,7 +18,7 @@ import {UnorderedIndexableContractDynamicSetLib} from "./utils/UnorderedIndexabl import {ADMIN_KEY, OPERATIONAL_KEY} from "./constants/IdentityConstants.sol"; contract Profile is Named, Versioned, ContractStatus, Initializable { - event ProfileCreated(uint72 indexed identityId, bytes nodeId); + event ProfileCreated(uint72 indexed identityId, bytes nodeId, address sharesContractAddress); event ProfileDeleted(uint72 indexed identityId); event AskUpdated(uint72 indexed identityId, bytes nodeId, uint96 ask); @@ -104,7 +104,7 @@ contract Profile is Named, Versioned, ContractStatus, Initializable { ps.createProfile(identityId, nodeId, address(sharesContract)); _setAvailableNodeAddresses(identityId); - emit ProfileCreated(identityId, nodeId); + emit ProfileCreated(identityId, nodeId, address(sharesContract)); } function setAsk(uint72 identityId, uint96 ask) external onlyIdentityOwner(identityId) { diff --git a/contracts/v2/Staking.sol b/contracts/v2/Staking.sol index c70605b6..b57b8588 100644 --- a/contracts/v2/Staking.sol +++ b/contracts/v2/Staking.sol @@ -30,6 +30,12 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { uint96 oldStake, uint96 newStake ); + event SharesMinted( + address indexed sharesContractAddress, + address indexed delegator, + uint256 sharesMintedAmount, + uint256 newTotalSupply + ); event RewardCollected( uint72 indexed identityId, bytes nodeId, @@ -46,6 +52,12 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { uint256 withdrawalPeriodEnd ); event StakeWithdrawn(uint72 indexed identityId, bytes nodeId, address indexed staker, uint96 withdrawnStakeAmount); + event SharesBurned( + address indexed sharesContractAddress, + address indexed delegator, + uint256 sharesBurnedAmount, + uint256 newTotalSupply + ); event AccumulatedOperatorFeeIncreased( uint72 indexed identityId, bytes nodeId, @@ -144,6 +156,7 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { newStake, withdrawalPeriodEnd ); + emit SharesBurned(address(sharesContract), msg.sender, sharesToBurn, sharesContract.totalSupply()); } function withdrawStake(uint72 identityId) external { @@ -158,7 +171,8 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { (stakeWithdrawalAmount, withdrawalTimestamp) = ss.withdrawalRequests(identityId, msg.sender); if (stakeWithdrawalAmount == 0) revert StakingErrors.WithdrawalWasntInitiated(); - if (withdrawalTimestamp >= block.timestamp) revert StakingErrors.WithdrawalPeriodPending(withdrawalTimestamp); + if (block.timestamp < withdrawalTimestamp) + revert StakingErrors.WithdrawalPeriodPending(block.timestamp, withdrawalTimestamp); ss.deleteWithdrawalRequest(identityId, msg.sender); ss.transferStake(msg.sender, stakeWithdrawalAmount); @@ -232,7 +246,8 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { uint256 feeChangeDelayEnd; (newFee, feeChangeDelayEnd) = nofcs.operatorFeeChangeRequests(identityId); - if (block.timestamp < feeChangeDelayEnd) revert StakingErrors.OperatorFeeChangeDelayPending(feeChangeDelayEnd); + if (block.timestamp < feeChangeDelayEnd) + revert StakingErrors.OperatorFeeChangeDelayPending(block.timestamp, feeChangeDelayEnd); stakingStorage.setOperatorFee(identityId, newFee); nofcs.deleteOperatorFeeChangeRequest(identityId); @@ -269,6 +284,7 @@ contract StakingV2 is Named, Versioned, ContractStatus, Initializable { shardingTableContract.insertNode(identityId); emit StakeIncreased(identityId, ps.getNodeId(identityId), sender, oldStake, newStake); + emit SharesMinted(address(sharesContract), sender, sharesMinted, sharesContract.totalSupply()); } function _checkAdmin(uint72 identityId) internal view virtual { diff --git a/contracts/v2/errors/StakingErrors.sol b/contracts/v2/errors/StakingErrors.sol index 7577570b..ebd3b984 100644 --- a/contracts/v2/errors/StakingErrors.sol +++ b/contracts/v2/errors/StakingErrors.sol @@ -5,8 +5,8 @@ pragma solidity ^0.8.16; library StakingErrors { error ZeroSharesAmount(); error WithdrawalWasntInitiated(); - error WithdrawalPeriodPending(uint256 endTimestamp); + error WithdrawalPeriodPending(uint256 nowTimestamp, uint256 endTimestamp); error InvalidOperatorFee(); - error OperatorFeeChangeDelayPending(uint256 endTimestamp); + error OperatorFeeChangeDelayPending(uint256 nowTimestamp, uint256 endTimestamp); error MaximumStakeExceeded(uint256 amount); } diff --git a/test/v1/unit/Profile.test.ts b/test/v1/unit/Profile.test.ts index 7c18d62c..747f8e82 100644 --- a/test/v1/unit/Profile.test.ts +++ b/test/v1/unit/Profile.test.ts @@ -28,9 +28,10 @@ describe('@v1 @unit Profile contract', function () { const identityId1 = 1; async function createProfile() { - await expect(Profile.createProfile(accounts[1].address, nodeId1, 'Token', 'TKN')) - .to.emit(Profile, 'ProfileCreated') - .withArgs(identityId1, nodeId1); + await expect(Profile.createProfile(accounts[1].address, nodeId1, 'Token', 'TKN')).to.emit( + Profile, + 'ProfileCreated', + ); } async function deployProfileFixture(): Promise { diff --git a/test/v2/unit/CommitManagerV2U1.test.ts b/test/v2/unit/CommitManagerV2U1.test.ts new file mode 100644 index 00000000..f4e2d94e --- /dev/null +++ b/test/v2/unit/CommitManagerV2U1.test.ts @@ -0,0 +1,871 @@ +import { randomBytes } from 'crypto'; + +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { expect } from 'chai'; +import { BytesLike, BigNumber } from 'ethers'; +import hre from 'hardhat'; + +import { + CommitManagerV2, + CommitManagerV2U1, + ContentAsset, + ContentAssetStorageV2, + LinearSum, + ParametersStorage, + Profile, + ProfileStorage, + ServiceAgreementV1, + StakingV2, + Token, +} from '../../../typechain'; +import { ContentAssetStructs } from '../../../typechain/contracts/v1/assets/ContentAsset'; +import { ServiceAgreementStructsV1 } from '../../../typechain/contracts/v1/CommitManagerV1U1'; +import { ServiceAgreementStructsV2 } from '../../../typechain/contracts/v2/CommitManagerV2U1'; + +const UINT256_MAX_BN = BigNumber.from(2).pow(256).sub(1); +const UINT64_MAX_BN = BigNumber.from(2).pow(64).sub(1); +const UINT40_MAX_BN = BigNumber.from(2).pow(40).sub(1); + +type CommitManagerV2U1Fixture = { + accounts: SignerWithAddress[]; + CommitManagerV2: CommitManagerV2; + CommitManagerV2U1: CommitManagerV2U1; +}; + +type Node = { + account: SignerWithAddress; + identityId: number; + nodeId: BytesLike; + sha256: BytesLike; + stake: BigNumber; +}; + +type NodeWithDistance = { + account: SignerWithAddress; + identityId: number; + nodeId: BytesLike; + sha256: BytesLike; + stake: BigNumber; + index: BigNumber; + distance: BigNumber; +}; + +describe('@v2 @unit CommitManagerV2U1 contract', function () { + const HASH_RING_SIZE = BigNumber.from(2).pow(256); + + let accounts: SignerWithAddress[]; + let Token: Token; + let ServiceAgreementV1: ServiceAgreementV1; + let ContentAsset: ContentAsset; + let ContentAssetStorageV2: ContentAssetStorageV2; + let LinearSum: LinearSum; + let CommitManagerV2: CommitManagerV2; + let CommitManagerV2U1: CommitManagerV2U1; + let ParametersStorage: ParametersStorage; + let ProfileStorage: ProfileStorage; + let Profile: Profile; + let StakingV2: StakingV2; + + let commitV1InputArgs: ServiceAgreementStructsV1.CommitInputArgsStruct; + let commitV2InputArgs: ServiceAgreementStructsV2.CommitInputArgsStruct; + + async function createAsset( + scoreFunctionId = 1, + ): Promise<{ tokenId: number; keyword: BytesLike; agreementId: BytesLike }> { + const assetInputStruct: ContentAssetStructs.AssetInputArgsStruct = { + assertionId: '0x' + randomBytes(32).toString('hex'), + size: 1000, + triplesNumber: 10, + chunksNumber: 10, + epochsNumber: 5, + tokenAmount: hre.ethers.utils.parseEther('250'), + scoreFunctionId, + immutable_: false, + }; + + await Token.increaseAllowance(ServiceAgreementV1.address, assetInputStruct.tokenAmount); + const receipt = await (await ContentAsset.createAsset(assetInputStruct)).wait(); + + const tokenId = Number(receipt.logs[0].topics[3]); + const keyword = hre.ethers.utils.solidityPack( + ['address', 'bytes32'], + [ContentAssetStorageV2.address, assetInputStruct.assertionId], + ); + const agreementId = hre.ethers.utils.soliditySha256( + ['address', 'uint256', 'bytes'], + [ContentAssetStorageV2.address, tokenId, keyword], + ); + + return { tokenId, keyword, agreementId }; + } + + async function updateAsset(tokenId: number) { + const assetUpdateArgs = { + assertionId: '0x' + randomBytes(32).toString('hex'), + size: 2000, + triplesNumber: 20, + chunksNumber: 20, + tokenAmount: hre.ethers.utils.parseEther('500'), + }; + + await Token.increaseAllowance(ServiceAgreementV1.address, assetUpdateArgs.tokenAmount); + await ContentAsset.updateAssetState( + tokenId, + assetUpdateArgs.assertionId, + assetUpdateArgs.size, + assetUpdateArgs.triplesNumber, + assetUpdateArgs.chunksNumber, + assetUpdateArgs.tokenAmount, + ); + } + + async function finalizeUpdateV1(tokenId: number, keyword: BytesLike): Promise { + const finalizationRequirement = await ParametersStorage.finalizationCommitsNumber(); + + const nodes = []; + for (let i = 0; i < finalizationRequirement; i++) { + nodes.push(await createProfile(accounts[i], accounts[accounts.length - 1])); + } + + commitV1InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + }; + + for (let i = 0; i < finalizationRequirement; i++) { + await expect( + CommitManagerV2U1.connect(accounts[i])['submitUpdateCommit((address,uint256,bytes,uint8,uint16))']( + commitV1InputArgs, + ), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + + return nodes; + } + + async function finalizeUpdateV2( + tokenId: number, + keyword: BytesLike, + ): Promise<{ + winners: { account: SignerWithAddress; identityId: number; score: number }[]; + closestNodeIndex: BigNumber; + leftEdgeNodeIndex: BigNumber; + rightEdgeNodeIndex: BigNumber; + }> { + const finalizationRequirement = await ParametersStorage.finalizationCommitsNumber(); + + const r2 = await ParametersStorage.r2(); + const minStake = await ParametersStorage.minimumStake(); + const maxStake = await ParametersStorage.maximumStake(); + const nodes = await createMultipleProfiles(30); + + const keyHash = hre.ethers.utils.soliditySha256(['bytes'], [keyword]); + + const neighborhood = await getNeighborhood(nodes, keyHash); + + const closestNode = neighborhood[0]; + const { leftEdgeNode, rightEdgeNode } = await getNeighborhoodEdgeNodes(neighborhood, keyHash); + + commitV2InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + closestNodeIndex: closestNode.index, + leftEdgeNodeIndex: leftEdgeNode.index, + rightEdgeNodeIndex: rightEdgeNode.index, + }; + + const scoredNeighborhood = await Promise.all( + neighborhood.map(async (node) => ({ + account: node.account, + identityId: node.identityId, + score: ( + await calculateScore( + node.distance, + node.stake, + neighborhood[neighborhood.length - 1].distance, + r2, + nodes.length, + minStake, + maxStake, + ) + ).toNumber(), + })), + ); + + scoredNeighborhood.sort((a, b) => a.score - b.score); + + for (let i = 0; i < finalizationRequirement; i++) { + await expect( + CommitManagerV2U1.connect(scoredNeighborhood[i].account)[ + 'submitUpdateCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))' + ](commitV2InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + + return { + winners: scoredNeighborhood.slice(0, finalizationRequirement), + closestNodeIndex: closestNode.index, + leftEdgeNodeIndex: leftEdgeNode.index, + rightEdgeNodeIndex: rightEdgeNode.index, + }; + } + + async function createProfile(operational: SignerWithAddress, admin: SignerWithAddress): Promise { + const { minter } = await hre.getNamedAccounts(); + const OperationalProfile = Profile.connect(operational); + + const nodeId = '0x' + randomBytes(32).toString('hex'); + const sha256 = hre.ethers.utils.soliditySha256(['bytes'], [nodeId]); + + const receipt = await ( + await OperationalProfile.createProfile( + admin.address, + nodeId, + randomBytes(5).toString('hex'), + randomBytes(3).toString('hex'), + ) + ).wait(); + const identityId = Number(receipt.logs[0].topics[1]); + const blockchainNodeId = await ProfileStorage.getNodeId(identityId); + const blockchainSha256 = await ProfileStorage.getNodeAddress(identityId, 1); + + expect(blockchainNodeId).to.be.equal(nodeId); + expect(blockchainSha256).to.be.equal(sha256); + + await OperationalProfile.setAsk(identityId, hre.ethers.utils.parseEther('0.25')); + + const minStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.minimumStake())); + const maxStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.maximumStake())); + const stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - minStake + 1)) + minStake}`, + ); + await Token.mint(admin.address, stakeAmount, { from: minter }); + await Token.connect(admin).increaseAllowance(StakingV2.address, stakeAmount); + await StakingV2.connect(admin)['addStake(uint72,uint96)'](identityId, stakeAmount); + + return { + account: operational, + identityId, + nodeId, + sha256, + stake: stakeAmount, + }; + } + + async function createMultipleProfiles(count = 150): Promise { + const nodes = []; + + for (let i = 0; i < count; i++) { + const node = await createProfile(accounts[i], accounts[i + count]); + nodes.push(node); + } + + return nodes; + } + + function calculateDistance(peerHash: BytesLike, keyHash: BytesLike): BigNumber { + const peerPositionOnHashRing = BigNumber.from(peerHash); + const keyPositionOnHashRing = BigNumber.from(keyHash); + + const directDistance = peerPositionOnHashRing.gt(keyPositionOnHashRing) + ? peerPositionOnHashRing.sub(keyPositionOnHashRing) + : keyPositionOnHashRing.sub(peerPositionOnHashRing); + const wraparoundDistance = HASH_RING_SIZE.sub(directDistance); + + return directDistance.lt(wraparoundDistance) ? directDistance : wraparoundDistance; + } + + async function calculateScore( + distance: BigNumber, + stake: BigNumber, + maxNeighborhoodDistance: BigNumber, + r2: number, + nodesNumber: number, + minStake: BigNumber, + maxStake: BigNumber, + ): Promise { + const linearSumParams = await LinearSum.getParameters(); + const [distanceScaleFactor, stakeScaleFactor, w1, w2] = linearSumParams; + + const idealMaxDistanceInNeighborhood = HASH_RING_SIZE.div(nodesNumber).mul(Math.ceil(r2 / 2)); + const divisor = + maxNeighborhoodDistance <= idealMaxDistanceInNeighborhood + ? maxNeighborhoodDistance + : idealMaxDistanceInNeighborhood; + + const maxMultiplier = UINT256_MAX_BN.div(distance); + + let scaledDistanceScaleFactor = distanceScaleFactor; + let compensationFactor = BigNumber.from(1); + + if (scaledDistanceScaleFactor.gt(maxMultiplier)) { + compensationFactor = scaledDistanceScaleFactor.div(maxMultiplier); + scaledDistanceScaleFactor = maxMultiplier; + } + + const scaledDistance = distance.mul(scaledDistanceScaleFactor); + const adjustedDivisor = divisor.div(compensationFactor); + + let normalizedDistance = scaledDistance.div(adjustedDivisor); + if (normalizedDistance.gt(UINT64_MAX_BN)) { + normalizedDistance = normalizedDistance.mod(UINT64_MAX_BN.add(1)); + } + + let normalizedStake = stakeScaleFactor.mul(stake.sub(minStake)).div(maxStake.sub(minStake)); + if (normalizedStake.gt(UINT64_MAX_BN)) { + normalizedStake = normalizedStake.mod(UINT64_MAX_BN.add(1)); + } + + const oneEther = BigNumber.from('1000000000000000000'); + + const isProximityScorePositive = oneEther.gte(normalizedDistance); + + const proximityScore = isProximityScorePositive + ? oneEther.sub(normalizedDistance).mul(w1) + : normalizedDistance.sub(oneEther).mul(w1); + const stakeScore = normalizedStake.mul(w2); + + let finalScore; + if (isProximityScorePositive) { + finalScore = proximityScore.add(stakeScore); + } else if (stakeScore.gte(proximityScore)) { + finalScore = stakeScore.sub(proximityScore); + } else { + finalScore = BigNumber.from(0); + } + + if (finalScore.gt(UINT40_MAX_BN)) { + finalScore = finalScore.mod(UINT40_MAX_BN.add(1)); + } + + return finalScore; + } + + async function getNeighborhood(nodes: Node[], keyHash: BytesLike): Promise { + const nodesWithIndexes = nodes + .sort((a, b) => { + const aBN = BigNumber.from(a.sha256); + const bBN = BigNumber.from(b.sha256); + if (aBN.eq(bBN)) { + return 0; + } + return aBN.lt(bBN) ? -1 : 1; + }) + .map((node, index) => ({ ...node, index: BigNumber.from(index) })); + + const nodesWithDistance = await Promise.all( + nodesWithIndexes.map(async (node) => ({ + node, + distance: calculateDistance(node.sha256, keyHash), + })), + ); + nodesWithDistance.sort((a, b) => { + if (a.distance.eq(b.distance)) { + return 0; + } + return a.distance.lt(b.distance) ? -1 : 1; + }); + return nodesWithDistance.slice(0, 20).map((pd) => ({ ...pd.node, distance: pd.distance })); + } + + async function getNeighborhoodEdgeNodes( + neighborhood: NodeWithDistance[], + keyHash: BytesLike, + ): Promise<{ leftEdgeNode: NodeWithDistance; rightEdgeNode: NodeWithDistance }> { + const assetPositionOnHashRing = BigNumber.from(keyHash); + const hashRing = []; + + const maxDistance = neighborhood[neighborhood.length - 1].distance; + + for (const neighbor of neighborhood) { + const neighborPositionOnHashRing = BigNumber.from(neighbor.sha256); + + if (neighborPositionOnHashRing.lte(assetPositionOnHashRing)) { + if (assetPositionOnHashRing.sub(neighborPositionOnHashRing).lte(maxDistance)) { + hashRing.unshift(neighbor); + } else { + hashRing.push(neighbor); + } + } else { + if (neighborPositionOnHashRing.sub(assetPositionOnHashRing).lte(maxDistance)) { + hashRing.push(neighbor); + } else { + hashRing.unshift(neighbor); + } + } + } + + return { + leftEdgeNode: hashRing[0], + rightEdgeNode: hashRing[hashRing.length - 1], + }; + } + + async function deployCommitManagerV2U1Fixture(): Promise { + await hre.deployments.fixture([ + 'HubV2', + 'ContentAssetStorageV2', + 'ShardingTableV2', + 'StakingV2', + 'CommitManagerV2', + 'CommitManagerV2U1', + 'ContentAsset', + 'Profile', + ]); + Token = await hre.ethers.getContract('Token'); + ServiceAgreementV1 = await hre.ethers.getContract('ServiceAgreementV1'); + ContentAsset = await hre.ethers.getContract('ContentAsset'); + ContentAssetStorageV2 = await hre.ethers.getContract('ContentAssetStorage'); + LinearSum = await hre.ethers.getContract('LinearSum'); + CommitManagerV2 = await hre.ethers.getContract('CommitManagerV1'); + CommitManagerV2U1 = await hre.ethers.getContract('CommitManagerV1U1'); + ParametersStorage = await hre.ethers.getContract('ParametersStorage'); + ProfileStorage = await hre.ethers.getContract('ProfileStorage'); + Profile = await hre.ethers.getContract('Profile'); + StakingV2 = await hre.ethers.getContract('Staking'); + ContentAssetStorageV2 = await hre.ethers.getContract('ContentAssetStorage'); + accounts = await hre.ethers.getSigners(); + + return { accounts, CommitManagerV2, CommitManagerV2U1 }; + } + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + ({ accounts, CommitManagerV2U1 } = await loadFixture(deployCommitManagerV2U1Fixture)); + }); + + it('The contract is named "CommitManagerV1U1"', async () => { + expect(await CommitManagerV2U1.name()).to.equal('CommitManagerV1U1'); + }); + + it('The contract is version "2.0.0"', async () => { + expect(await CommitManagerV2U1.version()).to.equal('2.0.0'); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update and finalize V1 update, check if commit window is open, expect to be true', async () => { + const { tokenId, keyword, agreementId } = await createAsset(); + await updateAsset(tokenId); + await finalizeUpdateV1(tokenId, keyword); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 0)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 0)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update and finalize V2 update, check if commit window is open, expect to be true', async () => { + const { tokenId, keyword, agreementId } = await createAsset(2); + await updateAsset(tokenId); + await finalizeUpdateV2(tokenId, keyword); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 0)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 0)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update and finalize V1 update, teleport to the end of commit phase and check if commit window is open, expect to be false', async () => { + const { tokenId, keyword, agreementId } = await createAsset(); + await updateAsset(tokenId); + await finalizeUpdateV1(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + const commitWindowDurationPerc = await ParametersStorage.commitWindowDurationPerc(); + const commitWindowDuration = (epochLength * commitWindowDurationPerc) / 100; + + await time.increase(commitWindowDuration + 1); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 0)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 0)).to.eql(false); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update and finalize V2 update, teleport to the end of commit phase and check if commit window is open, expect to be false', async () => { + const { tokenId, keyword, agreementId } = await createAsset(2); + await updateAsset(tokenId); + await finalizeUpdateV2(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + const commitWindowDurationPerc = await ParametersStorage.commitWindowDurationPerc(); + const commitWindowDuration = (epochLength * commitWindowDurationPerc) / 100; + + await time.increase(commitWindowDuration + 1); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 0)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 0)).to.eql(false); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update and finalize update V1, teleport to second epoch and check if commit window is open, expect to be true', async () => { + const { tokenId, keyword, agreementId } = await createAsset(); + await updateAsset(tokenId); + await finalizeUpdateV1(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 1)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 1)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update and finalize update V2, teleport to second epoch and check if commit window is open, expect to be true', async () => { + const { tokenId, keyword, agreementId } = await createAsset(2); + await updateAsset(tokenId); + await finalizeUpdateV2(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + await expect(CommitManagerV2.isCommitWindowOpen(agreementId, 1)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + expect(await CommitManagerV2U1.isCommitWindowOpen(agreementId, 1)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update it, check if update commit window is open, expect to be true', async () => { + const { tokenId, agreementId } = await createAsset(); + await updateAsset(tokenId); + + expect(await CommitManagerV2U1.isUpdateCommitWindowOpen(agreementId, 0, 1)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update it, check if update commit window is open, expect to be true', async () => { + const { tokenId, agreementId } = await createAsset(2); + await updateAsset(tokenId); + + expect(await CommitManagerV2U1.isUpdateCommitWindowOpen(agreementId, 0, 1)).to.eql(true); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update it, teleport to the end of update commit window and check if its open, expect to be false', async () => { + const { tokenId, agreementId } = await createAsset(); + await updateAsset(tokenId); + + const updateCommitWindowDuration = await ParametersStorage.updateCommitWindowDuration(); + await time.increase(updateCommitWindowDuration); + + expect(await CommitManagerV2U1.isUpdateCommitWindowOpen(agreementId, 0, 1)).to.eql(false); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update it, teleport to the end of update commit window and check if its open, expect to be false', async () => { + const { tokenId, agreementId } = await createAsset(2); + await updateAsset(tokenId); + + const updateCommitWindowDuration = await ParametersStorage.updateCommitWindowDuration(); + await time.increase(updateCommitWindowDuration); + + expect(await CommitManagerV2U1.isUpdateCommitWindowOpen(agreementId, 0, 1)).to.eql(false); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update it, finalize V1 update, teleport to the second epoch, submit commit, expect CommitSubmitted event', async () => { + const { tokenId, keyword } = await createAsset(); + await updateAsset(tokenId); + await finalizeUpdateV1(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + commitV1InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 1, + }; + + await expect( + CommitManagerV2['submitCommit((address,uint256,bytes,uint8,uint16))'](commitV2InputArgs), + ).to.be.revertedWithCustomError(CommitManagerV2, 'ServiceAgreementDoesntExist'); + await expect(CommitManagerV2U1['submitCommit((address,uint256,bytes,uint8,uint16))'](commitV1InputArgs)).to.emit( + CommitManagerV2U1, + 'CommitSubmitted', + ); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update it, finalize V2 update, teleport to the second epoch, submit commit, expect CommitSubmitted event', async () => { + const { tokenId, keyword } = await createAsset(2); + await updateAsset(tokenId); + const { closestNodeIndex, leftEdgeNodeIndex, rightEdgeNodeIndex } = await finalizeUpdateV2(tokenId, keyword); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + commitV2InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 1, + closestNodeIndex: closestNodeIndex, + leftEdgeNodeIndex: leftEdgeNodeIndex, + rightEdgeNodeIndex: rightEdgeNodeIndex, + }; + + await expect( + CommitManagerV2['submitCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))'](commitV2InputArgs), + ).to.be.revertedWithCustomError(CommitManagerV2, 'ServiceAgreementDoesntExist'); + await expect( + CommitManagerV2U1['submitCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))'](commitV2InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update it, finalize update V1, teleport to the second epoch, submit R0 commits, expect R0 commits to be returned', async () => { + const r0 = await ParametersStorage.r0(); + + const { tokenId, keyword, agreementId } = await createAsset(); + await updateAsset(tokenId); + const nodes = await finalizeUpdateV1(tokenId, keyword); + const identityIds = nodes.map((node) => node.identityId); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + commitV1InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 1, + }; + + for (let i = 0; i < r0; i++) { + await expect( + CommitManagerV2.connect(accounts[i])['submitCommit((address,uint256,bytes,uint8,uint16))'](commitV1InputArgs), + ).to.be.revertedWithCustomError(CommitManagerV2, 'ServiceAgreementDoesntExist'); + await expect( + CommitManagerV2U1.connect(accounts[i])['submitCommit((address,uint256,bytes,uint8,uint16))'](commitV1InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + + await expect(CommitManagerV2.getTopCommitSubmissions(agreementId, 1)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + const topCommits = await CommitManagerV2U1.getTopCommitSubmissions(agreementId, 1, 1); + + expect(topCommits.map((arr) => arr[0])).to.have.deep.members( + identityIds.map((identityId) => hre.ethers.BigNumber.from(identityId)), + ); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update it, finalize update V2, teleport to the second epoch, submit R0 commits, expect R0 commits to be returned', async () => { + const r0 = await ParametersStorage.r0(); + + const { tokenId, keyword, agreementId } = await createAsset(2); + await updateAsset(tokenId); + const { winners, closestNodeIndex, leftEdgeNodeIndex, rightEdgeNodeIndex } = await finalizeUpdateV2( + tokenId, + keyword, + ); + + const epochLength = (await ParametersStorage.epochLength()).toNumber(); + await time.increase(epochLength); + + commitV2InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 1, + closestNodeIndex: closestNodeIndex, + leftEdgeNodeIndex: leftEdgeNodeIndex, + rightEdgeNodeIndex: rightEdgeNodeIndex, + }; + + for (let i = 0; i < r0; i++) { + await expect( + CommitManagerV2.connect(winners[i].account)[ + 'submitCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))' + ](commitV2InputArgs), + ).to.be.revertedWithCustomError(CommitManagerV2, 'ServiceAgreementDoesntExist'); + await expect( + CommitManagerV2U1.connect(winners[i].account)[ + 'submitCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))' + ](commitV2InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + + await expect(CommitManagerV2.getTopCommitSubmissions(agreementId, 1)).to.be.revertedWithCustomError( + CommitManagerV2, + 'ServiceAgreementDoesntExist', + ); + const topCommits = await CommitManagerV2U1.getTopCommitSubmissions(agreementId, 1, 1); + + expect(topCommits.map((arr) => arr[0])).to.have.deep.members( + winners.map((winner) => hre.ethers.BigNumber.from(winner.identityId)), + ); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update asset, submit update commit V1, expect CommitSubmitted event', async () => { + await createProfile(accounts[0], accounts[1]); + + const { tokenId, keyword } = await createAsset(); + await updateAsset(tokenId); + + commitV1InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + }; + + await expect( + CommitManagerV2U1['submitUpdateCommit((address,uint256,bytes,uint8,uint16))'](commitV1InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update asset, submit update commit V2, expect CommitSubmitted event', async () => { + const { tokenId, keyword } = await createAsset(2); + + const nodes = await createMultipleProfiles(30); + + const keyHash = hre.ethers.utils.soliditySha256(['bytes'], [keyword]); + + const neighborhood = await getNeighborhood(nodes, keyHash); + + const closestNode = neighborhood[0]; + const { leftEdgeNode, rightEdgeNode } = await getNeighborhoodEdgeNodes(neighborhood, keyHash); + + commitV2InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + closestNodeIndex: closestNode.index, + leftEdgeNodeIndex: leftEdgeNode.index, + rightEdgeNodeIndex: rightEdgeNode.index, + }; + + await updateAsset(tokenId); + + await expect( + CommitManagerV2U1['submitUpdateCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))']( + commitV2InputArgs, + ), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + }); + + it('Create new asset with proximityScoreFunctionsPair 1, update it and submit V1 update commits, expect StateFinalized event', async () => { + const finalizationRequirement = await ParametersStorage.finalizationCommitsNumber(); + + const identityIds = []; + for (let i = 0; i < finalizationRequirement; i++) { + const node = await createProfile(accounts[i], accounts[accounts.length - 1]); + identityIds.push(node.identityId); + } + + const { tokenId, keyword, agreementId } = await createAsset(); + await updateAsset(tokenId); + + commitV1InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + }; + for (let i = 0; i < finalizationRequirement - 1; i++) { + await expect( + CommitManagerV2U1.connect(accounts[i])['submitUpdateCommit((address,uint256,bytes,uint8,uint16))']( + commitV1InputArgs, + ), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + await expect( + CommitManagerV2U1.connect(accounts[identityIds.length - 1])[ + 'submitUpdateCommit((address,uint256,bytes,uint8,uint16))' + ](commitV1InputArgs), + ).to.emit(CommitManagerV2U1, 'StateFinalized'); + const topCommits = await CommitManagerV2U1.getTopCommitSubmissions(agreementId, 0, 1); + expect(topCommits.map((arr) => arr[0])).to.include.deep.members( + identityIds.map((identityId) => hre.ethers.BigNumber.from(identityId)), + ); + }); + + it('Create new asset with proximityScoreFunctionsPair 2, update it and submit V2 update commits, expect StateFinalized event', async () => { + const r2 = await ParametersStorage.r2(); + const minStake = await ParametersStorage.minimumStake(); + const maxStake = await ParametersStorage.maximumStake(); + const finalizationRequirement = await ParametersStorage.finalizationCommitsNumber(); + + const { tokenId, keyword, agreementId } = await createAsset(2); + + const nodes = await createMultipleProfiles(30); + + const keyHash = hre.ethers.utils.soliditySha256(['bytes'], [keyword]); + + const neighborhood = await getNeighborhood(nodes, keyHash); + + const closestNode = neighborhood[0]; + const { leftEdgeNode, rightEdgeNode } = await getNeighborhoodEdgeNodes(neighborhood, keyHash); + + await updateAsset(tokenId); + + commitV2InputArgs = { + assetContract: ContentAssetStorageV2.address, + tokenId: tokenId, + keyword: keyword, + hashFunctionId: 1, + epoch: 0, + closestNodeIndex: closestNode.index, + leftEdgeNodeIndex: leftEdgeNode.index, + rightEdgeNodeIndex: rightEdgeNode.index, + }; + + const scoredNeighborhood = await Promise.all( + neighborhood.map(async (node) => ({ + account: node.account, + identityId: node.identityId, + score: ( + await calculateScore( + node.distance, + node.stake, + neighborhood[neighborhood.length - 1].distance, + r2, + nodes.length, + minStake, + maxStake, + ) + ).toNumber(), + })), + ); + + scoredNeighborhood.sort((a, b) => a.score - b.score); + + for (let i = 0; i < finalizationRequirement - 1; i++) { + await expect( + CommitManagerV2U1.connect(scoredNeighborhood[i].account)[ + 'submitUpdateCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))' + ](commitV2InputArgs), + ).to.emit(CommitManagerV2U1, 'CommitSubmitted'); + } + await expect( + CommitManagerV2U1.connect(scoredNeighborhood[finalizationRequirement - 1].account)[ + 'submitUpdateCommit((address,uint256,bytes,uint8,uint16,uint72,uint72,uint72))' + ](commitV2InputArgs), + ).to.emit(CommitManagerV2U1, 'StateFinalized'); + const topCommits = await CommitManagerV2U1.getTopCommitSubmissions(agreementId, 0, 1); + expect(topCommits.map((arr) => arr[0])).to.include.deep.members( + scoredNeighborhood.slice(0, finalizationRequirement).map((node) => hre.ethers.BigNumber.from(node.identityId)), + ); + }); +}); diff --git a/test/v2/unit/StakingV2.test.ts b/test/v2/unit/StakingV2.test.ts new file mode 100644 index 00000000..f88a8dfd --- /dev/null +++ b/test/v2/unit/StakingV2.test.ts @@ -0,0 +1,533 @@ +import { randomBytes } from 'crypto'; + +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { BigNumber, BytesLike } from 'ethers'; +import hre from 'hardhat'; + +import { + Token, + Profile, + ServiceAgreementStorageV1U1, + StakingStorage, + HubController, + StakingV2, + Shares, + ParametersStorage, + ProfileStorage, +} from '../../../typechain'; + +type StakingFixture = { + accounts: SignerWithAddress[]; + Token: Token; + Profile: Profile; + ServiceAgreementStorageV1U1: ServiceAgreementStorageV1U1; + StakingV2: StakingV2; + StakingStorage: StakingStorage; +}; + +type Node = { + account: SignerWithAddress; + identityId: number; + nodeId: BytesLike; + sha256: BytesLike; +}; + +describe('@v2 @unit StakingV2 contract', function () { + let accounts: SignerWithAddress[]; + let ParametersStorage: ParametersStorage; + let ProfileStorage: ProfileStorage; + let StakingV2: StakingV2; + let StakingStorage: StakingStorage; + let Token: Token; + let Profile: Profile; + let ServiceAgreementStorageV1U1: ServiceAgreementStorageV1U1; + const identityId1 = 1; + const totalStake = hre.ethers.utils.parseEther('1000'); + const operatorFee = hre.ethers.BigNumber.from(10); + const transferAmount = hre.ethers.utils.parseEther('100'); + const timestamp = 1674261619; + + async function deployStakingFixture(): Promise { + await hre.deployments.fixture(['StakingV2', 'Profile']); + ParametersStorage = await hre.ethers.getContract('ParametersStorage'); + ProfileStorage = await hre.ethers.getContract('ProfileStorage'); + StakingV2 = await hre.ethers.getContract('Staking'); + StakingStorage = await hre.ethers.getContract('StakingStorage'); + Token = await hre.ethers.getContract('Token'); + Profile = await hre.ethers.getContract('Profile'); + ServiceAgreementStorageV1U1 = await hre.ethers.getContract( + 'ServiceAgreementStorageV1U1', + ); + accounts = await hre.ethers.getSigners(); + const HubController = await hre.ethers.getContract('HubController'); + await HubController.setContractAddress('HubOwner', accounts[0].address); + await HubController.setContractAddress('NotHubOwner', accounts[1].address); + + return { accounts, Token, Profile, ServiceAgreementStorageV1U1, StakingV2, StakingStorage }; + } + + async function createProfile(operational: SignerWithAddress, admin: SignerWithAddress): Promise { + const OperationalProfile = Profile.connect(operational); + + const nodeId = '0x' + randomBytes(32).toString('hex'); + const sha256 = hre.ethers.utils.soliditySha256(['bytes'], [nodeId]); + + const receipt = await ( + await OperationalProfile.createProfile( + admin.address, + nodeId, + randomBytes(5).toString('hex'), + randomBytes(3).toString('hex'), + ) + ).wait(); + const identityId = Number(receipt.logs[0].topics[1]); + const blockchainNodeId = await ProfileStorage.getNodeId(identityId); + const blockchainSha256 = await ProfileStorage.getNodeAddress(identityId, 1); + + expect(blockchainNodeId).to.be.equal(nodeId); + expect(blockchainSha256).to.be.equal(sha256); + + await OperationalProfile.setAsk(identityId, hre.ethers.utils.parseEther('0.25')); + + return { + account: operational, + identityId, + nodeId, + sha256, + }; + } + + async function calculateSharesToMint(newStakeAmount: BigNumber, totalShares: BigNumber): Promise { + const totalStake = await Token.balanceOf(StakingStorage.address); + if (totalStake.isZero()) { + return newStakeAmount; + } else { + return newStakeAmount.mul(totalShares).div(totalStake); + } + } + + async function calculateEligibleTokens(heldShares: BigNumber, totalShares: BigNumber): Promise { + const totalStake = await Token.balanceOf(StakingStorage.address); + return heldShares.mul(totalStake).div(totalShares); + } + + beforeEach(async () => { + hre.helpers.resetDeploymentsJson(); + ({ accounts, Token, Profile, ServiceAgreementStorageV1U1, StakingV2, StakingStorage } = await loadFixture( + deployStakingFixture, + )); + }); + + it('The contract is named "Staking"', async () => { + expect(await StakingV2.name()).to.equal('Staking'); + }); + + it('The contract is version "2.0.0"', async () => { + expect(await StakingV2.version()).to.equal('2.0.0'); + }); + + it('Non-Contract should not be able to setTotalStake; expect to fail', async () => { + const StakingStorageWithNonHubOwner = StakingStorage.connect(accounts[2]); + await expect(StakingStorageWithNonHubOwner.setTotalStake(identityId1, totalStake)).to.be.revertedWith( + 'Fn can only be called by the hub', + ); + }); + + it('Contract should be able to setTotalStake; expect to pass', async () => { + await StakingStorage.setTotalStake(identityId1, totalStake); + expect(await StakingStorage.totalStakes(identityId1)).to.equal(totalStake); + }); + + it('Non-Contract should not be able to setOperatorFee; expect to fail', async () => { + const StakingStorageWithNonHubOwner = StakingStorage.connect(accounts[2]); + await expect(StakingStorageWithNonHubOwner.setOperatorFee(identityId1, operatorFee)).to.be.revertedWith( + 'Fn can only be called by the hub', + ); + }); + + it('Contract should be able to setOperatorFee; expect to pass', async () => { + await StakingStorage.setOperatorFee(identityId1, operatorFee); + expect(await StakingStorage.operatorFees(identityId1)).to.equal(operatorFee); + }); + + it('Non-Contract should not be able to createWithdrawalRequest; expect to fail', async () => { + const StakingStorageWithNonHubOwner = StakingStorage.connect(accounts[2]); + await expect( + StakingStorageWithNonHubOwner.createWithdrawalRequest(identityId1, accounts[2].address, totalStake, 2022), + ).to.be.revertedWith('Fn can only be called by the hub'); + }); + + it('Contract should be able to createWithdrawalRequest; expect to pass', async () => { + await StakingStorage.createWithdrawalRequest(identityId1, accounts[1].address, totalStake, timestamp); + + expect(await StakingStorage.withdrawalRequestExists(identityId1, accounts[1].address)).to.equal(true); + expect(await StakingStorage.getWithdrawalRequestAmount(identityId1, accounts[1].address)).to.equal(totalStake); + expect(await StakingStorage.getWithdrawalRequestTimestamp(identityId1, accounts[1].address)).to.equal(timestamp); + }); + + it('Non-Contract should not be able to deleteWithdrawalRequest; expect to fail', async () => { + const StakingStorageWithNonHubOwner = StakingStorage.connect(accounts[2]); + await expect( + StakingStorageWithNonHubOwner.deleteWithdrawalRequest(identityId1, accounts[2].address), + ).to.be.revertedWith('Fn can only be called by the hub'); + }); + + it('Contract should be able to deleteWithdrawalRequest; expect to pass', async () => { + await StakingStorage.createWithdrawalRequest(identityId1, accounts[1].address, totalStake, timestamp); + + await StakingStorage.deleteWithdrawalRequest(identityId1, accounts[1].address); + expect(await StakingStorage.withdrawalRequestExists(identityId1, accounts[1].address)).to.equal(false); + expect(await StakingStorage.getWithdrawalRequestAmount(identityId1, accounts[1].address)).to.equal(0); + expect(await StakingStorage.getWithdrawalRequestTimestamp(identityId1, accounts[1].address)).to.equal(0); + }); + + it('Non-Contract should not be able to transferStake; expect to fail', async () => { + const StakingStorageWithNonHubOwner = StakingStorage.connect(accounts[2]); + await expect(StakingStorageWithNonHubOwner.transferStake(accounts[2].address, transferAmount)).to.be.revertedWith( + 'Fn can only be called by the hub', + ); + }); + + it('Contract should be able to transferStake; expect to pass', async () => { + await Token.mint(StakingStorage.address, hre.ethers.utils.parseEther(`${2_000_000}`)); + + const initialReceiverBalance = await Token.balanceOf(accounts[1].address); + await StakingStorage.transferStake(accounts[1].address, transferAmount); + + expect(await Token.balanceOf(accounts[1].address)).to.equal(initialReceiverBalance.add(transferAmount)); + }); + + it('Create 1 node; expect that stake is created and correctly set', async () => { + await Token.increaseAllowance(StakingV2.address, hre.ethers.utils.parseEther(`${2_000_000}`)); + + const nodeId1 = '0x07f38512786964d9e70453371e7c98975d284100d44bd68dab67fe00b525cb66'; + await Profile.createProfile(accounts[1].address, nodeId1, 'Token', 'TKN'); + + await StakingV2.connect(accounts[1])['addStake(address,uint72,uint96)']( + accounts[0].address, + identityId1, + hre.ethers.utils.parseEther(`${2_000_000}`), + ); + expect(await StakingStorage.totalStakes(identityId1)).to.equal( + hre.ethers.utils.parseEther(`${2_000_000}`), + 'Total amount of stake is not set', + ); + }); + + it('Add reward; expect that total stake is increased', async () => { + await Token.mint(ServiceAgreementStorageV1U1.address, hre.ethers.utils.parseEther(`${2_000_000}`)); + const nodeId1 = '0x07f38512786964d9e70453371e7c98975d284100d44bd68dab67fe00b525cb66'; + await Profile.createProfile(accounts[1].address, nodeId1, 'Token', 'TKN'); + + const agreementId = '0x' + randomBytes(32).toString('hex'); + const startTime = Math.floor(Date.now() / 1000).toString(); + const epochsNumber = 5; + const epochLength = 10; + const tokenAmount = hre.ethers.utils.parseEther('100'); + const scoreFunctionId = 0; + const proofWindowOffsetPerc = 10; + + await ServiceAgreementStorageV1U1.createServiceAgreementObject( + agreementId, + startTime, + epochsNumber, + epochLength, + tokenAmount, + scoreFunctionId, + proofWindowOffsetPerc, + ); + + await StakingV2.connect(accounts[1]).addReward( + agreementId, + identityId1, + hre.ethers.utils.parseEther(`${2_000_000}`), + ); + expect(await StakingStorage.totalStakes(identityId1)).to.equal( + hre.ethers.utils.parseEther(`${2_000_000}`), + 'Total amount of stake is not increased after adding reward', + ); + }); + + it('New profile created, stake added by node admin, StakeIncreased/SharesMinted events are emitted, token/shares balances are correct', async () => { + const node = await createProfile(accounts[0], accounts[1]); + + const minStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.minimumStake())); + const maxStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.maximumStake())); + const stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - minStake + 1)) + minStake}`, + ); + + const initialBalance = await Token.balanceOf(accounts[1].address); + const oldStake = await StakingStorage.totalStakes(node.identityId); + + const sharesAddress = await ProfileStorage.getSharesContractAddress(node.identityId); + const SharesContract = await hre.ethers.getContractAt('Shares', sharesAddress); + const initialSharesBalance = await SharesContract.balanceOf(accounts[1].address); + const sharesTotalSupply = await SharesContract.totalSupply(); + const sharesToMint = await calculateSharesToMint(stakeAmount, sharesTotalSupply); + + await Token.connect(accounts[1]).increaseAllowance(StakingV2.address, stakeAmount); + + expect(await StakingV2.connect(accounts[1])['addStake(uint72,uint96)'](node.identityId, stakeAmount)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs(node.identityId, node.nodeId, accounts[1], oldStake, oldStake.add(stakeAmount)) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(sharesAddress, accounts[1].address, sharesToMint, sharesTotalSupply.add(sharesToMint)); + + const finalBalance = await Token.balanceOf(accounts[1].address); + const finalSharesBalance = await SharesContract.balanceOf(accounts[1].address); + + expect(finalBalance).to.be.equal(initialBalance.sub(stakeAmount)); + expect(finalSharesBalance).to.be.equal(initialSharesBalance.add(sharesToMint)); + }); + + it('New profile created, stake added by 5 delegators, StakeIncreased/SharesMinted events are emitted, token/shares balances are correct', async () => { + const node = await createProfile(accounts[0], accounts[1]); + + const minStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.minimumStake())); + const maxStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.maximumStake())); + let stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - minStake + 1)) + minStake}`, + ); + + let initialBalance = await Token.balanceOf(accounts[1].address); + let oldStake = await StakingStorage.totalStakes(node.identityId); + + const sharesAddress = await ProfileStorage.getSharesContractAddress(node.identityId); + const SharesContract = await hre.ethers.getContractAt('Shares', sharesAddress); + let initialSharesBalance = await SharesContract.balanceOf(accounts[1].address); + let sharesTotalSupply = await SharesContract.totalSupply(); + let sharesToMint = await calculateSharesToMint(stakeAmount, sharesTotalSupply); + + await Token.connect(accounts[1]).increaseAllowance(StakingV2.address, stakeAmount); + + expect(await StakingV2.connect(accounts[1])['addStake(uint72,uint96)'](node.identityId, stakeAmount)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs(node.identityId, node.nodeId, accounts[1], oldStake, oldStake.add(stakeAmount)) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(sharesAddress, accounts[1].address, sharesToMint, sharesTotalSupply.add(sharesToMint)); + + let finalBalance = await Token.balanceOf(accounts[1].address); + let finalSharesBalance = await SharesContract.balanceOf(accounts[1].address); + + expect(finalBalance).to.be.equal(initialBalance.sub(stakeAmount)); + expect(finalSharesBalance).to.be.equal(initialSharesBalance.add(sharesToMint)); + + for (let i = 2; i < 7; i += 1) { + oldStake = await StakingStorage.totalStakes(node.identityId); + stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - Number(hre.ethers.utils.formatEther(oldStake))))}`, + ); + sharesTotalSupply = await SharesContract.totalSupply(); + sharesToMint = await calculateSharesToMint(stakeAmount, sharesTotalSupply); + + initialBalance = await Token.balanceOf(accounts[i].address); + initialSharesBalance = await SharesContract.balanceOf(accounts[i].address); + + await Token.connect(accounts[i]).increaseAllowance(StakingV2.address, stakeAmount); + + expect(await StakingV2.connect(accounts[i])['addStake(uint72,uint96)'](node.identityId, stakeAmount)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs(node.identityId, node.nodeId, accounts[1], oldStake, oldStake.add(stakeAmount)) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(sharesAddress, accounts[i].address, sharesToMint, sharesTotalSupply.add(sharesToMint)); + + finalBalance = await Token.balanceOf(accounts[i].address); + finalSharesBalance = await SharesContract.balanceOf(accounts[i].address); + + expect(finalBalance).to.be.equal(initialBalance.sub(stakeAmount)); + expect(finalSharesBalance).to.be.equal(initialSharesBalance.add(sharesToMint)); + } + }); + + it('New profile created, stake added by node runner and 5 delegators, operator fee change triggered, teleport, operator fee changed, reward added, stake withdrawn, events and balance are correct', async () => { + const node = await createProfile(accounts[0], accounts[1]); + + const minStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.minimumStake())); + const maxStake = Number(hre.ethers.utils.formatEther(await ParametersStorage.maximumStake())); + let stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - minStake + 1)) + minStake}`, + ); + + let initialBalance = await Token.balanceOf(accounts[1].address); + let oldStake = await StakingStorage.totalStakes(node.identityId); + + const sharesAddress = await ProfileStorage.getSharesContractAddress(node.identityId); + const SharesContract = await hre.ethers.getContractAt('Shares', sharesAddress); + let initialSharesBalance = await SharesContract.balanceOf(accounts[1].address); + let sharesTotalSupply = await SharesContract.totalSupply(); + let sharesToMint = await calculateSharesToMint(stakeAmount, sharesTotalSupply); + + await Token.connect(accounts[1]).increaseAllowance(StakingV2.address, stakeAmount); + + expect(await StakingV2.connect(accounts[1])['addStake(uint72,uint96)'](node.identityId, stakeAmount)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs(node.identityId, node.nodeId, accounts[1], oldStake, oldStake.add(stakeAmount)) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(sharesAddress, accounts[1].address, sharesToMint, sharesTotalSupply.add(sharesToMint)); + + let finalBalance = await Token.balanceOf(accounts[1].address); + let finalSharesBalance = await SharesContract.balanceOf(accounts[1].address); + + expect(finalBalance).to.be.equal(initialBalance.sub(stakeAmount)); + expect(finalSharesBalance).to.be.equal(initialSharesBalance.add(sharesToMint)); + + for (let i = 2; i < 7; i += 1) { + oldStake = await StakingStorage.totalStakes(node.identityId); + stakeAmount = hre.ethers.utils.parseEther( + `${Math.floor(Math.random() * (maxStake - Number(hre.ethers.utils.formatEther(oldStake))))}`, + ); + sharesTotalSupply = await SharesContract.totalSupply(); + sharesToMint = await calculateSharesToMint(stakeAmount, sharesTotalSupply); + + initialBalance = await Token.balanceOf(accounts[i].address); + initialSharesBalance = await SharesContract.balanceOf(accounts[i].address); + + await Token.connect(accounts[i]).increaseAllowance(StakingV2.address, stakeAmount); + + expect(await StakingV2.connect(accounts[i])['addStake(uint72,uint96)'](node.identityId, stakeAmount)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs(node.identityId, node.nodeId, accounts[1].address, oldStake, oldStake.add(stakeAmount)) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(sharesAddress, accounts[i].address, sharesToMint, sharesTotalSupply.add(sharesToMint)); + + finalBalance = await Token.balanceOf(accounts[i].address); + finalSharesBalance = await SharesContract.balanceOf(accounts[i].address); + + expect(finalBalance).to.be.equal(initialBalance.sub(stakeAmount)); + expect(finalSharesBalance).to.be.equal(initialSharesBalance.add(sharesToMint)); + } + + const newOperatorFee = 50; + const stakeWithdrawalDelay = await ParametersStorage.stakeWithdrawalDelay(); + let blockTimestamp = (await hre.ethers.provider.getBlock('latest')).timestamp; + expect(await StakingV2.connect(accounts[1]).startOperatorFeeChange(node.identityId, newOperatorFee)) + .to.emit(StakingV2, 'OperatorFeeChangeStarted') + .withArgs(node.identityId, node.nodeId, newOperatorFee, blockTimestamp + stakeWithdrawalDelay); + + await time.increaseTo(blockTimestamp + stakeWithdrawalDelay); + + expect(await StakingV2.connect(accounts[1]).finishOperatorFeeChange(node.identityId)) + .to.emit(StakingV2, 'OperatorFeeChangeFinished') + .withArgs(node.identityId, node.nodeId, newOperatorFee); + + const agreementId = '0x' + randomBytes(32).toString('hex'); + const startTime = Math.floor(Date.now() / 1000).toString(); + const epochsNumber = 5; + const epochLength = 10; + const tokenAmount = hre.ethers.utils.parseEther('100'); + const scoreFunctionId = 0; + const proofWindowOffsetPerc = 10; + + const reward = hre.ethers.utils.parseEther(`${2_000_000}`); + await Token.mint(ServiceAgreementStorageV1U1.address, reward); + + await ServiceAgreementStorageV1U1.createServiceAgreementObject( + agreementId, + startTime, + epochsNumber, + epochLength, + tokenAmount, + scoreFunctionId, + proofWindowOffsetPerc, + ); + + const oldAccOperatorFee = await ProfileStorage.getAccumulatedOperatorFee(node.identityId); + const accOperatorFee = reward.mul(newOperatorFee).div(100); + const delegatorsReward = reward.sub(accOperatorFee); + oldStake = await StakingStorage.totalStakes(node.identityId); + + expect(await StakingV2.addReward(agreementId, node.identityId, reward)) + .to.emit(StakingV2, 'AccumulatedOperatorFeeIncreased') + .withArgs(node.identityId, node.nodeId, oldAccOperatorFee, oldAccOperatorFee.add(accOperatorFee)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs( + node.identityId, + node.nodeId, + ServiceAgreementStorageV1U1.address, + oldStake, + oldStake.add(delegatorsReward), + ) + .to.emit(StakingV2, 'RewardCollected') + .withArgs(node.identityId, node.nodeId, ServiceAgreementStorageV1U1.address, accOperatorFee, delegatorsReward); + + initialBalance = await Token.balanceOf(accounts[1].address); + oldStake = await StakingStorage.totalStakes(node.identityId); + + initialSharesBalance = await SharesContract.balanceOf(accounts[1].address); + sharesTotalSupply = await SharesContract.totalSupply(); + let eligibleTokens = await calculateEligibleTokens(initialSharesBalance, sharesTotalSupply); + + await SharesContract.connect(accounts[1]).increaseAllowance(StakingV2.address, initialSharesBalance); + + blockTimestamp = (await hre.ethers.provider.getBlock('latest')).timestamp; + expect(await StakingV2.connect(accounts[1]).startStakeWithdrawal(node.identityId, initialSharesBalance)) + .to.emit(StakingV2, 'StakeWithdrawalStarted') + .withArgs( + node.identityId, + node.nodeId, + accounts[1].address, + oldStake, + oldStake.add(eligibleTokens), + blockTimestamp + stakeWithdrawalDelay, + ) + .to.emit(StakingV2, 'SharesBurned') + .withArgs(sharesAddress, accounts[1].address, initialSharesBalance, sharesTotalSupply.sub(initialSharesBalance)); + + await time.increaseTo(blockTimestamp + stakeWithdrawalDelay); + + expect(await StakingV2.connect(accounts[1]).withdrawStake(node.identityId)) + .to.emit(StakingV2, 'StakeWithdrawn') + .withArgs(node.identityId, node.nodeId, accounts[1].address, eligibleTokens); + + finalBalance = await Token.balanceOf(accounts[1].address); + finalSharesBalance = await SharesContract.balanceOf(accounts[1].address); + + expect(finalBalance).to.be.equal(initialBalance.add(eligibleTokens)); + expect(finalSharesBalance).to.be.equal(0); + + for (let i = 2; i < 7; i += 1) { + initialBalance = await Token.balanceOf(accounts[i].address); + oldStake = await StakingStorage.totalStakes(node.identityId); + + initialSharesBalance = await SharesContract.balanceOf(accounts[i].address); + sharesTotalSupply = await SharesContract.totalSupply(); + eligibleTokens = await calculateEligibleTokens(initialSharesBalance, sharesTotalSupply); + + await SharesContract.connect(accounts[i]).increaseAllowance(StakingV2.address, initialSharesBalance); + + blockTimestamp = (await hre.ethers.provider.getBlock('latest')).timestamp; + expect(await StakingV2.connect(accounts[i]).startStakeWithdrawal(node.identityId, initialSharesBalance)) + .to.emit(StakingV2, 'StakeWithdrawalStarted') + .withArgs( + node.identityId, + node.nodeId, + accounts[i].address, + oldStake, + oldStake.add(eligibleTokens), + blockTimestamp + stakeWithdrawalDelay, + ) + .to.emit(StakingV2, 'SharesBurned') + .withArgs( + sharesAddress, + accounts[i].address, + initialSharesBalance, + sharesTotalSupply.sub(initialSharesBalance), + ); + + await time.increaseTo(blockTimestamp + stakeWithdrawalDelay); + + expect(await StakingV2.connect(accounts[i]).withdrawStake(node.identityId)) + .to.emit(StakingV2, 'StakeWithdrawn') + .withArgs(node.identityId, node.nodeId, accounts[i].address, eligibleTokens); + + finalBalance = await Token.balanceOf(accounts[i].address); + finalSharesBalance = await SharesContract.balanceOf(accounts[i].address); + + expect(finalBalance).to.be.equal(initialBalance.add(eligibleTokens)); + expect(finalSharesBalance).to.be.equal(0); + } + }); +});