diff --git a/README.md b/README.md index 37f1edf..4b7cdee 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,25 @@ ERC20 wrapper for COMP token that delegates voting power to another address. -Set the required environment variable before deploying: +Set the required environment variables before deploying: + +- `INITIAL_DELEGATEE`: address that receives COMP voting power delegated by the wrapper +- `WHITELISTED_DEPOSITOR`: single address initially allowed to deposit/wrap COMP Deploy on Ethereum: ``` -INITIAL_DELEGATEE=0xDelegateeAddress pnpm deploy:dcomp:ethereum +INITIAL_DELEGATEE=0xDelegateeAddress WHITELISTED_DEPOSITOR=0xDepositorAddress pnpm deploy:dcomp:ethereum ``` Override initial owner: ``` -INITIAL_DELEGATEE=0xDelegateeAddress OWNER=0xOwnerAddress pnpm run deploy:dcomp:ethereum +INITIAL_DELEGATEE=0xDelegateeAddress WHITELISTED_DEPOSITOR=0xDepositorAddress OWNER=0xOwnerAddress pnpm run deploy:dcomp:ethereum ``` Deploy to a local mainnet fork (running on `localhost`) with: ``` -INITIAL_DELEGATEE=0xDelegateeAddress pnpm deploy:dcomp:localhost +INITIAL_DELEGATEE=0xDelegateeAddress WHITELISTED_DEPOSITOR=0xDepositorAddress pnpm deploy:dcomp:localhost ``` diff --git a/contracts/DComp.sol b/contracts/DComp.sol index a387c3c..29eeb93 100644 --- a/contracts/DComp.sol +++ b/contracts/DComp.sol @@ -18,18 +18,45 @@ contract DComp is ERC20Wrapper, Ownable2Step { 0xc00e94Cb662C3520282E6f5717214004A7f26888; IComp internal immutable comp = IComp(COMP_ADDRESS); + /// @notice Tracks which addresses are allowed to deposit COMP. + /// @dev Withdrawals are never restricted by this whitelist, including when dCOMP is transferred, + /// when a depositor is later removed from the whitelist, or when COMP is deposited for another address via `depositFor`. + mapping(address => bool) public isDepositorWhitelisted; + + /// @notice Emitted when an address whitelist status changes + /// @param account Address whose whitelist status changed + /// @param isWhitelisted New whitelist status + event DepositorWhitelistStatusUpdated( + address indexed account, + bool isWhitelisted + ); + + modifier onlyWhitelisted() { + require( + isDepositorWhitelisted[msg.sender], + "Caller is not whitelisted" + ); + _; + } + /// @notice Constructs the dCOMP wrapper /// @param initialOwner Address of the initial owner /// @param initialDelegatee Address of the initial delegatee for COMP voting power + /// @param whitelistedDepositors Initial addresses allowed to deposit COMP constructor( address initialOwner, - address initialDelegatee + address initialDelegatee, + address[] memory whitelistedDepositors ) ERC20("dCOMP", "dCOMP") ERC20Wrapper(IERC20(COMP_ADDRESS)) Ownable(initialOwner) { _setDelegatee(initialDelegatee); + + for (uint256 i = 0; i < whitelistedDepositors.length; ++i) { + _setDepositorWhitelistStatus(whitelistedDepositors[i], true); + } } /// @notice Returns the current COMP delegatee for voting power held by this wrapper @@ -38,10 +65,21 @@ contract DComp is ERC20Wrapper, Ownable2Step { return comp.delegates(address(this)); } + /// @notice Wraps COMP tokens into dCOMP for a specific recipient at a 1:1 ratio + /// @param account Recipient of newly minted dCOMP + /// @param value Amount of COMP to deposit + /// @return success Whether wrapping succeeded + function depositFor( + address account, + uint256 value + ) public override onlyWhitelisted returns (bool) { + return super.depositFor(account, value); + } + /// @notice Wraps COMP tokens for the caller at a 1:1 ratio /// @param value Amount of COMP to deposit /// @return success Whether wrapping succeeded - function deposit(uint256 value) external returns (bool) { + function deposit(uint256 value) external onlyWhitelisted returns (bool) { return depositFor(msg.sender, value); } @@ -52,18 +90,47 @@ contract DComp is ERC20Wrapper, Ownable2Step { return withdrawTo(msg.sender, value); } - /** - * @notice Allows the owner to change who receives the aggregated voting power - * @param newDelegatee The new address to receive the COMP voting power - */ + /// @notice Updates depositor whitelist + /// @param addresses Addresses whose whitelist status will be updated + /// @param isAddressWhitelisted New whitelist status for each corresponding address + function updateWhitelistedDepositors( + address[] calldata addresses, + bool[] calldata isAddressWhitelisted + ) external onlyOwner { + require( + addresses.length == isAddressWhitelisted.length, + "Mismatched array lengths" + ); + + for (uint256 i = 0; i < addresses.length; ++i) { + _setDepositorWhitelistStatus(addresses[i], isAddressWhitelisted[i]); + } + } + + /// @notice Allows the owner to change who receives the aggregated voting power + /// @param newDelegatee The new address to receive the COMP voting power function setDelegatee(address newDelegatee) external onlyOwner { _setDelegatee(newDelegatee); } - /** - * @notice Updates delegated voting power recipient for COMP held by this wrapper - * @param newDelegatee New delegatee address - */ + /// @notice Updates whitelist status for a depositor + /// @param account Address whose whitelist status is being updated + /// @param isWhitelisted New whitelist status for the address + function _setDepositorWhitelistStatus( + address account, + bool isWhitelisted + ) internal { + require( + isDepositorWhitelisted[account] != isWhitelisted, + "No change in whitelist status" + ); + + isDepositorWhitelisted[account] = isWhitelisted; + emit DepositorWhitelistStatusUpdated(account, isWhitelisted); + } + + /// @notice Updates delegated voting power recipient for COMP held by this wrapper + /// @param newDelegatee New delegatee address function _setDelegatee(address newDelegatee) internal { comp.delegate(newDelegatee); } diff --git a/deploy/001_deploy_dComp.ts b/deploy/001_deploy_dComp.ts index c01afdd..a208faa 100644 --- a/deploy/001_deploy_dComp.ts +++ b/deploy/001_deploy_dComp.ts @@ -25,14 +25,18 @@ const deployDComp: DeployFunction = async ({ deployments, ethers, network, getUn const ownerAddress = process.env.OWNER ?? deployer; const initialDelegatee = process.env.INITIAL_DELEGATEE; + const whitelistedDepositor = process.env.WHITELISTED_DEPOSITOR; if (!initialDelegatee) { throw new Error('Set INITIAL_DELEGATEE in environment'); } + if (!whitelistedDepositor) { + throw new Error('Set WHITELISTED_DEPOSITOR in environment'); + } const deployment = await deployments.deploy('DComp', { from: deployer, - args: [ownerAddress, initialDelegatee], + args: [ownerAddress, initialDelegatee, [whitelistedDepositor]], log: true, }); @@ -41,10 +45,13 @@ const deployDComp: DeployFunction = async ({ deployments, ethers, network, getUn console.log(`dComp deployed at: ${deployment.address}`); console.log(`Owner: ${await dComp.owner()}`); console.log(`Initial delegatee: ${await dComp.delegatee()}`); + console.log(`Whitelisted depositor: ${whitelistedDepositor}`); if (network.name === 'ethereum') { console.log('Verify with:'); - console.log(`pnpm hardhat verify --network ethereum ${deployment.address} "${ownerAddress}" "${initialDelegatee}"`); + console.log( + `pnpm hardhat verify --network ethereum ${deployment.address} "${ownerAddress}" "${initialDelegatee}" '["${whitelistedDepositor}"]'` + ); } }; diff --git a/test/DComp.sol.ts b/test/DComp.sol.ts index a27740a..459b3c6 100644 --- a/test/DComp.sol.ts +++ b/test/DComp.sol.ts @@ -25,7 +25,7 @@ async function deploy() { const mockComp = await ethers.getContractAt('MockComp', COMP_ADDRESS, roles.deployer); const dCompFactory = await ethers.getContractFactory('DComp', roles.deployer); - const dComp = await dCompFactory.deploy(roles.owner.address, roles.delegateeA.address); + const dComp = await dCompFactory.deploy(roles.owner.address, roles.delegateeA.address, [roles.user.address]); const mintedAmount = ethers.parseEther('100'); await mockComp.mint(roles.user.address, mintedAmount); @@ -47,10 +47,17 @@ describe('DComp', function () { it('reverts if owner is zero', async function () { const { roles, dCompFactory } = await helpers.loadFixture(deploy); - await expect(dCompFactory.deploy(ethers.ZeroAddress, roles.delegateeA.address)) + await expect(dCompFactory.deploy(ethers.ZeroAddress, roles.delegateeA.address, [])) .to.be.revertedWithCustomError(dCompFactory, 'OwnableInvalidOwner') .withArgs(ethers.ZeroAddress); }); + + it('seeds initial whitelisted depositors from constructor', async function () { + const { roles, dComp } = await helpers.loadFixture(deploy); + + expect(await dComp.isDepositorWhitelisted(roles.user.address)).to.equal(true); + expect(await dComp.isDepositorWhitelisted(roles.otherUser.address)).to.equal(false); + }); }); describe('deposit/withdraw helpers', function () { @@ -96,6 +103,115 @@ describe('DComp', function () { const { roles, dComp } = await helpers.loadFixture(deploy); await expect(dComp.connect(roles.user).withdraw(1)).to.be.reverted; }); + + it('reverts deposit for non-whitelisted caller', async function () { + const { roles, dComp, mockComp } = await helpers.loadFixture(deploy); + const amount = ethers.parseEther('1'); + + await mockComp.connect(roles.otherUser).approve(await dComp.getAddress(), amount); + await expect(dComp.connect(roles.otherUser).deposit(amount)).to.be.revertedWith('Caller is not whitelisted'); + await expect(dComp.connect(roles.otherUser).depositFor(roles.otherUser.address, amount)).to.be.revertedWith( + 'Caller is not whitelisted' + ); + }); + + it('allows non-whitelisted user to withdraw funds', async function () { + const { roles, dComp, mockComp, mintedAmount } = await helpers.loadFixture(deploy); + const amount = ethers.parseEther('7'); + + await mockComp.connect(roles.user).approve(await dComp.getAddress(), amount); + await dComp.connect(roles.user).depositFor(roles.otherUser.address, amount); + + expect(await dComp.isDepositorWhitelisted(roles.otherUser.address)).to.equal(false); + expect(await dComp.balanceOf(roles.otherUser.address)).to.equal(amount); + + await expect(dComp.connect(roles.otherUser).withdraw(amount)) + .to.emit(dComp, 'Transfer') + .withArgs(roles.otherUser.address, ethers.ZeroAddress, amount); + + expect(await dComp.balanceOf(roles.otherUser.address)).to.equal(0); + expect(await mockComp.balanceOf(roles.otherUser.address)).to.equal(amount); + expect(await mockComp.balanceOf(roles.user.address)).to.equal(mintedAmount - amount); + }); + + it('allows user to withdraw after being removed from whitelist', async function () { + const { roles, dComp, mockComp, mintedAmount } = await helpers.loadFixture(deploy); + const amount = ethers.parseEther('9'); + + await mockComp.connect(roles.user).approve(await dComp.getAddress(), amount); + await dComp.connect(roles.user).deposit(amount); + + await expect(dComp.connect(roles.owner).updateWhitelistedDepositors([roles.user.address], [false])) + .to.emit(dComp, 'DepositorWhitelistStatusUpdated') + .withArgs(roles.user.address, false); + + expect(await dComp.isDepositorWhitelisted(roles.user.address)).to.equal(false); + + await expect(dComp.connect(roles.user).withdraw(amount)) + .to.emit(dComp, 'Transfer') + .withArgs(roles.user.address, ethers.ZeroAddress, amount); + + expect(await dComp.balanceOf(roles.user.address)).to.equal(0); + expect(await mockComp.balanceOf(roles.user.address)).to.equal(mintedAmount); + }); + + it('allows non-whitelisted recipient to withdraw transferred dCOMP', async function () { + const { roles, dComp, mockComp, mintedAmount } = await helpers.loadFixture(deploy); + const amount = ethers.parseEther('11'); + + await mockComp.connect(roles.user).approve(await dComp.getAddress(), amount); + await dComp.connect(roles.user).deposit(amount); + + await dComp.connect(roles.user).transfer(roles.otherUser.address, amount); + expect(await dComp.isDepositorWhitelisted(roles.otherUser.address)).to.equal(false); + expect(await dComp.balanceOf(roles.otherUser.address)).to.equal(amount); + + await expect(dComp.connect(roles.otherUser).withdraw(amount)) + .to.emit(dComp, 'Transfer') + .withArgs(roles.otherUser.address, ethers.ZeroAddress, amount); + + expect(await dComp.balanceOf(roles.otherUser.address)).to.equal(0); + expect(await mockComp.balanceOf(roles.otherUser.address)).to.equal(amount); + expect(await mockComp.balanceOf(roles.user.address)).to.equal(mintedAmount - amount); + }); + }); + + describe('depositor whitelist', function () { + it('owner updates whitelist and emits event on change', async function () { + const { roles, dComp } = await helpers.loadFixture(deploy); + + expect(await dComp.isDepositorWhitelisted(roles.otherUser.address)).to.equal(false); + + await expect(dComp.connect(roles.owner).updateWhitelistedDepositors([roles.otherUser.address], [true])) + .to.emit(dComp, 'DepositorWhitelistStatusUpdated') + .withArgs(roles.otherUser.address, true); + + expect(await dComp.isDepositorWhitelisted(roles.otherUser.address)).to.equal(true); + }); + + it('reverts for no-op whitelist update', async function () { + const { roles, dComp } = await helpers.loadFixture(deploy); + + await expect( + dComp.connect(roles.owner).updateWhitelistedDepositors([roles.user.address], [true]) + ).to.be.revertedWith('No change in whitelist status'); + }); + + it('reverts whitelist update from non-owner', async function () { + const { roles, dComp } = await helpers.loadFixture(deploy); + + await expect(dComp.connect(roles.user).updateWhitelistedDepositors([roles.otherUser.address], [true])) + .to.be.revertedWithCustomError(dComp, 'OwnableUnauthorizedAccount') + .withArgs(roles.user.address); + }); + + it('reverts on mismatched whitelist update array lengths', async function () { + const { roles, dComp } = await helpers.loadFixture(deploy); + + await expect( + dComp.connect(roles.owner).updateWhitelistedDepositors([roles.user.address], [true, false]) + ).to.be.revertedWith('Mismatched array lengths'); + }); }); describe('erc20Wrapper inherited paths', function () {