Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
87 changes: 77 additions & 10 deletions contracts/DComp.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand All @@ -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);
}
Expand Down
11 changes: 9 additions & 2 deletions deploy/001_deploy_dComp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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}"]'`
);
}
};

Expand Down
120 changes: 118 additions & 2 deletions test/DComp.sol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 () {
Expand Down Expand Up @@ -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 () {
Expand Down