diff --git a/src/CurveGaugeJoin.sol b/src/CurveGaugeJoin.sol new file mode 100644 index 0000000..e7f7406 --- /dev/null +++ b/src/CurveGaugeJoin.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2022 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity 0.6.12; + +import "./CropJoin.sol"; + +interface LiquidityGaugeLike { + function crv_token() external view returns (address); + function lp_token() external view returns (address); + function minter() external view returns (address); + function deposit(uint256) external; + function withdraw(uint256) external; +} + +interface LiquidityGaugeMinterLike { + function mint_for(address,address) external; +} + +// Join adapter for the Curve gauge contract +contract CurveGaugeJoinImp is CropJoinImp { + + LiquidityGaugeLike immutable public pool; + LiquidityGaugeMinterLike immutable public minter; + + /** + @param vat_ MCD_VAT DSS core accounting module + @param ilk_ Collateral type + @param gem_ The collateral LP token address + @param bonus_ The rewards token contract address. + @param pool_ The staking rewards pool. + */ + constructor( + address vat_, + bytes32 ilk_, + address gem_, + address bonus_, + address pool_ + ) + public + CropJoinImp(vat_, ilk_, gem_, bonus_) + { + // Sanity checks + require(LiquidityGaugeLike(pool_).crv_token() == bonus_, "CurveGaugeJoin/bonus-mismatch"); + require(LiquidityGaugeLike(pool_).lp_token() == gem_, "CurveGaugeJoin/gem-mismatch"); + + pool = LiquidityGaugeLike(pool_); + minter = LiquidityGaugeMinterLike(LiquidityGaugeLike(pool_).minter()); + } + + function init() external { + gem.approve(address(pool), type(uint256).max); + } + + function nav() public override view returns (uint256) { + return total; + } + + function crop() internal override returns (uint256) { + if (live == 1) { + minter.mint_for(address(pool), address(this)); + } + return super.crop(); + } + + function join(address urn, address usr, uint256 val) public override { + super.join(urn, usr, val); + if (val > 0) pool.deposit(val); + } + + function exit(address urn, address usr, uint256 val) public override { + if (live == 1) { + if (val > 0) pool.withdraw(val); + } + super.exit(urn, usr, val); + } + + function flee(address urn, address usr, uint256 val) public override { + if (live == 1) { + if (val > 0) pool.withdraw(val); + } + super.flee(urn, usr, val); + } + function cage() override public auth { + require(live == 1, "CurveGaugeJoin/not-live"); + + if (total > 0) pool.withdraw(total); + live = 0; + } + function uncage() external auth { + require(live == 0, "CurveGaugeJoin/live"); + + if (total > 0) pool.deposit(total); + live = 1; + } +} diff --git a/src/test/CurveGaugeJoin-integration.t.sol b/src/test/CurveGaugeJoin-integration.t.sol new file mode 100644 index 0000000..c7e1cb4 --- /dev/null +++ b/src/test/CurveGaugeJoin-integration.t.sol @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2021 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity 0.6.12; + +import "./TestBase.sol"; +import {ERC20, CropJoin, CurveGaugeJoinImp} from "../CurveGaugeJoin.sol"; +import {Cropper,CropperImp} from "../Cropper.sol"; + +interface VatLike { + function wards(address) external view returns (uint256); + function rely(address) external; + function hope(address) external; + function gem(bytes32, address) external view returns (uint256); + function flux(bytes32, address, address, uint256) external; +} + +interface LiquidityGaugeLike { + function crv_token() external view returns (address); + function lp_token() external view returns (address); + function minter() external view returns (address); + function deposit(uint256) external; + function withdraw(uint256) external; + function balanceOf(address) external view returns (uint256); +} + +contract Usr { + + Hevm hevm; + VatLike vat; + CurveGaugeJoinImp adapter; + CropperImp cropper; + ERC20 gem; + + constructor(Hevm hevm_, CurveGaugeJoinImp join_, CropperImp cropper_, ERC20 gem_) public { + hevm = hevm_; + adapter = join_; + cropper = cropper_; + gem = gem_; + + vat = VatLike(address(adapter.vat())); + + gem.approve(address(cropper), uint(-1)); + + cropper.getOrCreateProxy(address(this)); + } + + function join(address usr, uint wad) public { + cropper.join(address(adapter), usr, wad); + } + function join(uint wad) public { + cropper.join(address(adapter), address(this), wad); + } + function exit(address usr, uint wad) public { + cropper.exit(address(adapter), usr, wad); + } + function exit(uint wad) public { + cropper.exit(address(adapter), address(this), wad); + } + function proxy() public view returns (address) { + return cropper.proxy(address(this)); + } + function crops() public view returns (uint256) { + return adapter.crops(proxy()); + } + function stake() public view returns (uint256) { + return adapter.stake(proxy()); + } + function gems() public view returns (uint256) { + return adapter.vat().gem(adapter.ilk(), proxy()); + } + function tokens() public view returns (uint256) { + return adapter.gem().balanceOf(address(this)); + } + function bonus() public view returns (uint256) { + return adapter.bonus().balanceOf(address(this)); + } + function urn() public view returns (uint256, uint256) { + return adapter.vat().urns(adapter.ilk(), proxy()); + } + function reap() public { + cropper.join(address(adapter), address(this), 0); + } + function flee(uint256 amt) public { + cropper.flee(address(adapter), address(this), amt); + } + function giveTokens(ERC20 token, uint256 amount) external { + // Edge case - balance is already set for some reason + if (token.balanceOf(address(this)) == amount) return; + + // Solidity-style + for (uint256 i = 0; i < 20; i++) { + // Scan the storage for the balance storage slot + bytes32 prevValue = hevm.load( + address(token), + keccak256(abi.encode(address(this), uint256(i))) + ); + hevm.store( + address(token), + keccak256(abi.encode(address(this), uint256(i))), + bytes32(amount) + ); + if (token.balanceOf(address(this)) == amount) { + // Found it + return; + } else { + // Keep going after restoring the original value + hevm.store( + address(token), + keccak256(abi.encode(address(this), uint256(i))), + prevValue + ); + } + } + + // Vyper-style + for (uint256 i = 0; i < 20; i++) { + // Scan the storage for the balance storage slot + bytes32 prevValue = hevm.load( + address(token), + keccak256(abi.encode(uint256(i), address(this))) + ); + hevm.store( + address(token), + keccak256(abi.encode(uint256(i), address(this))), + bytes32(amount) + ); + if (token.balanceOf(address(this)) == amount) { + // Found it + return; + } else { + // Keep going after restoring the original value + hevm.store( + address(token), + keccak256(abi.encode(uint256(i), address(this))), + prevValue + ); + } + } + } + +} + +// Mainnet tests against gauge CRV rewards for Curve pools +contract CurveGaugeIntegrationTest is TestBase { + + ERC20 gem; + ERC20 bonus; + LiquidityGaugeLike pool; + VatLike vat; + bytes32 ilk = "3CRV-A"; + CurveGaugeJoinImp join; + CropperImp cropper; + + Usr user1; + Usr user2; + Usr user3; + + function setUp() public { + vat = VatLike(0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B); + gem = ERC20(0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490); // 3pool + bonus = ERC20(0xD533a949740bb3306d119CC777fa900bA034cd52); // CRV token + pool = LiquidityGaugeLike(0xbFcF63294aD7105dEa65aA58F8AE5BE2D9d0952A); + + // Give this contract admin access on the vat + giveAuthAccess(address(vat), address(this)); + + CropJoin baseJoin = new CropJoin(); + baseJoin.setImplementation(address(new CurveGaugeJoinImp(address(vat), ilk, address(gem), address(bonus), address(pool)))); + join = CurveGaugeJoinImp(address(baseJoin)); + join.init(); + Cropper baseCropper = new Cropper(); + baseCropper.setImplementation(address(new CropperImp(address(vat)))); + cropper = CropperImp(address(baseCropper)); + baseJoin.rely(address(cropper)); + baseJoin.deny(address(this)); // Only access should be through cropper + vat.rely(address(baseJoin)); + assertEq(address(join.pool()), address(pool)); + user1 = new Usr(hevm, join, cropper, gem); + user2 = new Usr(hevm, join, cropper, gem); + user3 = new Usr(hevm, join, cropper, gem); + + assertTrue(user1.proxy() != address(0)); + assertTrue(user2.proxy() != address(0)); + assertTrue(user3.proxy() != address(0)); + + user1.giveTokens(gem, 100 ether); + assertEq(gem.balanceOf(address(user1)), 100 ether); + user2.giveTokens(gem, 100 ether); + user3.giveTokens(gem, 100 ether); + } + + function test_join() public { + user1.join(10 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + } + + function test_join_rewards() public { + user1.join(10 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(bonus.balanceOf(address(join)), 0 ether); + assertEq(user1.bonus(), 0 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + user1.reap(); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(bonus.balanceOf(address(join)), 0 ether); + assertGt(user1.bonus(), 0 ether); + } + + function test_join_exit() public { + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + + user1.exit(5 ether); + + assertEq(pool.balanceOf(address(join)), 5 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 5 ether); + } + + function test_join_exit_rewards() public { + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + assertEq(user1.bonus(), 0 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + user1.exit(5 ether); + + assertEq(pool.balanceOf(address(join)), 5 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 5 ether); + assertGt(user1.bonus(), 0 ether); + } + + function test_join_exit_none() public { + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + + user1.exit(0 ether); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + } + + function test_flee() public { + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(user1.tokens(), origBal - 10 ether); + + user1.flee(10 ether); // Should exit without rewards + + assertEq(pool.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal); + assertEq(user1.bonus(), 0 ether); + } + + function test_cage() public { + // Re-auth the join to cage it + giveAuthAccess(address(join), address(this)); + + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + assertEq(CropJoin(address(join)).live(), 1); + + join.cage(); + + assertEq(CropJoin(address(join)).live(), 0); + assertEq(pool.balanceOf(address(join)), 0 ether); + assertEq(gem.balanceOf(address(join)), 10 ether); + assertEq(user1.tokens(), origBal - 10 ether); + + // Can get all my gems + user1.exit(10 ether); + + assertEq(pool.balanceOf(address(join)), 0 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal); + } + + function test_cage_uncage() public { + // Re-auth the join to cage it + giveAuthAccess(address(join), address(this)); + + uint256 origBal = user1.tokens(); + + user1.join(10 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + assertEq(CropJoin(address(join)).live(), 1); + + join.cage(); + + assertEq(CropJoin(address(join)).live(), 0); + assertEq(pool.balanceOf(address(join)), 0 ether); + assertEq(gem.balanceOf(address(join)), 10 ether); + assertEq(user1.tokens(), origBal - 10 ether); + + join.uncage(); + + assertEq(CropJoin(address(join)).live(), 1); + assertEq(pool.balanceOf(address(join)), 10 ether); + assertEq(gem.balanceOf(address(join)), 0 ether); + assertEq(user1.tokens(), origBal - 10 ether); + } + + function testFail_uncage() public { + // Re-auth the join to cage it + giveAuthAccess(address(join), address(this)); + + user1.join(10 ether); + + // Acquire some rewards + hevm.warp(now + 100 days); + + join.uncage(); // This will fail + } + + function testFail_cage_join() public { + // Re-auth the join to cage it + giveAuthAccess(address(join), address(this)); + + join.cage(); + + user1.join(10 ether); // This will fail + } + +} diff --git a/src/test/SushiJoinV1-integration.t.sol b/src/test/SushiJoinV1-integration.t.sol index 66d9458..6d98257 100644 --- a/src/test/SushiJoinV1-integration.t.sol +++ b/src/test/SushiJoinV1-integration.t.sol @@ -175,7 +175,8 @@ contract Usr { } // Mainnet tests against SushiSwap -contract SushiV1IntegrationTest is TestBase { +// NOTE: Set to abstract to disable tests +abstract contract SushiV1IntegrationTest is TestBase { SushiLPLike pair; ERC20 sushi; diff --git a/src/test/SushiJoinV2-integration.t.sol b/src/test/SushiJoinV2-integration.t.sol index 50ac448..34f9b0b 100644 --- a/src/test/SushiJoinV2-integration.t.sol +++ b/src/test/SushiJoinV2-integration.t.sol @@ -183,7 +183,8 @@ contract Usr { } // Mainnet tests against SushiSwap -contract SushiIntegrationTest is TestBase { +// NOTE: Set to abstract to disable tests +abstract contract SushiIntegrationTest is TestBase { SushiLPLike pair; ERC20 sushi;