diff --git a/Readme.md b/Readme.md index 9a36611..5bda2a0 100644 --- a/Readme.md +++ b/Readme.md @@ -20,4 +20,4 @@ npx ts-node .\scripts\prepare-ethers.ts ``` # Testing -Run `forge test --fork-url https://mainnet.infura.io/v3/6f4f43507fa24302a651b52073c98d8a`. +Run `forge test --match-path test/YieldLever.t.sol --fork-url `. diff --git a/contracts/YieldLever.sol b/contracts/YieldLever.sol index ab21ded..2f7bfa4 100644 --- a/contracts/YieldLever.sol +++ b/contracts/YieldLever.sol @@ -10,6 +10,8 @@ pragma solidity ^0.8.13; +import "forge-std/console.sol"; + struct Vault { address owner; bytes6 seriesId; // Each vault is related to only one series, which also determines the underlying. @@ -44,6 +46,7 @@ struct SpotOracle { interface YieldLadle { function pools(bytes6 seriesId) external view returns (address); + function joins(bytes6 ilkId) external view returns (address); function build(bytes6 seriesId, bytes6 ilkId, uint8 salt) external payable returns(bytes12, Vault memory); @@ -81,7 +84,6 @@ interface yVault is IERC20 { } interface IToken { - function loanTokenAddress() external view returns (address); function flashBorrow( uint256 borrowAmount, address borrower, diff --git a/contracts/YieldLeverRinkeby.sol b/contracts/YieldLeverRinkeby.sol new file mode 100644 index 0000000..671e9b3 --- /dev/null +++ b/contracts/YieldLeverRinkeby.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: UNLICENSED + +/// # Security of this contract +/// This contract owns nothing between transactions. Any funds or vaults owned +/// by it may very well be extractable. The security comes from the fact that +/// after any interaction, the user (re-)obtains ownership of their assets. In +/// `doInvest`, this happens by transferring the vault at the end. In `unwind` +/// the contract can actually take ownership of a vault, but only when called +/// by the owner of the vault in the first place. + +pragma solidity ^0.8.13; + +import "forge-std/console.sol"; + +struct Vault { + address owner; + bytes6 seriesId; // Each vault is related to only one series, which also determines the underlying. + bytes6 ilkId; // Asset accepted as collateral +} + +interface IFYToken {} + +struct Series { + IFYToken fyToken; // Redeemable token for the series. + bytes6 baseId; // Asset received on redemption. + uint32 maturity; // Unix time at which redemption becomes possible. +} + +struct Balances { + uint128 art; // Debt amount + uint128 ink; // Collateral amount +} + +struct Debt { + uint96 max; // Maximum debt accepted for a given underlying, across all series + uint24 min; // Minimum debt accepted for a given underlying, across all series + uint8 dec; // Multiplying factor (10**dec) for max and min + uint128 sum; // Current debt for a given underlying, across all series +} + +struct SpotOracle { + address oracle; // Address for the spot price oracle + uint32 ratio; // Collateralization ratio to multiply the price for + // bytes8 free +} + +interface YieldLadle { + function pools(bytes6 seriesId) external view returns (address); + function joins(bytes6 ilkId) external view returns (address); + function build(bytes6 seriesId, bytes6 ilkId, uint8 salt) + external payable + returns(bytes12, Vault memory); + function serve(bytes12 vaultId_, address to, uint128 ink, uint128 base, uint128 max) + external payable + returns (uint128 art); + function repay(bytes12 vaultId_, address to, int128 ink, uint128 min) + external payable + returns (uint128 art); + function repayVault(bytes12 vaultId_, address to, int128 ink, uint128 max) + external payable + returns (uint128 base); + function close(bytes12 vaultId_, address to, int128 ink, int128 art) + external payable + returns (uint128 base); + function give(bytes12 vaultId_, address receiver) + external payable + returns(Vault memory vault); +} + +interface IERC20 { + event Approval(address indexed owner, address indexed spender, uint value); + event Transfer(address indexed from, address indexed to, uint value); + + function balanceOf(address owner) external view returns (uint); + + function approve(address spender, uint value) external returns (bool); + function transfer(address to, uint value) external returns (bool); + function transferFrom(address from, address to, uint value) external returns (bool); +} + +interface YVault is IERC20 { + function deposit(uint amount, address to) external returns (uint256); + function withdraw() external returns (uint); +} + +interface Cauldron { + function series(bytes6 seriesId) external view returns (Series memory); + function vaults(bytes12 vaultId) external view returns (Vault memory); + function balances(bytes12 vaultId) external view returns (Balances memory); + function debt(bytes6 baseId, bytes6 ilkId) external view returns (Debt memory); + function debtToBase(bytes6 seriesId, uint128 art) + external + returns (uint128 base); + function give(bytes12 vaultId, address receiver) + external + returns(Vault memory vault); + function spotOracles(bytes6 baseId, bytes6 ilkId) external view returns (SpotOracle memory); + event VaultGiven(bytes12 indexed vaultId, address indexed receiver); +} + +interface RinkebyToken is IERC20 { + function mint(address _to, uint256 _amount) external; +} + +contract YieldLever { + bytes6 ilkId; // for yvUSDC + YVault yvUSDC; + RinkebyToken usdc; + address usdcJoin; + YieldLadle ladle; + address yvUSDCJoin; + Cauldron cauldron; + + bytes6 constant usdcId = bytes6(bytes32("02")); + + /// @dev YieldLever is not expected to hold any USDC, so approve transfers for any amount. + constructor( + bytes6 ilkId_, + YVault yvUSDC_, + RinkebyToken usdc_, + address usdcJoin_, + YieldLadle ladle_, + address yvUSDCJoin_, + Cauldron cauldron_ + ) { + ilkId = ilkId_; + yvUSDC = yvUSDC_; + usdc = usdc_; + usdcJoin = usdcJoin_; + ladle = ladle_; + yvUSDCJoin = yvUSDCJoin_; + cauldron = cauldron_; + usdc.approve(address(0x1), type(uint256).max); + usdc.approve(address(yvUSDC), type(uint256).max); + } + + /// @notice Invest `baseAmount` and borrow an additional `borrowAmount`. + /// Use this to obtain a maximum of `maxFyAmount` to repay the flash loan. + /// The end goal is to have a debt of `borrowAmount`, but earn interest on + /// the entire collateral, including the borrowed part. + /// @param baseAmount - The amount to invest from your own funds. + /// @param borrowAmount - The extra amount to borrow. This is immediately + /// repaid, so setting this to 3 times baseAmount will incur a debt of 75% + /// of the collateral. + /// @param maxFyAmount - The maximum amount of fyTokens to sell. Should be + /// enough to cover the flash loan. + /// @param seriesId - The series Id to invest in. For example, 0x303230360000 + /// for FYUSDC06LP. + /// @return vauldId - The ID of the created vault. + function invest( + uint256 baseAmount, + uint128 borrowAmount, + uint128 maxFyAmount, + bytes6 seriesId + ) external returns (bytes12) { + // Check that it is a USDC series. + require(cauldron.series(seriesId).baseId == usdcId); + + // Take USDC from the msg sender. We know USDC reverts on failure. + // In future iterations, YieldLever can integrate with the Ladle by using + // USDC it received previously in the same transaction, if available. + usdc.transferFrom(msg.sender, address(this), baseAmount); + // Build a Yield Vault + (bytes12 vaultId, ) = ladle.build(seriesId, ilkId, 0); + + uint256 investAmount = baseAmount + borrowAmount; + + // Flash borrow USDC + usdc.mint(address(this), borrowAmount); + doInvest(investAmount, borrowAmount, maxFyAmount, vaultId); + + // Finally, give the vault to the sender + cauldron.give(vaultId, msg.sender); + + return vaultId; + } + + /// @notice This function is called inside the flash loan and handles the + /// actual investment. + /// @param borrowAmount - The amount borrowed using a flash loan. + /// @param maxFyAmount - The maximum amount of fyTokens to sell. + /// @param vaultId - The vault id to invest in. + /// @dev Calling this function outside a flash loan achieves nothing, + /// since the contract needs to have assets and own the vault it's borrowing from. + function doInvest( + uint256 investAmount, + uint128 borrowAmount, + uint128 maxFyAmount, + bytes12 vaultId + ) public { + // Deposit USDC and obtain yvUSDC. + // Send it to the yvUSDCJoin to use as collateral in the vault. + // Returned is the amount of yvUSDC obtained. + uint128 yvUSDCBalance = uint128(yvUSDC.deposit(investAmount, yvUSDCJoin)); + + // Add collateral to the Yield vault. + // Borrow enough to repay the flash loan. + // Transfer it to `address(iUSDC)` to repay the loan. + ladle.serve(vaultId, address(0x1), yvUSDCBalance, borrowAmount, maxFyAmount); + } + + /// @notice Empty a vault. + /// @param vaultId - The id of the vault that should be emptied. + /// @param maxAmount - The maximum amount of USDC to borrow. If past + /// maturity, this parameter is unused as the amount can be determined + /// precisely. + /// @param pool - The pool to deposit USDC into. This can be obtained via the + /// seriesId, and calling `address pool = ladle.pools(seriesId);` + /// @param ink - The amount of collateral in the vault. Together with art, + /// this value can be obtained using `cauldron.balances(vaultId);`, which + /// will return an object containing both `art` and `ink`. + /// @param art - The amount of debt taken from the vault. + function unwind(bytes12 vaultId, uint256 maxAmount, address pool, uint128 ink, uint128 art, bytes6 seriesId) external { + Vault memory vault_ = cauldron.vaults(vaultId); + Series memory series_ = cauldron.series(seriesId); + + // Test that the caller is the owner of the vault. + // This is important as we will take the vault from the user. + require(vault_.owner == msg.sender); + + // Give the vault to the contract + cauldron.give(vaultId, address(this)); + + if (uint32(block.timestamp) < series_.maturity) { + // Series is not past maturity + // Borrow to repay debt, move directly to the pool. + usdc.mint(address(this), maxAmount); + doRepay(msg.sender, vaultId, maxAmount, ink); + } else { + // Series is past maturity, borrow and move directly to collateral pool + uint128 base = cauldron.debtToBase(seriesId, art); + usdc.mint(address(this), base); + doClose(msg.sender, vaultId, base, ink, art); + } + + // Give the vault back to the sender, just in case there is anything left + cauldron.give(vaultId, msg.sender); + } + + /// @notice Repay a vault after having borrowed a suitable amount using a + /// flash loan. Will only succeed if the pool hasn't reached its expiration + /// date yet. + /// @param owner - The address of the owner. This is the address that will be + /// used to obtain certain parameters, and it is also the destination for + /// the profit that was obtained. + /// @param vaultId - The vault id to repay. + /// @dev Calling this function outside a flash loan achieves nothing, since + /// the contract needs to own the vault it's getting collateral from. + function doRepay(address owner, bytes12 vaultId, uint256 borrowAmount, uint128 ink) public { + // Repay Yield vault debt + ladle.repayVault(vaultId, address(this), -int128(ink), uint128(borrowAmount)); + + // withdraw from yvUSDC + yvUSDC.withdraw(); + // Repay the flash loan + usdc.transfer(address(0x1), borrowAmount); + // Send the remaining USDC balance to the user. + usdc.transfer(owner, usdc.balanceOf(address(this))); + } + + /// @notice Close a vault that has already reached its expiration date. + /// @param owner - The address of the owner. This is the address that will be + /// used to obtain certain parameters, and it is also the destination for + /// the profit that was obtained. + /// @param vaultId - The vault id to repay. + /// @param base - The size of the debt in USDC. + /// @dev Calling this function outside a flash loan achieves nothing, since + /// the contract needs to own the vault it's getting collateral from. + function doClose(address owner, bytes12 vaultId, uint128 base, uint128 ink, uint128 art) public { + // Close the vault + ladle.close(vaultId, address(this), -int128(ink), -int128(art)); + + // Withdraw from yvUSDC + yvUSDC.withdraw(); + // Repay flash loan + usdc.transfer(address(0x1), base); + // Send the remainder to user + usdc.transfer(owner, usdc.balanceOf(address(this))); + } +} diff --git a/test/YieldLever.t.sol b/test/YieldLever.t.sol index 58add95..abf39ef 100644 --- a/test/YieldLever.t.sol +++ b/test/YieldLever.t.sol @@ -83,9 +83,9 @@ contract YieldLeverTest is Test { YieldLadle constant ladle = YieldLadle(0x6cB18fF2A33e981D1e38A663Ca056c0a5265066A); Pool pool; + bytes6 constant usdcId = 0x303200000000; bytes6 constant yvUsdcIlkId = 0x303900000000; bytes6 constant seriesId = 0x303230360000; - bytes6 constant ILK_ID = 0x303900000000; constructor() { pool = Pool(ladle.pools(seriesId)); @@ -97,14 +97,14 @@ contract YieldLeverTest is Test { function setUp() public { yieldLever = new YieldLever( - 0x303900000000, + yvUsdcIlkId, yVault(0xa354F35829Ae975e850e23e9615b11Da1B3dC4DE), IToken(0x32E4c68B3A4a813b710595AebA7f6B7604Ab9c15), - IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48), - address(0x0d9A1A773be5a83eEbda23bf98efB8585C3ae4f4), - YieldLadle(0x6cB18fF2A33e981D1e38A663Ca056c0a5265066A), - address(0x403ae7384E89b086Ea2935d5fAFed07465242B38), - Cauldron(0xc88191F8cb8e6D4a668B047c1C8503432c3Ca867) + usdc, + ladle.joins(usdcId), + ladle, + ladle.joins(yvUsdcIlkId), + cauldron ); helperContract = new HelperContract(); helperContract.grantYieldLeverAccess(address(yieldLever)); diff --git a/test/YieldLeverRinkeby.t.sol b/test/YieldLeverRinkeby.t.sol new file mode 100644 index 0000000..8471ed0 --- /dev/null +++ b/test/YieldLeverRinkeby.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "contracts/YieldLeverRinkeby.sol"; + +interface AccessControl { + function grantRole(bytes4 role, address account) external; +} + +interface Pool { + function buyBasePreview(uint128 tokenOut) + external view + returns(uint128); + function buyFYTokenPreview(uint128 fyTokenOut) + external view + returns(uint128); + function getBaseBalance() + external view + returns(uint112); +} + +contract HelperContract is Test { + RinkebyToken constant usdc = RinkebyToken(0xf4aDD9708888e654C042613843f413A8d6aDB8Fe); + AccessControl constant cauldron = AccessControl(0x84EFA55faA9d774B4846c7a51c1C470232DFE50f); + + constructor() { + vm.label(address(usdc), "USDC"); + vm.label(address(cauldron), "Cauldron"); + } + + function buyUsdc(uint amountOut, address receiver) external { + usdc.mint(receiver, amountOut); + } + + function grantYieldLeverAccess(address yieldLeverAddress) external { + bytes4 sig = bytes4(abi.encodeWithSignature("give(bytes12,address)")); + + // Call as the Yield Admin contract + vm.prank(0x1BE7654F12BFC3ea2C53d05E512033d5a634c2b5); + cauldron.grantRole(sig, yieldLeverAddress); + } + + receive() payable external {} + + function testBuyUsdc() external { + uint amount = 5_000_000; + this.buyUsdc(amount, address(this)); + } +} + +contract YieldLeverTest is Test { + YieldLever yieldLever; + HelperContract helperContract; + + RinkebyToken constant usdc = RinkebyToken(0xf4aDD9708888e654C042613843f413A8d6aDB8Fe); + Cauldron constant cauldron = Cauldron(0x84EFA55faA9d774B4846c7a51c1C470232DFE50f); + YieldLadle constant ladle = YieldLadle(0xAE53c79926cb960feA17aF2369DE10938f5D0d52); + YVault constant yVault = YVault(0x2381d065e83DDdBaCD9B4955d49D5a858AE5957B); + Pool pool; + + bytes6 constant usdcId = 0x303200000000; + bytes6 constant yvUsdcIlkId = 0x303900000000; + bytes6 constant seriesId = 0x303230370000; + + constructor() { + pool = Pool(ladle.pools(seriesId)); + } + + function setUp() public { + yieldLever = new YieldLever( + yvUsdcIlkId, + yVault, + usdc, + ladle.joins(usdcId), + ladle, + ladle.joins(yvUsdcIlkId), + cauldron + ); + helperContract = new HelperContract(); + helperContract.grantYieldLeverAccess(address(yieldLever)); + } + + /// Test the creation of a Vault. + function testBuildVault() public { + uint128 collateral = 5_000_000_000; + uint128 borrowed = 1_000_000_000; + // Slippage, in tenths of a percent, 1 being no slippage + uint128 slippage = 1_001; + + helperContract.buyUsdc(collateral, address(this)); + usdc.approve(address(yieldLever), collateral); + + uint128 maxFy = (pool.buyBasePreview(borrowed) * slippage) / 1000; + + bytes12 vaultId = yieldLever.invest(collateral, borrowed, maxFy, seriesId); + + // Test some parameters + Vault memory vault = cauldron.vaults(vaultId); + assertEq(vault.owner, address(this)); + assertEq(vault.seriesId, seriesId); + assertEq(vault.ilkId, yvUsdcIlkId); + + Balances memory balances = cauldron.balances(vaultId); + assertGt(balances.ink, 0); + assertGt(balances.art, 0); + } + + function investAndUnwind(uint128 collateral, uint128 borrowed, uint128 slippage) public { + helperContract.buyUsdc(collateral, address(this)); + usdc.approve(address(yieldLever), collateral); + uint128 maxFy = (pool.buyBasePreview(borrowed) * slippage) / 1000; + bytes12 vaultId = yieldLever.invest(collateral, borrowed, maxFy, seriesId); + + // Unwind + Balances memory balances = cauldron.balances(vaultId); + uint128 maxAmount = (pool.buyFYTokenPreview(balances.art) * slippage) / 1000; + yieldLever.unwind(vaultId, maxAmount, address(pool), balances.ink, balances.art, seriesId); + + // Test new balances + Balances memory newBalances = cauldron.balances(vaultId); + assertEq(newBalances.art, 0); + assertEq(newBalances.ink, 0); + } + + function testInvestAndUnwind() public { + uint128 collateral = 5_000_000_000; + uint128 borrowed = 2 * collateral; + uint128 slippage = 1_001; + this.investAndUnwind(collateral, borrowed, slippage); + } + + function testInvestAndUnwind2() public { + uint128 collateral = 10_000_000_000; + uint128 borrowed = collateral; + uint128 slippage = 1_020; + this.investAndUnwind(collateral, borrowed, slippage); + } + + function testInvestAndUnwindAfterMaturity() public { + uint128 collateral = 5_000_000_000; + uint128 borrowed = 2 * collateral; + // Slippage, in tenths of a percent, 1 being no slippage + uint128 slippage = 1_001; + helperContract.buyUsdc(collateral, address(this)); + usdc.approve(address(yieldLever), collateral); + uint128 maxFy = (pool.buyBasePreview(borrowed) * slippage) / 1000; + bytes12 vaultId = yieldLever.invest(collateral, borrowed, maxFy, seriesId); + + // Move past maturity + Series memory series = cauldron.series(seriesId); + vm.warp(series.maturity); + + // Unwind + Balances memory balances = cauldron.balances(vaultId); + yieldLever.unwind(vaultId, 0, address(pool), balances.ink, balances.art, seriesId); + + // Test new balances + Balances memory newBalances = cauldron.balances(vaultId); + assertEq(newBalances.art, 0); + assertEq(newBalances.ink, 0); + } +}