From 6231ee1f0a77dfa7f3a525fd4d99db4f551e3f3a Mon Sep 17 00:00:00 2001 From: Ian He <39037239+ianhe8x@users.noreply.github.com> Date: Sun, 30 Mar 2025 01:03:36 +1300 Subject: [PATCH 1/5] feat: allow staking rewards to stake --- contracts/RewardsDistributor.sol | 19 ++ contracts/RewardsStaking.sol | 74 ++++- contracts/Staking.sol | 13 +- contracts/StakingManager.sol | 62 +++-- contracts/interfaces/IRewardsDistributor.sol | 2 + contracts/interfaces/IRewardsStaking.sol | 2 + contracts/interfaces/IStakingManager.sol | 11 + publish/ABI/RewardsDistributor.json | 24 ++ publish/ABI/RewardsStaking.json | 18 ++ publish/ABI/Staking.json | 5 + publish/ABI/StakingManager.json | 42 +++ publish/revertcode.json | 3 + test/RewardsDistributer.test.ts | 278 ++++++++++++++++++- 13 files changed, 529 insertions(+), 24 deletions(-) diff --git a/contracts/RewardsDistributor.sol b/contracts/RewardsDistributor.sol index 6dee3a78..9a6b6f99 100644 --- a/contracts/RewardsDistributor.sol +++ b/contracts/RewardsDistributor.sol @@ -450,6 +450,25 @@ contract RewardsDistributor is IRewardsDistributor, Initializable, OwnableUpgrad return rewards; } + function claimForDelegate(address runner, address user) public returns (uint256) { + require( + !(IEraManager(settings.getContractAddress(SQContracts.EraManager)).maintenance()), + 'G019' + ); + uint256 rewards = userRewards(runner, user); + if (rewards == 0) return 0; + info[runner].rewardDebt[user] += rewards; + + // delegate + IERC20(settings.getContractAddress(SQContracts.SQToken)).safeTransfer( + settings.getContractAddress(SQContracts.Staking), + rewards + ); + + emit ClaimRewards(runner, user, rewards); + return rewards; + } + /** * @notice extract for reuse emit RewardsChanged event */ diff --git a/contracts/RewardsStaking.sol b/contracts/RewardsStaking.sol index 69a97740..192ea9c9 100644 --- a/contracts/RewardsStaking.sol +++ b/contracts/RewardsStaking.sol @@ -120,6 +120,11 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { _; } + modifier onlyStakingManager() { + require(msg.sender == settings.getContractAddress(SQContracts.StakingManager), 'G016'); + _; + } + modifier onlyIndexerRegistry() { require(msg.sender == settings.getContractAddress(SQContracts.IndexerRegistry), 'G017'); _; @@ -174,7 +179,7 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { //make sure the eraReward be 0, when runner reregister rewardsDistributor.resetEraReward(_runner, currentEra); - _updateTotalStakingAmount(stakingManager, _runner, 0, false); + _updateTotalStakingAmount(stakingManager, _runner, 0, currentEra, false); //apply first onICRChgange uint256 newCommissionRate = IIndexerRegistry( @@ -254,7 +259,8 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { IStakingManager stakingManager = IStakingManager( settings.getContractAddress(SQContracts.StakingManager) ); - uint256 newDelegation = stakingManager.getAfterDelegationAmount(staker, runner); + uint256 currentEra = _getCurrentEra(); + uint256 newDelegation = stakingManager.getEraDelegationAmount(staker, runner, currentEra); // test whether it is runner's Stake Change if (staker == runner) { @@ -277,7 +283,7 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { pendingStakerNos[runner][lastStaker] = stakerIndex; pendingStakeChangeLength[runner]--; - _updateTotalStakingAmount(stakingManager, runner, lastClaimEra, true); + _updateTotalStakingAmount(stakingManager, runner, lastClaimEra, currentEra, true); emit StakeChanged(runner, staker, newDelegation); // notify stake allocation @@ -287,6 +293,57 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { stakingAllocation.onStakeUpdate(runner); } + function applyRedelegation(address runner, address staker) external onlyStakingManager { + IRewardsDistributor rewardsDistributor = _getRewardsDistributor(); + IndexerRewardInfo memory rewardInfo = rewardsDistributor.getRewardInfo(runner); + // uint256 lastClaimEra = rewardInfo.lastClaimEra; + uint256 currentEra = _getCurrentEra(); + + // require(_pendingStakeChange(runner, staker), 'RS005'); + require(lastSettledEra[runner] == currentEra - 1, 'RS007'); + + // run hook for delegation change + IStakingManager stakingManager = IStakingManager( + settings.getContractAddress(SQContracts.StakingManager) + ); + uint256 newDelegation = stakingManager.getEraDelegationAmount(staker, runner, currentEra); + + // test whether it is runner's Stake Change + if (staker == runner) { + uint256 _runnerStakeWeight = runnerStakeWeight(); + newDelegation = MathUtil.mulDiv(newDelegation, _runnerStakeWeight, PER_MILL); + if (_previousRunnerStakeWeights[runner] != _runnerStakeWeight) { + _setPreviousRunnerStakeWeights(runner, _runnerStakeWeight); + } + } + delegation[staker][runner] = newDelegation; + + uint256 newRewardDebt = MathUtil.mulDiv( + delegation[staker][runner], + rewardInfo.accSQTPerStake, + PER_TRILL + ); + rewardsDistributor.setRewardDebt(runner, staker, newRewardDebt); + // + // // Remove the pending stake change of the staker. + // uint256 stakerIndex = pendingStakerNos[runner][staker]; + // pendingStakers[runner][stakerIndex] = address(0x00); + // address lastStaker = pendingStakers[runner][pendingStakeChangeLength[runner] - 1]; + // pendingStakers[runner][stakerIndex] = lastStaker; + // pendingStakerNos[runner][lastStaker] = stakerIndex; + // pendingStakeChangeLength[runner]--; + + // when lastSettledEra is (currentEra - 1), lastClaimedEra must equal to lastSettledEra + _updateTotalStakingAmount(stakingManager, runner, lastSettledEra[runner], currentEra, true); + emit StakeChanged(runner, staker, delegation[staker][runner]); + + // notify stake allocation + IStakingAllocation stakingAllocation = IStakingAllocation( + settings.getContractAddress(SQContracts.StakingAllocation) + ); + stakingAllocation.onStakeUpdate(runner); + } + /** * @dev Apply the CommissionRate change and update the commissionRates stored in contract states. */ @@ -310,7 +367,13 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { ).getCommissionRate(runner); commissionRates[runner] = newCommissionRate; pendingCommissionRateChange[runner] = 0; - _updateTotalStakingAmount(stakingManager, runner, rewardInfo.lastClaimEra, true); + _updateTotalStakingAmount( + stakingManager, + runner, + rewardInfo.lastClaimEra, + currentEra, + true + ); emit ICRChanged(runner, newCommissionRate); } @@ -382,10 +445,11 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { IStakingManager stakingManager, address runner, uint256 lastClaimEra, + uint256 currentEra, bool doCheck ) private { if (!doCheck || checkAndReflectSettlement(runner, lastClaimEra)) { - uint256 runnerStake = stakingManager.getAfterDelegationAmount(runner, runner); + uint256 runnerStake = stakingManager.getEraDelegationAmount(runner, runner, currentEra); totalStakingAmount[runner] = stakingManager.getTotalStakingAmount(runner) + MathUtil.mulDiv(runnerStake, (runnerStakeWeight() - PER_MILL), PER_MILL); diff --git a/contracts/Staking.sol b/contracts/Staking.sol index cff86a94..8d4e0b75 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -296,7 +296,12 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { emit UnbondCancelled(_source, ua.indexer, ua.amount, _unbondReqId); } - function addDelegation(address _source, address _runner, uint256 _amount) external { + function addDelegation( + address _source, + address _runner, + uint256 _amount, + bool instant + ) external { require( msg.sender == settings.getContractAddress(SQContracts.StakingManager) || msg.sender == address(this), @@ -322,6 +327,10 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { delegation[_source][_runner].valueAfter = _amount; totalStakingAmount[_runner].valueAfter = _amount; } else { + if (instant) { + delegation[_source][_runner].valueAt += _amount; + totalStakingAmount[_runner].valueAt += _amount; + } delegation[_source][_runner].valueAfter += _amount; totalStakingAmount[_runner].valueAfter += _amount; } @@ -342,7 +351,7 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { _amount ); - this.addDelegation(_source, _runner, _amount); + this.addDelegation(_source, _runner, _amount, false); } function removeDelegation(address _source, address _runner, uint256 _amount) external { diff --git a/contracts/StakingManager.sol b/contracts/StakingManager.sol index 93fb6b80..9900d5a9 100644 --- a/contracts/StakingManager.sol +++ b/contracts/StakingManager.sol @@ -3,15 +3,16 @@ pragma solidity 0.8.15; -import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; -import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; - +import './interfaces/IRewardsDistributor.sol'; import './Staking.sol'; -import './interfaces/IStakingManager.sol'; -import './interfaces/IIndexerRegistry.sol'; + import './interfaces/IEraManager.sol'; -import './utils/StakingUtil.sol'; +import './interfaces/IIndexerRegistry.sol'; +import './interfaces/IStakingManager.sol'; import './utils/MathUtil.sol'; +import './utils/StakingUtil.sol'; +import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; /** * Split from Staking, to keep contract size under control @@ -109,7 +110,30 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable { staking.checkDelegateLimitation(_toRunner, _amount); staking.removeDelegation(_source, _fromRunner, _amount); - staking.addDelegation(_source, _toRunner, _amount); + staking.addDelegation(_source, _toRunner, _amount, false); + } + + // @dev delegate rewards to node operator, can be used by both node operator & delegator + // can be called even when the node operator has reached the max delegation limit + // can not be called when the node operator hasn't collected latest rewards + // can not be called when the node operator is unregistered + // @param _runner the node operator address + function delegateReward(address _runner) external { + Staking staking = Staking(settings.getContractAddress(SQContracts.Staking)); + address staker = msg.sender; + // runner should be valid in the following era. + require(this.getAfterDelegationAmount(_runner, _runner) > 0, 'S012'); + IRewardsDistributor rewardsDistributor = IRewardsDistributor( + settings.getContractAddress(SQContracts.RewardsDistributor) + ); + // rewards sent to Staking from rewardsDistributor + uint256 rewards = rewardsDistributor.claimForDelegate(_runner, staker); + require(rewards > 0, 'S011'); + staking.addDelegation(staker, _runner, rewards, true); + IRewardsStaking rewardsStaking = IRewardsStaking( + settings.getContractAddress(SQContracts.RewardsStaking) + ); + rewardsStaking.applyRedelegation(_runner, staker); } function cancelUnbonding(uint256 unbondReqId) external { @@ -127,10 +151,8 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable { require(indexerRegistry.isIndexer(indexer), 'S007'); staking.removeUnbondingAmount(msg.sender, unbondReqId); - // if (msg.sender != indexer) { - // staking.checkDelegateLimitation(indexer, amount); - // } - staking.addDelegation(msg.sender, indexer, amount); + + staking.addDelegation(msg.sender, indexer, amount, false); } /** @@ -189,13 +211,21 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable { return StakingUtil.currentStaking(sm, _currentEra); } - function getDelegationAmount(address _source, address _runner) public view returns (uint256) { + function getDelegationAmount( + address _source, + address _runner + ) external view override returns (uint256) { uint256 eraNumber = IEraManager(settings.getContractAddress(SQContracts.EraManager)) .eraNumber(); - Staking staking = Staking(settings.getContractAddress(SQContracts.Staking)); - (uint256 era, uint256 valueAt, uint256 valueAfter) = staking.delegation(_source, _runner); - StakingAmount memory sm = StakingAmount(era, valueAt, valueAfter); - return StakingUtil.currentStaking(sm, eraNumber); + return _getCurrentDelegationAmount(_source, _runner, eraNumber); + } + + function getEraDelegationAmount( + address _source, + address _runner, + uint256 _era + ) external view override returns (uint256) { + return _getCurrentDelegationAmount(_source, _runner, _era); } function getTotalStakingAmount(address _runner) public view override returns (uint256) { diff --git a/contracts/interfaces/IRewardsDistributor.sol b/contracts/interfaces/IRewardsDistributor.sol index a387b469..542aa4da 100644 --- a/contracts/interfaces/IRewardsDistributor.sol +++ b/contracts/interfaces/IRewardsDistributor.sol @@ -42,4 +42,6 @@ interface IRewardsDistributor { function userRewards(address indexer, address user) external view returns (uint256); function getRewardInfo(address indexer) external view returns (IndexerRewardInfo memory); + + function claimForDelegate(address runner, address user) external returns (uint256); } diff --git a/contracts/interfaces/IRewardsStaking.sol b/contracts/interfaces/IRewardsStaking.sol index 73c65784..2feee7fb 100644 --- a/contracts/interfaces/IRewardsStaking.sol +++ b/contracts/interfaces/IRewardsStaking.sol @@ -24,4 +24,6 @@ interface IRewardsStaking { function getDelegationAmount(address source, address indexer) external view returns (uint256); function applyRunnerWeightChange(address _runner) external; + + function applyRedelegation(address runner, address staker) external; } diff --git a/contracts/interfaces/IStakingManager.sol b/contracts/interfaces/IStakingManager.sol index c7f9ead0..5f722951 100644 --- a/contracts/interfaces/IStakingManager.sol +++ b/contracts/interfaces/IStakingManager.sol @@ -18,4 +18,15 @@ interface IStakingManager { address _delegator, address _runner ) external view returns (uint256); + + function getDelegationAmount( + address _delegator, + address _runner + ) external view returns (uint256); + + function getEraDelegationAmount( + address _delegator, + address _runner, + uint256 _era + ) external view returns (uint256); } diff --git a/publish/ABI/RewardsDistributor.json b/publish/ABI/RewardsDistributor.json index a4e6472c..d0a01283 100644 --- a/publish/ABI/RewardsDistributor.json +++ b/publish/ABI/RewardsDistributor.json @@ -253,6 +253,30 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "runner", + "type": "address" + }, + { + "internalType": "address", + "name": "user", + "type": "address" + } + ], + "name": "claimForDelegate", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/publish/ABI/RewardsStaking.json b/publish/ABI/RewardsStaking.json index a01230b0..4700d644 100644 --- a/publish/ABI/RewardsStaking.json +++ b/publish/ABI/RewardsStaking.json @@ -145,6 +145,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "runner", + "type": "address" + }, + { + "internalType": "address", + "name": "staker", + "type": "address" + } + ], + "name": "applyRedelegation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/publish/ABI/Staking.json b/publish/ABI/Staking.json index cdd4cd40..e7831da4 100644 --- a/publish/ABI/Staking.json +++ b/publish/ABI/Staking.json @@ -215,6 +215,11 @@ "internalType": "uint256", "name": "_amount", "type": "uint256" + }, + { + "internalType": "bool", + "name": "instant", + "type": "bool" } ], "name": "addDelegation", diff --git a/publish/ABI/StakingManager.json b/publish/ABI/StakingManager.json index 88e8bb1d..3f5c2421 100644 --- a/publish/ABI/StakingManager.json +++ b/publish/ABI/StakingManager.json @@ -62,6 +62,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_runner", + "type": "address" + } + ], + "name": "delegateReward", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -129,6 +142,35 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_source", + "type": "address" + }, + { + "internalType": "address", + "name": "_runner", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_era", + "type": "uint256" + } + ], + "name": "getEraDelegationAmount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/publish/revertcode.json b/publish/revertcode.json index d8ba3230..d4c426cb 100644 --- a/publish/revertcode.json +++ b/publish/revertcode.json @@ -29,6 +29,8 @@ "S008": "Insufficient unstake amount", "S009": "No unbond request to widthdraw", "S010": "Invaild amount to slash indexer", + "S011": "No rewards to claim", + "S012": "Invalid node operator to delegate to", "A001": "invaild round time set", "A002": "invaild round to airdrop", "A003": "duplicate airdrop", @@ -142,6 +144,7 @@ "RS005": "No pending", "ISA001": "Only ServiceAgreementRegistry can call this function", "RS006": "Rewards not collected", + "RS007": "Last era staking not settled", "SA001": "Invalid agreement", "SA002": "Only consumer can add user", "SA003": "Only consumer can remove user", diff --git a/test/RewardsDistributer.test.ts b/test/RewardsDistributer.test.ts index b3a593ec..0e3d6cef 100644 --- a/test/RewardsDistributer.test.ts +++ b/test/RewardsDistributer.test.ts @@ -698,7 +698,7 @@ describe('RewardsDistributor Contract', () => { expect(await token.balanceOf(delegator.address)).to.be.eq(etherParse('8.000899100898')); }); - it('delegatior should be able to delegate to collectAndDistributeRewards of last Era', async () => { + it('delegatior should be able to delegate cross 2 eras if no staking changed', async () => { //move to ear3 await startNewEra(eraManager); //for now only era2 rewards not be distributed @@ -706,6 +706,7 @@ describe('RewardsDistributor Contract', () => { //delegatior delegate 1000 SQT expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(1); await stakingManager.connect(delegator).delegate(runner.address, etherParse('1')); + // triggers reflectEraUpdate so lastClaimEra is pushed to 2 expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); }); @@ -1000,4 +1001,279 @@ describe('RewardsDistributor Contract', () => { await stakingManager.connect(delegator).undelegate(runner.address, etherParse('0.1')); }); }); + + describe('claim rewards into stake', async () => { + const delegation1 = etherParse(4000); + const delegation2 = etherParse(5000); + beforeEach(async () => { + await rewardsStaking.setRunnerStakeWeight(2e6); + //a 30 days agreement with 400 rewards come in at Era2 + await acceptPlan(runner, consumer, 30, etherParse('3'), DEPLOYMENT_ID, token, planManager); + await acceptPlan(root, consumer, 30, etherParse('3'), DEPLOYMENT_ID, token, planManager); + await staking.setIndexerLeverageLimit(20); + await token.transfer(delegator.address, delegation1); + await token.connect(delegator).increaseAllowance(staking.address, delegation1); + await stakingManager.connect(delegator).delegate(runner.address, delegation1); + await token.transfer(delegator2.address, delegation2); + await token.connect(delegator2).increaseAllowance(staking.address, delegation2); + await stakingManager.connect(delegator2).delegate(runner.address, delegation2); + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + }); + + it('should allow delegator to claim collect and delegate', async () => { + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await expect(stakingManager.connect(delegator).delegateReward(runner.address)) + .to.emit(rewardsDistributor, 'ClaimRewards') + .withArgs(runner.address, delegator.address, delegatorReward1) + .to.emit(token, 'Transfer') + .withArgs(rewardsDistributor.address, staking.address, delegatorReward1); + + // check userRewards + expect(await rewardsDistributor.userRewards(runner.address, delegator.address)).to.be.eq(0); + // check delegation in staking + const delegation = await staking.delegation(delegator.address, runner.address); + expect(delegation.era).to.be.eq(4); + expect(delegation.valueAt).to.be.eq(delegation1.add(delegatorReward1)); + expect(delegation.valueAfter).to.be.eq(delegation1.add(delegatorReward1)); + // check delegation in rewardsStaking + expect(await rewardsStaking.getDelegationAmount(delegator.address, runner.address)).to.be.eq( + delegation1.add(delegatorReward1) + ); + }); + + // node operator + it('should allow node operator to claim collect and delegate', async () => { + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + //delegator claim and delegate + const rewards1 = await rewardsDistributor.userRewards(runner.address, runner.address); + expect(rewards1).to.be.gt(0); + await expect(stakingManager.connect(runner).delegateReward(runner.address)) + .to.emit(rewardsDistributor, 'ClaimRewards') + .withArgs(runner.address, runner.address, rewards1) + .to.emit(token, 'Transfer') + .withArgs(rewardsDistributor.address, staking.address, rewards1); + + // check userRewards + expect(await rewardsDistributor.userRewards(runner.address, runner.address)).to.be.eq(0); + // check delegation in staking + const delegation = await staking.delegation(runner.address, runner.address); + expect(delegation.era).to.be.eq(4); + expect(delegation.valueAt).to.be.eq(runnerInitialStake.add(rewards1)); + expect(delegation.valueAfter).to.be.eq(runnerInitialStake.add(rewards1)); + // check delegation in rewardsStaking + const runnerWeight = await rewardsStaking.runnerStakeWeight(); + expect(await rewardsStaking.getDelegationAmount(runner.address, runner.address)).to.be.eq( + runnerInitialStake.add(rewards1).mul(runnerWeight).div(1e6) + ); + }); + + // when stake change together with reawrd delegate (before & after) + it('should allow delegator to claim collect and delegate after they increased stake', async () => { + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + const moreDelegation = etherParse(1000); + await token.transfer(delegator.address, moreDelegation); + await token.connect(delegator).increaseAllowance(staking.address, moreDelegation); + await stakingManager.connect(delegator).delegate(runner.address, moreDelegation); + + const delegationBefore = await staking.delegation(delegator.address, runner.address); + expect(delegationBefore.era).to.be.eq(4); + expect(delegationBefore.valueAt).to.be.eq(delegation1); + expect(delegationBefore.valueAfter).to.be.eq(delegation1.add(moreDelegation)); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await expect(stakingManager.connect(delegator).delegateReward(runner.address)) + .to.emit(rewardsDistributor, 'ClaimRewards') + .withArgs(runner.address, delegator.address, delegatorReward1) + .to.emit(token, 'Transfer') + .withArgs(rewardsDistributor.address, staking.address, delegatorReward1); + + // check userRewards + expect(await rewardsDistributor.userRewards(runner.address, delegator.address)).to.be.eq(0); + // check delegation in staking + const delegation = await staking.delegation(delegator.address, runner.address); + expect(delegation.era).to.be.eq(4); + expect(delegation.valueAt).to.be.eq(delegationBefore.valueAt.add(delegatorReward1)); + expect(delegation.valueAfter).to.be.eq(delegationBefore.valueAfter.add(delegatorReward1)); + // check delegation in rewardsStaking + expect(await rewardsStaking.getDelegationAmount(delegator.address, runner.address)).to.be.eq( + delegation1.add(delegatorReward1) + ); + }); + + it('should allow delegator to claim collect and delegate after they reduce stake', async () => { + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + const reduceDelegation = etherParse(1000); + await stakingManager.connect(delegator).undelegate(runner.address, reduceDelegation); + + const delegationBefore = await staking.delegation(delegator.address, runner.address); + expect(delegationBefore.era).to.be.eq(4); + expect(delegationBefore.valueAt).to.be.eq(delegation1); + expect(delegationBefore.valueAfter).to.be.eq(delegation1.sub(reduceDelegation)); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await expect(stakingManager.connect(delegator).delegateReward(runner.address)) + .to.emit(rewardsDistributor, 'ClaimRewards') + .withArgs(runner.address, delegator.address, delegatorReward1) + .to.emit(token, 'Transfer') + .withArgs(rewardsDistributor.address, staking.address, delegatorReward1); + + // check userRewards + expect(await rewardsDistributor.userRewards(runner.address, delegator.address)).to.be.eq(0); + // check delegation in staking + const delegation = await staking.delegation(delegator.address, runner.address); + expect(delegation.era).to.be.eq(4); + expect(delegation.valueAt).to.be.eq(delegationBefore.valueAt.add(delegatorReward1)); + expect(delegation.valueAfter).to.be.eq(delegationBefore.valueAfter.add(delegatorReward1)); + // check delegation in rewardsStaking + expect(await rewardsStaking.getDelegationAmount(delegator.address, runner.address)).to.be.eq( + delegation1.add(delegatorReward1) + ); + }); + + // when reward is 0 + it('should skip delegateRewards if reward is 0', async () => { + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await rewardsDistributor.connect(delegator).claim(runner.address); + expect(await rewardsDistributor.userRewards(runner.address, delegator.address)).to.be.eq(0); + + await expect(stakingManager.connect(delegator).delegateReward(runner.address)).to.revertedWith('S011'); + }); + + // when capacity full + // runner stake: 1000, capacity = 1000 * 10 = 10000 + // delegator stake: 4000, delegator2 stake: 5000 + // 1000, 15000 + it('should allow delegator to claim collect and delegate when capacity is full', async () => { + const leverageLimit = 10; + await staking.setIndexerLeverageLimit(leverageLimit); + const runnerStake = await staking.delegation(runner.address, runner.address); + console.log('runnerStake', runnerStake.valueAt.toString(), runnerStake.valueAfter.toString()); + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + await token.transfer(delegator2.address, 1); + await token.connect(delegator2).increaseAllowance(staking.address, 1); + const totalBefore = await staking.totalStakingAmount(runner.address); + console.log('totalStakingAmount', totalBefore.valueAfter.toString()); + + await expect(stakingManager.connect(delegator2).delegate(runner.address, 1)).to.be.revertedWith('S002'); + + //move to next era + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await expect(stakingManager.connect(delegator).delegateReward(runner.address)) + .to.emit(rewardsDistributor, 'ClaimRewards') + .withArgs(runner.address, delegator.address, delegatorReward1) + .to.emit(token, 'Transfer') + .withArgs(rewardsDistributor.address, staking.address, delegatorReward1); + + // check userRewards + expect(await rewardsDistributor.userRewards(runner.address, delegator.address)).to.be.eq(0); + // check delegation in staking + const delegation = await staking.delegation(delegator.address, runner.address); + expect(delegation.era).to.be.eq(4); + expect(delegation.valueAt).to.be.eq(delegation1.add(delegatorReward1)); + expect(delegation.valueAfter).to.be.eq(delegation1.add(delegatorReward1)); + // check delegation in rewardsStaking + expect(await rewardsStaking.getDelegationAmount(delegator.address, runner.address)).to.be.eq( + delegation1.add(delegatorReward1) + ); + const effectiveTotalStake = await stakingManager.getEffectiveTotalStake(runner.address); + expect(effectiveTotalStake).to.eq(runnerStake.valueAt.mul(leverageLimit)); + }); + + // when node operator not catch up + it('should not allow delegator to claim collect and delegate when node operator not catch up', async () => { + const runnerStake = await staking.delegation(runner.address, runner.address); + console.log('runnerStake', runnerStake.valueAt.toString(), runnerStake.valueAfter.toString()); + expect(await eraManager.eraNumber()).to.be.eq(3); + expect((await rewardsDistributor.getRewardInfo(runner.address)).lastClaimEra).to.be.eq(2); + + //move to next era, accumulate rewards + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + // make staking change + const moreDelegation = etherParse(1000); + await token.transfer(delegator2.address, moreDelegation); + await token.connect(delegator2).increaseAllowance(staking.address, moreDelegation); + await stakingManager.connect(delegator2).delegate(runner.address, moreDelegation); + + //move to next era, skip collectAndDistributeRewards + await startNewEra(eraManager); + + //delegator claim and delegate + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + await expect(stakingManager.connect(delegator).delegateReward(runner.address)).to.revertedWith('RS003'); + }); + + // when node operator unregister not catch up + it('delegate rewards with unregistered indexer', async () => { + //move to Era13 + await startNewEra(eraManager); + await rewardsHelper.connect(runner).indexerCatchup(runner.address); + + const delegatorReward1 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward1).to.be.gt(0); + + // wait until agreements all ended + await eraManager.updateEraPeriod(864000); + await startNewEra(eraManager); + await startNewEra(eraManager); + await startNewEra(eraManager); + + await projectRegistry.connect(runner).stopService(DEPLOYMENT_ID); + await rewardsHelper.indexerCatchup(runner.address); + await indexerRegistry.connect(runner).unregisterIndexer(); + + const delegatorReward2 = await rewardsDistributor.userRewards(runner.address, delegator.address); + expect(delegatorReward2).to.be.gt(0); + + // normal delegation is not allowed to unregistered indexer + + await token.transfer(delegator.address, 1); + await token.connect(delegator).increaseAllowance(staking.address, 1); + await expect(stakingManager.connect(delegator).delegate(runner.address, 1)).to.be.revertedWith('S002'); + + // delegator claim and delegate + await expect(stakingManager.connect(delegator).delegateReward(runner.address)).to.be.revertedWith('S012'); + }); + }); }); From 850e2b51c42fce0a97b67eaebb6bbe96b0663c3e Mon Sep 17 00:00:00 2001 From: Ian He <39037239+ianhe8x@users.noreply.github.com> Date: Mon, 31 Mar 2025 12:25:05 +1300 Subject: [PATCH 2/5] clean up --- contracts/RewardsStaking.sol | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/contracts/RewardsStaking.sol b/contracts/RewardsStaking.sol index 192ea9c9..aadcb9b1 100644 --- a/contracts/RewardsStaking.sol +++ b/contracts/RewardsStaking.sol @@ -296,10 +296,8 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { function applyRedelegation(address runner, address staker) external onlyStakingManager { IRewardsDistributor rewardsDistributor = _getRewardsDistributor(); IndexerRewardInfo memory rewardInfo = rewardsDistributor.getRewardInfo(runner); - // uint256 lastClaimEra = rewardInfo.lastClaimEra; uint256 currentEra = _getCurrentEra(); - // require(_pendingStakeChange(runner, staker), 'RS005'); require(lastSettledEra[runner] == currentEra - 1, 'RS007'); // run hook for delegation change @@ -324,16 +322,8 @@ contract RewardsStaking is IRewardsStaking, Initializable, OwnableUpgradeable { PER_TRILL ); rewardsDistributor.setRewardDebt(runner, staker, newRewardDebt); - // - // // Remove the pending stake change of the staker. - // uint256 stakerIndex = pendingStakerNos[runner][staker]; - // pendingStakers[runner][stakerIndex] = address(0x00); - // address lastStaker = pendingStakers[runner][pendingStakeChangeLength[runner] - 1]; - // pendingStakers[runner][stakerIndex] = lastStaker; - // pendingStakerNos[runner][lastStaker] = stakerIndex; - // pendingStakeChangeLength[runner]--; - - // when lastSettledEra is (currentEra - 1), lastClaimedEra must equal to lastSettledEra + + // since lastSettledEra is (currentEra - 1), lastClaimedEra must equal to lastSettledEra _updateTotalStakingAmount(stakingManager, runner, lastSettledEra[runner], currentEra, true); emit StakeChanged(runner, staker, delegation[staker][runner]); From 2402f7d6638398b9b71e192eeec5c2bf251408e7 Mon Sep 17 00:00:00 2001 From: Jacob <3282625+icezohu@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:42:58 +0800 Subject: [PATCH 3/5] feat: new delegation event to include instant indicator feat: new delegation event to include instant indicator --- contracts/Staking.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/contracts/Staking.sol b/contracts/Staking.sol index 8d4e0b75..6f473fc2 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -135,6 +135,16 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { */ event DelegationAdded(address indexed source, address indexed runner, uint256 amount); + /** + * @dev Emitted when stake to an Runner, with instant indicator. + */ + event DelegationAdded2( + address indexed source, + address indexed runner, + uint256 amount, + bool instant + ); + /** * @dev Emitted when unstake to an Runner. */ @@ -338,6 +348,7 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { _onDelegationChange(_source, _runner); emit DelegationAdded(_source, _runner, _amount); + emit DelegationAdded2(_source, _runner, _amount, instant); } function delegateToIndexer( From 54a9a7e1f80b2438656fbf659bfa433a8860ac05 Mon Sep 17 00:00:00 2001 From: Jacob <3282625+icezohu@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:14:30 +0800 Subject: [PATCH 4/5] feat: replace DelegationAdded event with DelegationAdded2 for instant delegation support --- contracts/Staking.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/Staking.sol b/contracts/Staking.sol index 6f473fc2..f066c964 100644 --- a/contracts/Staking.sol +++ b/contracts/Staking.sol @@ -347,7 +347,6 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter { lockedAmount[_source] += _amount; _onDelegationChange(_source, _runner); - emit DelegationAdded(_source, _runner, _amount); emit DelegationAdded2(_source, _runner, _amount, instant); } From 9db231a4f59c7d3454177e40fcbb41d43c29912b Mon Sep 17 00:00:00 2001 From: Jacob <3282625+icezohu@users.noreply.github.com> Date: Mon, 14 Apr 2025 17:17:05 +0800 Subject: [PATCH 5/5] feat: add detailed inputs to DelegationAdded2 event for enhanced delegation tracking --- publish/ABI/Staking.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/publish/ABI/Staking.json b/publish/ABI/Staking.json index e7831da4..1cc41a64 100644 --- a/publish/ABI/Staking.json +++ b/publish/ABI/Staking.json @@ -24,6 +24,37 @@ "name": "DelegationAdded", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "source", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "runner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "instant", + "type": "bool" + } + ], + "name": "DelegationAdded2", + "type": "event" + }, { "anonymous": false, "inputs": [