Skip to content

Commit 3681613

Browse files
authored
Add ability to sweep funds from Bulker (#413)
This PR adds an admin to the Bulker contract and allows the admin to sweep ERC20 tokens and ETH out of the contract to handle accidentally sent funds. This addresses part of #376's concern This PR also removes the getAmount helper used in the Bulker to calculate the max transfer/withdraw values, as max transfer/withdraw are now supported natively in the protocol.
1 parent dab6b0c commit 3681613

4 files changed

Lines changed: 110 additions & 24 deletions

File tree

contracts/Bulker.sol

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
pragma solidity 0.8.13;
33

44
import "./CometInterface.sol";
5+
import "./ERC20.sol";
56
import "./IWETH9.sol";
67

78
contract Bulker {
89
/** General configuration constants **/
9-
address payable public immutable weth;
10+
address public immutable admin;
1011
address public immutable comet;
12+
address payable public immutable weth;
1113
address public immutable baseToken;
1214

1315
/** Actions **/
@@ -20,8 +22,10 @@ contract Bulker {
2022
/** Custom errors **/
2123
error InvalidArgument();
2224
error FailedToSendEther();
25+
error Unauthorized();
2326

24-
constructor(address comet_, address payable weth_) {
27+
constructor(address admin_, address comet_, address payable weth_) {
28+
admin = admin_;
2529
comet = comet_;
2630
weth = weth_;
2731
baseToken = CometInterface(comet_).baseToken();
@@ -32,6 +36,30 @@ contract Bulker {
3236
*/
3337
receive() external payable {}
3438

39+
/**
40+
* @notice A public function to sweep accidental ERC-20 transfers to this contract. Tokens are sent to admin (Timelock)
41+
* @param recipient The address that will receive the swept funds
42+
* @param asset The address of the ERC-20 token to sweep
43+
*/
44+
function sweepToken(address recipient, ERC20 asset) external {
45+
if (msg.sender != admin) revert Unauthorized();
46+
47+
uint256 balance = asset.balanceOf(address(this));
48+
asset.transfer(recipient, balance);
49+
}
50+
51+
/**
52+
* @notice A public function to sweep accidental ETH transfers to this contract. Tokens are sent to admin (Timelock)
53+
* @param recipient The address that will receive the swept funds
54+
*/
55+
function sweepEth(address recipient) external {
56+
if (msg.sender != admin) revert Unauthorized();
57+
58+
uint256 balance = address(this).balance;
59+
(bool success, ) = recipient.call{ value: balance }("");
60+
if (!success) revert FailedToSendEther();
61+
}
62+
3563
/**
3664
* @notice Executes a list of actions in order
3765
* @param actions The list of actions to execute in order
@@ -90,40 +118,23 @@ contract Bulker {
90118
* @notice Transfers an asset to a user in Comet
91119
*/
92120
function transferTo(address to, address asset, uint amount) internal {
93-
amount = getAmount(asset, amount);
94121
CometInterface(comet).transferAssetFrom(msg.sender, to, asset, amount);
95122
}
96123

97124
/**
98125
* @notice Withdraws an asset to a user in Comet
99126
*/
100127
function withdrawTo(address to, address asset, uint amount) internal {
101-
amount = getAmount(asset, amount);
102128
CometInterface(comet).withdrawFrom(msg.sender, to, asset, amount);
103129
}
104130

105131
/**
106132
* @notice Withdraws WETH from Comet to a user after unwrapping it to ETH
107133
*/
108134
function withdrawEthTo(address to, uint amount) internal {
109-
amount = getAmount(weth, amount);
110135
CometInterface(comet).withdrawFrom(msg.sender, address(this), weth, amount);
111136
IWETH9(weth).withdraw(amount);
112137
(bool success, ) = to.call{ value: amount }("");
113138
if (!success) revert FailedToSendEther();
114139
}
115-
116-
/**
117-
* @notice Handles the max transfer/withdraw case so that no dust is left in the protocol.
118-
*/
119-
function getAmount(address asset, uint amount) internal view returns (uint) {
120-
if (amount == type(uint256).max) {
121-
if (asset == baseToken) {
122-
return CometInterface(comet).balanceOf(msg.sender);
123-
} else {
124-
return CometInterface(comet).collateralBalanceOf(msg.sender, asset);
125-
}
126-
}
127-
return amount;
128-
}
129140
}

deployments/kovan/migrations/1651257129_bulker_and_rewards.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ migration<Vars>('1651257129_bulker_and_rewards', {
1616
const timelock = await deploymentManager.contract('timelock') as SimpleTimelock;
1717

1818
// Deploy new Bulker and Rewards contracts
19-
const newBulker = await deploymentManager.deploy<Bulker, Bulker__factory, [string, string]>(
19+
const newBulker = await deploymentManager.deploy<Bulker, Bulker__factory, [string, string, string]>(
2020
'Bulker.sol',
21-
[comet.address, weth.address]
21+
[timelock.address, comet.address, weth.address]
2222
);
2323

2424
const newRewards = await deploymentManager.deploy<CometRewards, CometRewards__factory, [string]>(

test/bulker-test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,77 @@ describe('bulker', function () {
329329
await expect(bulker.connect(alice).invoke([await bulker.ACTION_WITHDRAW_ETH()], [withdrawEthCalldata]))
330330
.to.be.reverted; // Should revert with "custom error 'Unauthorized()'"
331331
});
332+
333+
describe('admin functions', function () {
334+
it('sweep ERC20 token', async () => {
335+
const protocol = await makeProtocol({});
336+
const { comet, governor, tokens: { USDC, WETH }, users: [alice] } = protocol;
337+
const bulkerInfo = await makeBulker({ admin: governor, comet: comet.address, weth: WETH.address });
338+
const { bulker } = bulkerInfo;
339+
340+
// Alice "accidentally" sends 10 USDC to the Bulker
341+
const transferAmount = exp(10, 6);
342+
await USDC.allocateTo(alice.address, transferAmount);
343+
await USDC.connect(alice).transfer(bulker.address, transferAmount);
344+
345+
const oldBulkerBalance = await USDC.balanceOf(bulker.address);
346+
const oldGovBalance = await USDC.balanceOf(governor.address);
347+
348+
// Governor sweeps tokens
349+
await bulker.connect(governor).sweepToken(governor.address, USDC.address);
350+
351+
const newBulkerBalance = await USDC.balanceOf(bulker.address);
352+
const newGovBalance = await USDC.balanceOf(governor.address);
353+
354+
expect(newBulkerBalance.sub(oldBulkerBalance)).to.be.equal(-transferAmount);
355+
expect(newGovBalance.sub(oldGovBalance)).to.be.equal(transferAmount);
356+
});
357+
358+
it('sweep ETH', async () => {
359+
const protocol = await makeProtocol({});
360+
const { comet, governor, tokens: { WETH }, users: [alice] } = protocol;
361+
const bulkerInfo = await makeBulker({ admin: governor, comet: comet.address, weth: WETH.address });
362+
const { bulker } = bulkerInfo;
363+
364+
// Alice "accidentally" sends 1 ETH to the Bulker
365+
const transferAmount = exp(1, 18);
366+
await alice.sendTransaction({ to: bulker.address, value: transferAmount });
367+
368+
const oldBulkerBalance = await ethers.provider.getBalance(bulker.address);
369+
const oldGovBalance = await ethers.provider.getBalance(governor.address);
370+
371+
// Governor sweeps ETH
372+
const txn = await wait(bulker.connect(governor).sweepEth(governor.address));
373+
374+
const newBulkerBalance = await ethers.provider.getBalance(bulker.address);
375+
const newGovBalance = await ethers.provider.getBalance(governor.address);
376+
377+
expect(newBulkerBalance.sub(oldBulkerBalance)).to.be.equal(-transferAmount);
378+
expect(newGovBalance.sub(oldGovBalance)).to.be.equal(transferAmount - getGasUsed(txn));
379+
});
380+
381+
it('reverts if sweepToken is called by non-admin', async () => {
382+
const protocol = await makeProtocol({});
383+
const { comet, governor, tokens: { USDC, WETH }, users: [alice] } = protocol;
384+
const bulkerInfo = await makeBulker({ admin: governor, comet: comet.address, weth: WETH.address });
385+
const { bulker } = bulkerInfo;
386+
387+
// Alice sweeps tokens
388+
await expect(bulker.connect(alice).sweepToken(governor.address, USDC.address))
389+
.to.be.revertedWith("custom error 'Unauthorized()'");
390+
});
391+
392+
it('reverts if sweepEth is called by non-admin', async () => {
393+
const protocol = await makeProtocol({});
394+
const { comet, governor, tokens: { WETH }, users: [alice] } = protocol;
395+
const bulkerInfo = await makeBulker({ admin: governor, comet: comet.address, weth: WETH.address });
396+
const { bulker } = bulkerInfo;
397+
398+
// Alice sweeps ETH
399+
await expect(bulker.connect(alice).sweepEth(governor.address))
400+
.to.be.revertedWith("custom error 'Unauthorized()'");
401+
});
402+
});
332403
});
333404

334405
describe('bulker multiple actions', function () {

test/helpers.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ export type Rewards = {
120120
};
121121

122122
export type BulkerOpts = {
123-
comet: string;
124-
weth: string;
123+
admin?: SignerWithAddress;
124+
comet?: string;
125+
weth?: string;
125126
};
126127

127128
export type BulkerInfo = {
@@ -466,11 +467,14 @@ export async function makeRewards(opts: RewardsOpts = {}): Promise<Rewards> {
466467
}
467468

468469
export async function makeBulker(opts: BulkerOpts): Promise<BulkerInfo> {
470+
const signers = await ethers.getSigners();
471+
472+
const admin = opts.admin || signers[0];
469473
const comet = opts.comet;
470474
const weth = opts.weth;
471475

472476
const BulkerFactory = (await ethers.getContractFactory('Bulker')) as Bulker__factory;
473-
const bulker = await BulkerFactory.deploy(comet, weth);
477+
const bulker = await BulkerFactory.deploy(admin.address, comet, weth);
474478
await bulker.deployed();
475479

476480
return {

0 commit comments

Comments
 (0)