Skip to content

Commit

Permalink
Merge pull request #833 from NexusMutual/fix/prevent-switch-withdraw-…
Browse files Browse the repository at this point in the history
…v1-tokens

Fix: add withdraw/switch membership restrictions
  • Loading branch information
roxdanila committed Apr 18, 2023
2 parents 0552a2a + b90ebf9 commit 7dfe69f
Show file tree
Hide file tree
Showing 10 changed files with 490 additions and 4 deletions.
12 changes: 12 additions & 0 deletions contracts/interfaces/ITokenController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,16 @@ interface ITokenController {
function burnStakedNXM(uint amount, uint poolId) external;

function stakingPoolNXMBalances(uint poolId) external view returns(uint128 rewards, uint128 deposits);

function tokensLocked(address _of, bytes32 _reason) external view returns (uint256 amount);

function getWithdrawableCoverNotes(
address coverOwner
) external view returns (
uint[] memory coverIds,
bytes32[] memory lockReasons,
uint withdrawableAmount
);

function getPendingRewards(address member) external view returns (uint);
}
12 changes: 12 additions & 0 deletions contracts/mocks/MemberRoles/MRMockAssessment.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-only

pragma solidity ^0.8.18;
import "../../interfaces/IAssessment.sol";

contract MRMockAssessment {
mapping(address => IAssessment.Stake) public stakeOf;

function setStakeOf(address staker, uint96 stakeAmount) external {
stakeOf[staker] = IAssessment.Stake(stakeAmount, 0 /* rewardWithdrawableFromIndex */ , 0 /* fraudCount */);
}
}
21 changes: 21 additions & 0 deletions contracts/mocks/MemberRoles/MRMockPooledStaking.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.18;

import "../../abstract/MasterAwareV2.sol";
import "../../interfaces/IPooledStaking.sol";

contract MRMockPooledStaking {

mapping(address => uint) public stakerReward;
mapping(address => uint) public stakerDeposit;

// Manually set the staker reward
function setStakerReward(address staker, uint reward) external {
stakerReward[staker] = reward;
}

// Manually set the staker deposit
function setStakerDeposit(address staker, uint deposit) external {
stakerDeposit[staker] = deposit;
}
}
38 changes: 36 additions & 2 deletions contracts/mocks/TokenControllerMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ contract TokenControllerMock is MasterAwareV2 {

mapping(address => bool) public isStakingPoolManager;

mapping(address => mapping (bytes32 => uint)) public _tokensLocked;

mapping(address => uint) public _withdrawableCoverNotes;

mapping(address => uint) public _pendingRewards;

function mint(address _member, uint256 _amount) public onlyInternal {
token().mint(_member, _amount);
}
Expand Down Expand Up @@ -119,6 +125,36 @@ contract TokenControllerMock is MasterAwareV2 {
isStakingPoolManager[member] = isManager;
}

function setTokensLocked(address member, bytes32 reason, uint amount) external {
_tokensLocked[member][reason] = amount;
}

function setWithdrawableCoverNotes(address member, uint amount) external {
_withdrawableCoverNotes[member] = amount;
}

function setPendingRewards(address member, uint amount) external {
_pendingRewards[member] = amount;
}

function tokensLocked(address member, bytes32 reason) external view returns (uint) {
return _tokensLocked[member][reason];
}

function getWithdrawableCoverNotes(address member) external view returns (
uint[] memory /* coverIds */,
bytes32[] memory /* lockReasons */,
uint amount
) {
uint[] memory coverIds;
bytes32[] memory lockReasons;
return (coverIds, lockReasons, _withdrawableCoverNotes[member]);
}

function getPendingRewards(address member) external view returns (uint) {
return _pendingRewards[member];
}

/* unused functions */

modifier unused {
Expand All @@ -128,7 +164,5 @@ contract TokenControllerMock is MasterAwareV2 {

function burnLockedTokens(address, bytes32, uint256) unused external {}

function tokensLocked(address, bytes32) unused external pure returns (uint256) { return 0; }

function releaseLockedTokens(address _of, bytes32 _reason, uint256 _amount) unused external {}
}
43 changes: 43 additions & 0 deletions contracts/modules/governance/MemberRoles.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import "../../interfaces/ITokenController.sol";
import "../../interfaces/ICover.sol";
import "../../interfaces/INXMToken.sol";
import "../../interfaces/IStakingPool.sol";
import "../../interfaces/IPooledStaking.sol";
import "../../interfaces/IAssessment.sol";
import "./external/Governed.sol";

contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 {
Expand Down Expand Up @@ -108,6 +110,14 @@ contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 {
return ICover(internalContracts[uint(ID.CO)]);
}

function legacyPooledStaking() internal view returns (IPooledStaking) {
return IPooledStaking(internalContracts[uint(ID.PS)]);
}

function assessment() internal view returns (IAssessment) {
return IAssessment(internalContracts[uint(ID.AS)]);
}

/// Updates contracts dependencies.
///
/// @dev Iupgradable Interface to update dependent contract address
Expand All @@ -122,6 +132,8 @@ contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 {
internalContracts[uint(ID.TC)] = master.getLatestAddress("TC");
internalContracts[uint(ID.P1)] = master.getLatestAddress("P1");
internalContracts[uint(ID.CO)] = master.getLatestAddress("CO");
internalContracts[uint(ID.PS)] = master.getLatestAddress("PS");
internalContracts[uint(ID.AS)] = master.getLatestAddress("AS");
}

/// Adds a new member role.
Expand Down Expand Up @@ -223,6 +235,21 @@ contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 {
"MemberRoles: Member is a staking pool manager"
);

IPooledStaking _legacyPooledStaking = legacyPooledStaking();

// check that there are no tokens left to withdraw
require(_legacyPooledStaking.stakerDeposit(msg.sender) == 0, "Member has NXM staked in Pooled Staking");
require(_legacyPooledStaking.stakerReward(msg.sender) == 0, "Member has NXM rewards in Pooled Staking");

require(_tokenController.tokensLocked(msg.sender, "CLA") == 0, "Member has NXM staked in Claim Assessment V1");
(, , uint coverNotesAmount) = _tokenController.getWithdrawableCoverNotes(msg.sender);
require(coverNotesAmount == 0, "Member has withdrawable cover notes");
// _tokenController.getPendingRewards includes both assessment and governance rewards
require(_tokenController.getPendingRewards(msg.sender) == 0, "Member has pending rewards in Token Controller");

(uint96 stakeAmount, ,) = assessment().stakeOf(msg.sender);
require(stakeAmount == 0, "Member has Assessment stake");

_tokenController.burnFrom(msg.sender, token.balanceOf(msg.sender));
_updateRole(msg.sender, uint(Role.Member), false);
_tokenController.removeFromWhitelist(msg.sender); // need clarification on whitelist
Expand Down Expand Up @@ -303,6 +330,22 @@ contract MemberRoles is IMemberRoles, Governed, MasterAwareV2 {
require(block.timestamp > token.isLockedForMV(currentAddress), "Locked for governance voting");

ITokenController _tokenController = tokenController();
IPooledStaking _legacyPooledStaking = legacyPooledStaking();

// check that there are no tokens left to withdraw
require(_legacyPooledStaking.stakerDeposit(currentAddress) == 0, "Member has NXM staked in Pooled Staking");
require(_legacyPooledStaking.stakerReward(currentAddress) == 0, "Member has NXM rewards in Pooled Staking");

require(_tokenController.tokensLocked(currentAddress, "CLA") == 0, "Member has NXM staked in Claim Assessment V1");
(, , uint coverNotesAmount) = _tokenController.getWithdrawableCoverNotes(currentAddress);
require(coverNotesAmount == 0, "Member has withdrawable cover notes");
// _tokenController.getPendingRewards includes both assessment and governance rewards
require(_tokenController.getPendingRewards(currentAddress) == 0, "Member has pending rewards in Token Controller");

(uint96 stakeAmount, ,) = assessment().stakeOf(currentAddress);
require(stakeAmount == 0, "Member has Assessment stake");


_tokenController.addToWhitelist(newAddress);
_updateRole(currentAddress, uint(Role.Member), false);
_updateRole(newAddress, uint(Role.Member), true);
Expand Down
3 changes: 1 addition & 2 deletions test/fork/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
describe('fork tests', function () {
require('./migrated-claims');
require('./recalculate-effective-weights');
require('./withdraw-switch-membership-restrictions');
});
202 changes: 202 additions & 0 deletions test/fork/withdraw-switch-membership-restrictions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
const { ethers, network } = require('hardhat');
const { expect } = require('chai');

const evm = require('./evm')();

const {
Address: { ETH },
UserAddress,
} = require('./utils');
const { ProposalCategory: PROPOSAL_CATEGORIES } = require('../../lib/constants');
const { formatBytes32String } = ethers.utils;

const { NXM_WHALE_1, NXM_WHALE_2 } = UserAddress;

const { parseEther, defaultAbiCoder, toUtf8Bytes } = ethers.utils;

const DAI_ADDRESS = '0x6B175474E89094C44Da98b954EedeAC495271d0F';

const ASSET_V1_TO_ASSET_V2 = {};
ASSET_V1_TO_ASSET_V2[ETH.toLowerCase()] = 0;
ASSET_V1_TO_ASSET_V2[DAI_ADDRESS.toLowerCase()] = 1;

const V2Addresses = {
SwapOperator: '0xcafea536d7f79F31Fa49bC40349f6a5F7E19D842',
PriceFeedOracle: '0xcafeaf0a0672360941b7f0b6d015797292e842c6',
Pool: '0xcafea112Db32436c2390F5EC988f3aDB96870627',
NXMaster: '0xcafea0047591B979c714A63283B8f902554deB66',
ProductsV1: '0xcafeab02966FdC69Ce5aFDD532DD51466892E32B',
CoverNFTDescriptor: '0xcafead1E31Ac8e4924Fc867c2C54FAB037458cb9',
CoverNFT: '0xcafeaCa76be547F14D0220482667B42D8E7Bc3eb',
StakingPoolFactory: '0xcafeafb97BF8831D95C0FC659b8eB3946B101CB3',
StakingNFTDescriptor: '0xcafea534e156a41b3e77f29Bf93C653004f1455C',
StakingNFT: '0xcafea508a477D94c502c253A58239fb8F948e97f',
StakingPool: '0xcafeacf62FB96fa1243618c4727Edf7E04D1D4Ca',
CoverImpl: '0xcafeaCbabeEd884AE94046d87C8aAB120958B8a6',
StakingProductsImpl: '0xcafea524e89514e131eE9F8462536793d49d8738',
IndividualClaimsImpl: '0xcafeaC308bC9B49d6686897270735b4Dc11Fa1Cf',
YieldTokenIncidentsImpl: '0xcafea7F77b63E995aE864dA9F36c8012666F8Fa4',
AssessmentImpl: '0xcafea40dE114C67925BeB6e8f0F0e2ee4a25Dd88',
LegacyClaimsReward: '0xcafeaDcAcAA2CD81b3c54833D6896596d218BFaB',
TokenController: '0xcafea53357c11b3967A8C7167Fb4973C75063DbB',
MCR: '0xcafea444db21dc06f34570185cF0014701c7D62e',
MemberRoles: '0xcafea22Faff6aEc1d1bfc146b2e2EABC73Fa7Acc',
LegacyPooledStaking: '0xcafea16366682a6c0083c38b2a731BC223c53D27',
CoverMigrator: '0xcafeac41b010299A9bec5308CCe6aFC2c4DF8D39',
LegacyGateway: '0xcafeaD694A05815f03F19c357200c6D95968e205',
Governance: '0xcafeafA258Be9aCb7C0De989be21A8e9583FBA65',
CoverViewer: '0xcafea84e199C85E44F34CD75374188D33FB94B4b',
StakingViewer: '0xcafea2B7904eE0089206ab7084bCaFB8D476BD04',
};

const NXM_TOKEN_ADDRESS = '0xd7c49CEE7E9188cCa6AD8FF264C1DA2e69D4Cf3B';

const getSigner = async address => {
const provider =
network.name !== 'hardhat' // ethers errors out when using non-local accounts
? new ethers.providers.JsonRpcProvider(network.config.url)
: ethers.provider;
return provider.getSigner(address);
};
async function submitGovernanceProposal(categoryId, actionData, signers, gv) {
const id = await gv.getProposalLength();

console.log(`Proposal ${id}`);

await gv.connect(signers[0]).createProposal('', '', '', 0);
await gv.connect(signers[0]).categorizeProposal(id, categoryId, 0);
await gv.connect(signers[0]).submitProposalWithSolution(id, '', actionData);

for (let i = 0; i < signers.length; i++) {
await gv.connect(signers[i]).submitVote(id, 1);
}

const tx = await gv.closeProposal(id, { gasLimit: 21e6 });
const receipt = await tx.wait();

assert.equal(
receipt.events.some(x => x.event === 'ActionSuccess' && x.address === gv.address),
true,
'ActionSuccess was expected',
);

const proposal = await gv.proposal(id);
assert.equal(proposal[2].toNumber(), 3, 'Proposal Status != ACCEPTED');
}

describe('prevent switch or withdraw membership when tokens are locked', function () {
before(async function () {
// Initialize evm helper
await evm.connect(ethers.provider);
await getSigner('0x1eE3ECa7aEF17D1e74eD7C447CcBA61aC76aDbA9');

// Get or revert snapshot if network is tenderly
if (network.name === 'tenderly') {
const { TENDERLY_SNAPSHOT_ID } = process.env;
if (TENDERLY_SNAPSHOT_ID) {
await evm.revert(TENDERLY_SNAPSHOT_ID);
console.log(`Reverted to snapshot ${TENDERLY_SNAPSHOT_ID}`);
} else {
console.log('Snapshot ID: ', await evm.snapshot());
}
}
});

it('load contracts', async function () {
this.master = await ethers.getContractAt('NXMaster', '0x01BFd82675DBCc7762C84019cA518e701C0cD07e');
this.productsV1 = await ethers.getContractAt('ProductsV1', V2Addresses.ProductsV1);
this.gateway = await ethers.getContractAt('LegacyGateway', '0x089Ab1536D032F54DFbC194Ba47529a4351af1B5');
this.quotationData = await ethers.getContractAt(
'LegacyQuotationData',
'0x1776651F58a17a50098d31ba3C3cD259C1903f7A',
);
this.individualClaims = await ethers.getContractAt(
'IndividualClaims',
await this.master.getLatestAddress(toUtf8Bytes('CI')),
);
this.coverMigrator = await ethers.getContractAt(
'CoverMigrator',
await this.master.getLatestAddress(toUtf8Bytes('CL')),
);
this.coverViewer = await ethers.getContractAt('CoverViewer', V2Addresses.CoverViewer);
this.assessment = await ethers.getContractAt('Assessment', await this.master.getLatestAddress(toUtf8Bytes('AS')));
this.assessment = await ethers.getContractAt('Assessment', await this.master.getLatestAddress(toUtf8Bytes('AS')));
this.dai = await ethers.getContractAt('ERC20Mock', DAI_ADDRESS);
this.cover = await ethers.getContractAt('Cover', await this.master.getLatestAddress(toUtf8Bytes('CO')));
this.memberRoles = await ethers.getContractAt('MemberRoles', await this.master.getLatestAddress(toUtf8Bytes('MR')));
this.governance = await ethers.getContractAt('Governance', await this.master.getLatestAddress(toUtf8Bytes('GV')));
this.tokenController = await ethers.getContractAt(
'TokenController',
await this.master.getLatestAddress(toUtf8Bytes('TC')),
);
this.legacyPooledStaking = await ethers.getContractAt(
'LegacyPooledStaking',
await this.master.getLatestAddress(toUtf8Bytes('PS')),
);
this.assessment = await ethers.getContractAt('Assessment', await this.master.getLatestAddress(toUtf8Bytes('AS')));
this.nxmToken = await ethers.getContractAt('NXMToken', NXM_TOKEN_ADDRESS);
});

it('Impersonate AB members', async function () {
const { memberArray: abMembers } = await this.memberRoles.members(1);
this.abMembers = [];
for (const address of abMembers) {
await evm.impersonate(address);
await evm.setBalance(address, parseEther('1000'));
this.abMembers.push(await getSigner(address));
}
});

it('upgrades MemberRoles', async function () {
const codes = ['MR'].map(code => toUtf8Bytes(code));

const memberRolesImpl = await ethers.deployContract('MemberRoles', [NXM_TOKEN_ADDRESS]);

const addresses = [memberRolesImpl].map(c => c.address);

await submitGovernanceProposal(
PROPOSAL_CATEGORIES.upgradeMultipleContracts, // upgradeMultipleContracts(bytes2[],address[])
defaultAbiCoder.encode(['bytes2[]', 'address[]'], [codes, addresses]),
this.abMembers,
this.governance,
);
});

it('should revert when a member with locked tokens switches or withdraws membership', async function () {
const address = NXM_WHALE_1;
await evm.impersonate(address);
await evm.setBalance(address, parseEther('1000'));
const signer = await getSigner(address);

const pendingRewards = await this.tokenController.getPendingRewards(address);

expect(pendingRewards).to.be.greaterThan('0');
await expect(this.memberRoles.connect(signer).withdrawMembership()).to.be.revertedWith('TC pendingRewards != 0');

const newAddress = '0x63E3fa77780B21ab89E036C660770Ec4134f13D0';
await expect(this.memberRoles.connect(signer).switchMembership(newAddress)).to.be.revertedWith(
'TC pendingRewards != 0',
);
});

it('should not revert when a member has no tokens locked', async function () {
const address = NXM_WHALE_2;

expect(await this.legacyPooledStaking.stakerDeposit(address)).to.be.equal(0);
expect(await this.legacyPooledStaking.stakerReward(address)).to.be.equal(0);

expect(await this.tokenController.tokensLocked(address, formatBytes32String('CLA'))).to.be.equal(0);
const { withdrawableAmount } = await this.tokenController.getWithdrawableCoverNotes(address);
expect(withdrawableAmount).to.be.equal('0');

expect(await this.tokenController.getPendingRewards(address)).to.be.equal('0');

const { amount: stakeAmount } = await this.assessment.stakeOf(address);
expect(stakeAmount).to.be.equal(0);

await evm.impersonate(address);
await evm.setBalance(address, parseEther('1000'));
const signer = await getSigner(address);
await this.memberRoles.connect(signer).withdrawMembership();
});
});
Loading

0 comments on commit 7dfe69f

Please sign in to comment.