diff --git a/abi/StakingV2.json b/abi/StakingV2.json index aab204f0..ea92a602 100644 --- a/abi/StakingV2.json +++ b/abi/StakingV2.json @@ -681,6 +681,29 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "uint72", + "name": "from", + "type": "uint72" + }, + { + "internalType": "uint72", + "name": "to", + "type": "uint72" + }, + { + "internalType": "uint96", + "name": "sharesToBurn", + "type": "uint96" + } + ], + "name": "redelegate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "serviceAgreementStorageProxy", diff --git a/contracts/v2/Staking.sol b/contracts/v2/Staking.sol index 1be62211..5356205c 100644 --- a/contracts/v2/Staking.sol +++ b/contracts/v2/Staking.sol @@ -88,7 +88,7 @@ contract StakingV2 is Named, Versioned, ContractStatusV2, Initializable { event OperatorFeeChangeFinished(uint72 indexed identityId, bytes nodeId, uint8 operatorFee); string private constant _NAME = "Staking"; - string private constant _VERSION = "2.2.0"; + string private constant _VERSION = "2.3.0"; ShardingTableV2 public shardingTableContract; IdentityStorageV2 public identityStorage; @@ -212,6 +212,80 @@ contract StakingV2 is Named, Versioned, ContractStatusV2, Initializable { emit SharesMinted(identityId, address(sharesContract), msg.sender, sharesMinted, sharesContract.totalSupply()); } + function redelegate(uint72 from, uint72 to, uint96 sharesToBurn) external { + if (sharesToBurn == 0) { + revert StakingErrors.ZeroSharesAmount(); + } + + ProfileStorage ps = profileStorage; + StakingStorage ss = stakingStorage; + ShardingTableStorageV2 sts = shardingTableStorage; + + if (!ps.profileExists(from)) { + revert ProfileErrors.ProfileDoesntExist(from); + } + + if (!ps.profileExists(to)) { + revert ProfileErrors.ProfileDoesntExist(to); + } + + Shares fromSharesContract = Shares(ps.getSharesContractAddress(from)); + Shares toSharesContract = Shares(ps.getSharesContractAddress(to)); + + if (sharesToBurn > fromSharesContract.balanceOf(msg.sender)) { + revert TokenErrors.TooLowBalance(address(fromSharesContract), fromSharesContract.balanceOf(msg.sender)); + } + + ParametersStorage params = parametersStorage; + + uint96 fromCurrentStake = ss.totalStakes(from); + uint96 toCurrentStake = ss.totalStakes(to); + + uint96 redelegationAmount = uint96( + (uint256(fromCurrentStake) * sharesToBurn) / fromSharesContract.totalSupply() + ); + + if (toCurrentStake + redelegationAmount > params.maximumStake()) { + revert StakingErrors.MaximumStakeExceeded(params.maximumStake()); + } + + fromSharesContract.burnFrom(msg.sender, sharesToBurn); + + uint256 sharesToMint; + if (toSharesContract.totalSupply() == 0) { + sharesToMint = redelegationAmount; + } else { + sharesToMint = ((uint256(redelegationAmount) * toSharesContract.totalSupply()) / toCurrentStake); + } + toSharesContract.mint(msg.sender, sharesToMint); + + ss.setTotalStake(from, fromCurrentStake - redelegationAmount); + + if (sts.nodeExists(from) && (fromCurrentStake - redelegationAmount) < params.minimumStake()) { + shardingTableContract.removeNode(from); + } + + ss.setTotalStake(to, toCurrentStake + redelegationAmount); + + if (!sts.nodeExists(to) && (toCurrentStake + redelegationAmount >= params.minimumStake())) { + if (sts.nodesCount() >= params.shardingTableSizeLimit()) { + revert ShardingTableErrors.ShardingTableIsFull(); + } + shardingTableContract.insertNode(to); + } + + emit SharesBurned( + from, + address(fromSharesContract), + msg.sender, + sharesToBurn, + fromSharesContract.totalSupply() + ); + emit StakeWithdrawn(from, ps.getNodeId(from), msg.sender, redelegationAmount); + emit SharesMinted(to, address(toSharesContract), msg.sender, sharesToMint, toSharesContract.totalSupply()); + emit StakeIncreased(to, ps.getNodeId(to), msg.sender, toCurrentStake, toCurrentStake + redelegationAmount); + } + function startStakeWithdrawal(uint72 identityId, uint96 sharesToBurn) external { if (sharesToBurn == 0) { revert StakingErrors.ZeroSharesAmount(); diff --git a/deployments/base_mainnet_contracts.json b/deployments/base_mainnet_contracts.json index 1914b309..0b47f961 100644 --- a/deployments/base_mainnet_contracts.json +++ b/deployments/base_mainnet_contracts.json @@ -243,12 +243,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x39713a4E7d05dAB796fCA6e84cBA5aFC905822d9", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 19022524, - "deploymentTimestamp": 1724834405325, + "evmAddress": "0xb4a9CF231FC623498347ecb0859E2BC3Ad9ad110", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "d72f5fcd0e6159f1cf8dccd033cbeca63595953d", + "deploymentBlock": 21665377, + "deploymentTimestamp": 1730120110036, "deployed": true }, "Profile": { diff --git a/deployments/base_sepolia_dev_contracts.json b/deployments/base_sepolia_dev_contracts.json index 8ee4ef8f..555b41a8 100644 --- a/deployments/base_sepolia_dev_contracts.json +++ b/deployments/base_sepolia_dev_contracts.json @@ -249,7 +249,7 @@ "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", "deploymentBlock": 14543288, "deploymentTimestamp": 1724854871142, - "deployed": true + "deployed": false }, "Profile": { "evmAddress": "0x2Ff4C4CAc001CBcC1893620Ef2a261071dB2498A", diff --git a/deployments/base_sepolia_test_contracts.json b/deployments/base_sepolia_test_contracts.json index a52834c0..75cdac9d 100644 --- a/deployments/base_sepolia_test_contracts.json +++ b/deployments/base_sepolia_test_contracts.json @@ -243,12 +243,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x1dD6bE1EC9410aff2E95685d84cEA6aCf40bFBa9", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 14543315, - "deploymentTimestamp": 1724854925334, + "evmAddress": "0x1E8317d7A84eD72Bbfb421454cD8c6881F935010", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "19ee7ca7323be5ef8a30ae3482d64c1d51b9bd44", + "deploymentBlock": 17048866, + "deploymentTimestamp": 1729866025390, "deployed": true }, "Profile": { diff --git a/deployments/gnosis_chiado_dev_contracts.json b/deployments/gnosis_chiado_dev_contracts.json index e45f50b3..aa78abc8 100644 --- a/deployments/gnosis_chiado_dev_contracts.json +++ b/deployments/gnosis_chiado_dev_contracts.json @@ -265,12 +265,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x4991C23D82469e1b9b583515Ed400e5B0437d499", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 11519675, - "deploymentTimestamp": 1724854757920, + "evmAddress": "0x6EE3DbEd6477320A6CBb399cC08862BEB327d81b", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "d72f5fcd0e6159f1cf8dccd033cbeca63595953d", + "deploymentBlock": 12524619, + "deploymentTimestamp": 1730119900339, "deployed": true }, "ParanetsRegistry": { diff --git a/deployments/gnosis_chiado_test_contracts.json b/deployments/gnosis_chiado_test_contracts.json index 961b5f40..729c568f 100644 --- a/deployments/gnosis_chiado_test_contracts.json +++ b/deployments/gnosis_chiado_test_contracts.json @@ -262,7 +262,7 @@ "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", "deploymentBlock": 11519686, "deploymentTimestamp": 1724854815721, - "deployed": true + "deployed": false }, "ContentAsset": { "evmAddress": "0x20A489D9dc7d2D241AF24C650d71631722aF0Aa1", diff --git a/deployments/gnosis_mainnet_contracts.json b/deployments/gnosis_mainnet_contracts.json index 0691dca4..b4f143bb 100644 --- a/deployments/gnosis_mainnet_contracts.json +++ b/deployments/gnosis_mainnet_contracts.json @@ -201,12 +201,12 @@ "deployed": true }, "Staking": { - "evmAddress": "0x8883aA8cd98fCf6D9203b731F843Fe9eed09E3bb", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 35708723, - "deploymentTimestamp": 1724834659300, + "evmAddress": "0xbce4d40D93B8bE20E8Ad689Dd00D03A7e17f57dD", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "d72f5fcd0e6159f1cf8dccd033cbeca63595953d", + "deploymentBlock": 36734779, + "deploymentTimestamp": 1730120579662, "deployed": true }, "Profile": { diff --git a/deployments/otp_mainnet_contracts.json b/deployments/otp_mainnet_contracts.json index 9910cb3b..3b255fb4 100644 --- a/deployments/otp_mainnet_contracts.json +++ b/deployments/otp_mainnet_contracts.json @@ -207,13 +207,13 @@ "deployed": true }, "Staking": { - "evmAddress": "0x666B604B1b32ca467a6eEF3257475D32f8ADD04b", - "substrateAddress": "5EMjsczghQorAAxG77BAa4V9YSAkJ8TfGRJsvaARzTNHdDgn", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 5672930, - "deploymentTimestamp": 1724834500366, + "evmAddress": "0x4039101dFD15AB3cDBBb44F5b46687a4fAE1893B", + "substrateAddress": "5EMjsczZ3WFMFrHbF7aTAa7i744NsqyoYZDbZg6s8ca2Zucc", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "d72f5fcd0e6159f1cf8dccd033cbeca63595953d", + "deploymentBlock": 6422825, + "deploymentTimestamp": 1730120359479, "deployed": true }, "CommitManagerV1": { diff --git a/deployments/otp_testnet_contracts.json b/deployments/otp_testnet_contracts.json index 5304da87..d06c439f 100644 --- a/deployments/otp_testnet_contracts.json +++ b/deployments/otp_testnet_contracts.json @@ -216,13 +216,13 @@ "deployed": true }, "Staking": { - "evmAddress": "0x873d2Ac030ba5cb14184E6Af3152fcfF7555b432", - "substrateAddress": "5EMjsczoGpwyeX7YPDeYFej1RGnqi8CeW6b6Vw5xA4P6pN3k", - "version": "2.2.0", - "gitBranch": "main", - "gitCommitHash": "b9e7dede2a75acf193272f84b1a5ce155210e47a", - "deploymentBlock": 4354638, - "deploymentTimestamp": 1724854704943, + "evmAddress": "0x2127A10E32034107a68301544d863c6833072457", + "substrateAddress": "5EMjsczSpSeJw1TKkhh1tXZvaHqDvyBXd39K88BDPDJaPSsb", + "version": "2.3.0", + "gitBranch": "feature/redelegation", + "gitCommitHash": "d72f5fcd0e6159f1cf8dccd033cbeca63595953d", + "deploymentBlock": 4939016, + "deploymentTimestamp": 1730120002198, "deployed": true }, "CommitManagerV1": { diff --git a/package-lock.json b/package-lock.json index c9e7603f..c69e039d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dkg-evm-module", - "version": "4.3.4", + "version": "4.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "dkg-evm-module", - "version": "4.3.4", + "version": "4.4.0", "license": "Apache-2.0", "dependencies": { "@openzeppelin/contracts": "^4.9.3", diff --git a/package.json b/package.json index 1446c74d..2b9f1f15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dkg-evm-module", - "version": "4.3.4", + "version": "4.4.0", "description": "Smart contracts for OriginTrail V6", "main": "index.ts", "files": [ diff --git a/test/v2/unit/StakingV2.test.ts b/test/v2/unit/StakingV2.test.ts index fc9430cf..a4dd1f1f 100644 --- a/test/v2/unit/StakingV2.test.ts +++ b/test/v2/unit/StakingV2.test.ts @@ -16,6 +16,8 @@ import { Shares, ParametersStorage, ProfileStorage, + ShardingTableV2, + ShardingTableStorageV2, } from '../../../typechain'; type StakingFixture = { @@ -25,6 +27,8 @@ type StakingFixture = { ServiceAgreementStorageV1U1: ServiceAgreementStorageV1U1; StakingV2: StakingV2; StakingStorage: StakingStorage; + ShardingTableStorage: ShardingTableStorageV2; + ShardingTable: ShardingTableV2; }; type Node = { @@ -43,6 +47,8 @@ describe('@v2 @unit StakingV2 contract', function () { let Token: Token; let Profile: Profile; let ServiceAgreementStorageV1U1: ServiceAgreementStorageV1U1; + let ShardingTableStorage: ShardingTableStorageV2; + let ShardingTable: ShardingTableV2; const identityId1 = 1; const totalStake = hre.ethers.utils.parseEther('1000'); const operatorFee = hre.ethers.BigNumber.from(10); @@ -50,7 +56,7 @@ describe('@v2 @unit StakingV2 contract', function () { const timestamp = 1674261619; async function deployStakingFixture(): Promise { - await hre.deployments.fixture(['StakingV2', 'Profile']); + await hre.deployments.fixture(['StakingV2', 'Profile', 'ShardingTableStorageV2', 'ShardingTableV2']); ParametersStorage = await hre.ethers.getContract('ParametersStorage'); ProfileStorage = await hre.ethers.getContract('ProfileStorage'); StakingV2 = await hre.ethers.getContract('Staking'); @@ -60,12 +66,23 @@ describe('@v2 @unit StakingV2 contract', function () { ServiceAgreementStorageV1U1 = await hre.ethers.getContract( 'ServiceAgreementStorageV1U1', ); + ShardingTableStorage = await hre.ethers.getContract('ShardingTableStorage'); + ShardingTable = await hre.ethers.getContract('ShardingTable'); 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 }; + return { + accounts, + Token, + Profile, + ServiceAgreementStorageV1U1, + StakingV2, + StakingStorage, + ShardingTable, + ShardingTableStorage, + }; } async function createProfile(operational: SignerWithAddress, admin: SignerWithAddress): Promise { @@ -121,17 +138,24 @@ describe('@v2 @unit StakingV2 contract', function () { beforeEach(async () => { hre.helpers.resetDeploymentsJson(); - ({ accounts, Token, Profile, ServiceAgreementStorageV1U1, StakingV2, StakingStorage } = await loadFixture( - deployStakingFixture, - )); + ({ + accounts, + Token, + Profile, + ServiceAgreementStorageV1U1, + StakingV2, + StakingStorage, + ShardingTable, + ShardingTableStorage, + } = await loadFixture(deployStakingFixture)); }); it('The contract is named "Staking"', async () => { expect(await StakingV2.name()).to.equal('Staking'); }); - it('The contract is version "2.2.0"', async () => { - expect(await StakingV2.version()).to.equal('2.2.0'); + it('The contract is version "2.3.0"', async () => { + expect(await StakingV2.version()).to.equal('2.3.0'); }); it('Non-Contract should not be able to setTotalStake; expect to fail', async () => { @@ -706,7 +730,7 @@ describe('@v2 @unit StakingV2 contract', function () { const initialStake = hre.ethers.utils.parseEther('1000'); const { identityId, nodeId } = await createProfile(accounts[0], accounts[1]); - await Token.approve(StakingV2.address, initialStake); + await Token.increaseAllowance(StakingV2.address, initialStake); await StakingV2['addStake(uint72,uint96)'](identityId, initialStake); const sharesToBurn = hre.ethers.utils.parseEther('500'); @@ -752,7 +776,7 @@ describe('@v2 @unit StakingV2 contract', function () { const initialStakeAmount = hre.ethers.utils.parseEther('500'); const reward = hre.ethers.utils.parseEther('250'); - await Token.approve(StakingV2.address, initialStakeAmount); + await Token.increaseAllowance(StakingV2.address, initialStakeAmount); await StakingV2['addStake(uint72,uint96)'](identityId, initialStakeAmount); const sharesToBurn = initialStakeAmount.div(2); @@ -797,4 +821,293 @@ describe('@v2 @unit StakingV2 contract', function () { hre.ethers.utils.parseEther('375'), ); }); + + it('should correctly redelegate shares from one node to another', async function () { + // Create two nodes + const node1 = await createProfile(accounts[0], accounts[1]); // From node + const node2 = await createProfile(accounts[2], accounts[3]); // To node + + // Stake some amount on node1 + const initialStakeAmount = hre.ethers.utils.parseEther('1000'); // 1000 tokens + + // Approve tokens and stake on node1 + await Token.increaseAllowance(StakingV2.address, initialStakeAmount); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeAmount); + + // Get shares contracts + const fromSharesAddress = await ProfileStorage.getSharesContractAddress(node1.identityId); + const fromSharesContract = await hre.ethers.getContractAt('Shares', fromSharesAddress); + + const toSharesAddress = await ProfileStorage.getSharesContractAddress(node2.identityId); + const toSharesContract = await hre.ethers.getContractAt('Shares', toSharesAddress); + + // Get initial balances and stakes + const fromInitialSharesBalance = await fromSharesContract.balanceOf(accounts[0].address); + const toInitialSharesBalance = await toSharesContract.balanceOf(accounts[0].address); + + const fromCurrentStake = await StakingStorage.totalStakes(node1.identityId); + const toCurrentStake = await StakingStorage.totalStakes(node2.identityId); + + const fromTotalShares = await fromSharesContract.totalSupply(); + const toTotalShares = await toSharesContract.totalSupply(); + + // Redelegate half of the shares + const sharesToBurn = fromInitialSharesBalance.div(2); + + // Calculate redelegationAmount + const redelegationAmount = fromCurrentStake.mul(sharesToBurn).div(fromTotalShares); + + // Calculate sharesToMint + let sharesToMint: BigNumber; + if (toTotalShares.isZero()) { + sharesToMint = redelegationAmount; + } else { + sharesToMint = redelegationAmount.mul(toTotalShares).div(toCurrentStake); + } + + // Increase allowance for fromSharesContract + await fromSharesContract.connect(accounts[0]).increaseAllowance(StakingV2.address, sharesToBurn); + + // Call redelegate + await expect(StakingV2.redelegate(node1.identityId, node2.identityId, sharesToBurn)) + .to.emit(StakingV2, 'SharesBurned') + .withArgs( + node1.identityId, + fromSharesAddress, + accounts[0].address, + sharesToBurn, + fromTotalShares.sub(sharesToBurn), + ) + .to.emit(StakingV2, 'StakeWithdrawn') + .withArgs(node1.identityId, node1.nodeId, accounts[0].address, redelegationAmount) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(node2.identityId, toSharesAddress, accounts[0].address, sharesToMint, toTotalShares.add(sharesToMint)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs( + node2.identityId, + node2.nodeId, + accounts[0].address, + toCurrentStake, + toCurrentStake.add(redelegationAmount), + ); + + // Check final balances and stakes + const fromFinalSharesBalance = await fromSharesContract.balanceOf(accounts[0].address); + const toFinalSharesBalance = await toSharesContract.balanceOf(accounts[0].address); + + const fromFinalStake = await StakingStorage.totalStakes(node1.identityId); + const toFinalStake = await StakingStorage.totalStakes(node2.identityId); + + expect(fromFinalSharesBalance).to.equal(fromInitialSharesBalance.sub(sharesToBurn)); + expect(toFinalSharesBalance).to.equal(toInitialSharesBalance.add(sharesToMint)); + + expect(fromFinalStake).to.equal(fromCurrentStake.sub(redelegationAmount)); + expect(toFinalStake).to.equal(toCurrentStake.add(redelegationAmount)); + }); + + it('should revert when attempting to redelegate zero shares', async function () { + const node1 = await createProfile(accounts[0], accounts[1]); + const node2 = await createProfile(accounts[2], accounts[3]); + + const initialStakeAmount = hre.ethers.utils.parseEther('1000'); + await Token.increaseAllowance(StakingV2.address, initialStakeAmount); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeAmount); + + await expect(StakingV2.redelegate(node1.identityId, node2.identityId, 0)).to.be.revertedWithCustomError( + StakingV2, + 'ZeroSharesAmount', + ); + }); + + it('should revert when attempting to redelegate more shares than owned', async function () { + const node1 = await createProfile(accounts[0], accounts[1]); + const node2 = await createProfile(accounts[2], accounts[3]); + + const initialStakeAmount = hre.ethers.utils.parseEther('1000'); + await Token.increaseAllowance(StakingV2.address, initialStakeAmount); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeAmount); + + const fromSharesAddress = await ProfileStorage.getSharesContractAddress(node1.identityId); + const fromSharesContract = await hre.ethers.getContractAt('Shares', fromSharesAddress); + + const userSharesBalance = await fromSharesContract.balanceOf(accounts[0].address); + const sharesToBurn = userSharesBalance.add(1); // One more than user owns + + await fromSharesContract.connect(accounts[0]).increaseAllowance(StakingV2.address, sharesToBurn); + + await expect(StakingV2.redelegate(node1.identityId, node2.identityId, sharesToBurn)).to.be.revertedWithCustomError( + StakingV2, + 'TooLowBalance', + ); + }); + + it('should revert when attempting to redelegate from a non-existent identity', async function () { + const node2 = await createProfile(accounts[0], accounts[2]); + const nonExistentIdentityId = 9999; // Assuming this identity doesn't exist + + const sharesToBurn = hre.ethers.utils.parseEther('100'); + + await expect( + StakingV2.redelegate(nonExistentIdentityId, node2.identityId, sharesToBurn), + ).to.be.revertedWithCustomError(StakingV2, 'ProfileDoesntExist'); + }); + + it('should revert when attempting to redelegate to a non-existent identity', async function () { + const node1 = await createProfile(accounts[0], accounts[1]); + const nonExistentIdentityId = 9999; // Assuming this identity doesn't exist + + const initialStakeAmount = hre.ethers.utils.parseEther('1000'); + await Token.increaseAllowance(StakingV2.address, initialStakeAmount); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeAmount); + + const fromSharesAddress = await ProfileStorage.getSharesContractAddress(node1.identityId); + const fromSharesContract = await hre.ethers.getContractAt('Shares', fromSharesAddress); + + const sharesToBurn = hre.ethers.utils.parseEther('100'); + + await fromSharesContract.connect(accounts[0]).increaseAllowance(StakingV2.address, sharesToBurn); + + await expect( + StakingV2.redelegate(node1.identityId, nonExistentIdentityId, sharesToBurn), + ).to.be.revertedWithCustomError(StakingV2, 'ProfileDoesntExist'); + }); + + it('should revert when redelegating causes "to" identity stake to exceed maximumStake', async function () { + const node1 = await createProfile(accounts[0], accounts[1]); + const node2 = await createProfile(accounts[2], accounts[3]); + + const maximumStake = await ParametersStorage.maximumStake(); + + // Stake on node2 up to maximumStake - small amount + const initialStakeToNode2 = maximumStake.sub(hre.ethers.utils.parseEther('100')); + await Token.connect(accounts[0]).increaseAllowance(StakingV2.address, initialStakeToNode2); + await StakingV2['addStake(uint72,uint96)'](node2.identityId, initialStakeToNode2); + + // Stake on node1 + const initialStakeToNode1 = hre.ethers.utils.parseEther('1000'); + await Token.connect(accounts[0]).increaseAllowance(StakingV2.address, initialStakeToNode1); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeToNode1); + + const fromSharesAddress = await ProfileStorage.getSharesContractAddress(node1.identityId); + const fromSharesContract = await hre.ethers.getContractAt('Shares', fromSharesAddress); + + const sharesToBurn = await fromSharesContract.balanceOf(accounts[0].address); + + await fromSharesContract.connect(accounts[0]).increaseAllowance(StakingV2.address, sharesToBurn); + + await expect(StakingV2.redelegate(node1.identityId, node2.identityId, sharesToBurn)).to.be.revertedWithCustomError( + StakingV2, + 'MaximumStakeExceeded', + ); + }); + + it('should update sharding table when from node stake falls below minimum and to node stake exceeds minimum after redelegation', async function () { + // Create two nodes + const node1 = await createProfile(accounts[0], accounts[1]); // From node + const node2 = await createProfile(accounts[2], accounts[3]); // To node + + const minimumStake = await ParametersStorage.minimumStake(); + const extraStake = hre.ethers.utils.parseEther('100'); + + // Stake amount for node1: Initially above minimum + const initialStakeNode1 = minimumStake.add(extraStake); // Above minimum + + // Stake amount for node2: Initially below minimum + const initialStakeNode2 = minimumStake.sub(hre.ethers.utils.parseEther('1')); // Just below minimum + + // Approve tokens and stake on node1 + await Token.approve(StakingV2.address, initialStakeNode1); + await StakingV2['addStake(uint72,uint96)'](node1.identityId, initialStakeNode1); + + // Ensure node1 is in the sharding table + let node1InShardingTable = await ShardingTableStorage.nodeExists(node1.identityId); + expect(node1InShardingTable).to.be.true; + + // Approve tokens and stake on node2 + await Token.connect(accounts[0]).approve(StakingV2.address, initialStakeNode2); + await StakingV2['addStake(uint72,uint96)'](node2.identityId, initialStakeNode2); + + // Ensure node2 is not in the sharding table + let node2InShardingTable = await ShardingTableStorage.nodeExists(node2.identityId); + expect(node2InShardingTable).to.be.false; + + // Get shares contracts + const fromSharesAddress = await ProfileStorage.getSharesContractAddress(node1.identityId); + const fromSharesContract = await hre.ethers.getContractAt('Shares', fromSharesAddress); + + const toSharesAddress = await ProfileStorage.getSharesContractAddress(node2.identityId); + const toSharesContract = await hre.ethers.getContractAt('Shares', toSharesAddress); + + // Redelegate amount that will cause node1's stake to fall below minimum and node2's stake to exceed minimum + const fromCurrentStake = await StakingStorage.totalStakes(node1.identityId); + const toCurrentStake = await StakingStorage.totalStakes(node2.identityId); + + // Calculate amount to redelegate + const stakeToTransfer = fromCurrentStake.sub(minimumStake).add(hre.ethers.utils.parseEther('1')); // Enough to reduce node1 below minimum and increase node2 above minimum + + // Calculate shares to burn + const fromTotalShares = await fromSharesContract.totalSupply(); + const toTotalShares = await toSharesContract.totalSupply(); + + const sharesToBurn = fromTotalShares.mul(stakeToTransfer).div(fromCurrentStake); + + // Calculate redelegationAmount + const redelegationAmount = fromCurrentStake.mul(sharesToBurn).div(fromTotalShares); + + let sharesToMint: BigNumber; + if (toTotalShares.isZero()) { + sharesToMint = redelegationAmount; + } else { + sharesToMint = redelegationAmount.mul(toTotalShares).div(toCurrentStake); + } + + // Increase allowance for fromSharesContract + await fromSharesContract.connect(accounts[0]).increaseAllowance(StakingV2.address, sharesToBurn); + + // Redelegate and check events + await expect(StakingV2.redelegate(node1.identityId, node2.identityId, sharesToBurn)) + .to.emit(StakingV2, 'SharesBurned') + .withArgs( + node1.identityId, + fromSharesAddress, + accounts[0].address, + sharesToBurn, + fromTotalShares.sub(sharesToBurn), + ) + .to.emit(StakingV2, 'StakeWithdrawn') + .withArgs(node1.identityId, node1.nodeId, accounts[0].address, stakeToTransfer) + .to.emit(StakingV2, 'SharesMinted') + .withArgs(node2.identityId, toSharesAddress, accounts[0].address, sharesToMint, toTotalShares.add(sharesToMint)) + .to.emit(StakingV2, 'StakeIncreased') + .withArgs( + node2.identityId, + node2.nodeId, + accounts[0].address, + toCurrentStake, + toCurrentStake.add(stakeToTransfer), + ) + .to.emit(ShardingTable, 'NodeRemoved') // Assuming NodeRemoved event is emitted + .to.emit(ShardingTable, 'NodeAdded'); // Assuming NodeInserted event is emitted + + // After redelegation, check that node1 is no longer in sharding table + node1InShardingTable = await ShardingTableStorage.nodeExists(node1.identityId); + expect(node1InShardingTable).to.be.false; + + // Check that node2 is now in sharding table + node2InShardingTable = await ShardingTableStorage.nodeExists(node2.identityId); + expect(node2InShardingTable).to.be.true; + + // Verify the stakes + const fromFinalStake = await StakingStorage.totalStakes(node1.identityId); + const toFinalStake = await StakingStorage.totalStakes(node2.identityId); + + expect(fromFinalStake).to.equal(fromCurrentStake.sub(stakeToTransfer)); + expect(toFinalStake).to.equal(toCurrentStake.add(stakeToTransfer)); + + // Verify that node1's stake is below minimum + expect(fromFinalStake).to.be.lt(minimumStake); + + // Verify that node2's stake is above minimum + expect(toFinalStake).to.be.gte(minimumStake); + }); });