From 2af23382798c5f580080a056c9a9ff397a700233 Mon Sep 17 00:00:00 2001 From: Lawrence Forman Date: Wed, 15 Nov 2023 03:05:30 -0500 Subject: [PATCH] Add flash loans --- patterns/flash-loans/FlashLoanPool.sol | 74 +++++ patterns/flash-loans/README.md | 114 ++++++++ .../flash-loans/flash-loan-flow.drawio.svg | 197 +++++++++++++ test/FlashLoanPool.t.sol | 273 ++++++++++++++++++ 4 files changed, 658 insertions(+) create mode 100644 patterns/flash-loans/FlashLoanPool.sol create mode 100644 patterns/flash-loans/README.md create mode 100644 patterns/flash-loans/flash-loan-flow.drawio.svg create mode 100644 test/FlashLoanPool.t.sol diff --git a/patterns/flash-loans/FlashLoanPool.sol b/patterns/flash-loans/FlashLoanPool.sol new file mode 100644 index 0000000..e3f943a --- /dev/null +++ b/patterns/flash-loans/FlashLoanPool.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// Minimal ERC20 interface. +interface IERC20 { + function balanceOf(address owner) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); +} + +// Interface implemented by a flash loan borrower contract. +interface IBorrower { + function onFlashLoan( + // Who called `flashLoan()`. + address operator, + // Token borrowed. + IERC20 token, + // Amount of tokens borrowed. + uint256 amount, + // Extra tokens (on top of `amount`) to return as the loan fee. + uint256 fee, + // Arbitrary data passed into `flashLoan()`. + bytes calldata data + ) external; +} + + +contract FlashLoanPool { + uint16 public constant FEE_BPS = 0.001e4; + address public immutable OWNER; + + constructor(address owner) { OWNER = owner; } + + // Perform a flash loan. + function flashLoan( + // Token to borrow. + IERC20 token, + // How much to borrow. + uint256 borrowAmount, + // Address of the borrower (handler) contract. + IBorrower borrower, + // Arbitrary data to pass to borrower contract. + bytes calldata data + ) + external + { + // Snapshot our token balance before the transfer. + uint256 balanceBefore = token.balanceOf(address(this)); + require(balanceBefore >= borrowAmount, 'too much'); + // Compute the fee, rounded up. + uint256 fee = FEE_BPS * (borrowAmount + 1e4-1) / 1e4; + // Transfer tokens to the borrower contract. + token.transfer(address(borrower), borrowAmount); + // Let the borrower do its thing. + borrower.onFlashLoan( + msg.sender, + token, + borrowAmount, + fee, + data + ); + // Check that all the tokens were returned + fee. + uint256 balanceAfter = token.balanceOf(address(this)); + require(balanceAfter == balanceBefore + fee, 'not repaid'); + } + + // Withdraw tokens from this contract to the contract owner. + function withdraw(IERC20 token, uint256 amount) + external + { + require(msg.sender == OWNER, 'not owner'); + token.transfer(msg.sender, amount); + } +} diff --git a/patterns/flash-loans/README.md b/patterns/flash-loans/README.md new file mode 100644 index 0000000..782825b --- /dev/null +++ b/patterns/flash-loans/README.md @@ -0,0 +1,114 @@ +# Flash Loans + +- [📜 Example Code](./FlashLoanPool.sol) +- [🐞 Tests](../../test/FlashLoanPool.t.sol) + +For better or worse, flash loans are a permanent fixture of the modern defi landscape. As the name implies, flash loans allow people to borrow massive (sometimes protocol breaking) amounts of an asset asset during the lifespan of a function call, typically for just a small (or no) fee. For protocols that custody assets, flash loans can be an additional source of yield without risking any of its assets... if implemented [securely](#security-considerations) 🤞. + +Here we'll explore creating a basic flash loan protocol to illustrate the concept. + +## Anatomy of a Flash Loan + +At their core, flash loans are actually fairly simple, following this typical flow: + 1. Transfer loaned assets to a user-specified borrower contract. + 2. Call a handler function on the borrower contract. + 1. Let the borrower contract perform whatever actions it needs to do with those assets. + 3. After the borrower's handler function returns, verify that all of the borrowed assets have been returned + some extra as fee. + +![flash loan flow](./flash-loan-flow.drawio.svg) + +The entirety of the loan occurs inside of the call to the loan function. If the borrower fails to return the assets (+ fee) by the time their logic completes, the entire call frame reverts and it will be as if the loan and the actions performed with it never happened, exposing no assets to risk. It's this lack of risk that drives the fee associated with flash loans down. + +## A Simple FLash Loan Protocol + +Let's write a simple ERC20 pool contract owned and funded by a single entity. Borrowers can come along and take a flash loan against the pool's tokens, earning a small fee along the way and increasing the total value of the pool. For additional simplicity, this contract will only support [compliant](../erc20-compatibility/) ERC20 tokens that don't take fees on transfer. + +We're looking at the following minimal interfaces for this protocol: + +```solidity +// Interface implemented by our protocol. +interface IFLashLoanPool { + // Perform a flash loan. + function flashLoan( + // Token to borrow. + IERC20 token, + // How much to borrow. + uint256 borrowAmount, + // Address of the borrower (handler) contract. + IBorrower borrower, + // Arbitrary data to pass to borrower contract. + bytes calldata data + ) external; + + // Withdraw tokens to the contract owner. + function withdraw(IERC20 token, uint256 amount) external; +} + +// Interface implemented by a flash loan borrower. +interface IBorrower { + function onFlashLoan( + // Who called `flashLoan()`. + address operator, + // Token borrowed. + IERC20 token, + // Amount of tokens borrowed. + uint256 amount, + // Extra tokens (on top of `amount`) to return as the loan fee. + uint256 fee, + // Arbitrary data passed into `flashLoan()`. + bytes calldata data + ) external; +} +``` + +Let's flesh out `floashLoan()`, which is really all we need to have a functioning flash loan protocol. It needs to track the token balances, transfer tokens to the borrower, hand over execution control to the borrower, then verify assets were returned. We'll use the constant `FEE_BPS` to define the flash loan fee in BPS. + +```solidity +function onFlashLoan( + // Who called `flashLoan()`. + address operator, + // Token borrowed. + IERC20 token, + // Amount of tokens borrowed. + uint256 amount, + // Extra tokens (on top of `amount`) to return as the loan fee. + uint256 fee, + // Arbitrary data passed into `flashLoan()`. + bytes calldata data +) + external +{ + // Snapshot our token balance before the transfer. + uint256 balanceBefore = token.balanceOf(address(this)); + require(balanceBefore >= borrowAmount, 'too much'); + // Compute the fee, rounded up. + uint256 fee = FEE_BPS * (borrowAmount + 1e4-1) / 1e4; + // Transfer tokens to the borrower contract. + token.transfer(address(borrower), borrowAmount); + // Let the borrower do its thing. + borrower.onFlashLoan( + msg.sender, + token, + borrowAmount, + fee, + data + ); + // Check that all the tokens were returned + fee. + uint256 balanceAfter = token.balanceOf(address(this)); + require(balanceAfter == balanceBefore + fee, 'not repaid'); +} +``` + +The `withdraw()` function is trivial to implement so we'll omit it from this guide, but you can see the full, functional contract [here](./FlashLoanPool.sol). + +## Security Considerations + +Implementing flash loans might have seemed really simple but usually flash loans are added on top of an existing, more complex product. For example, Aave, Dydx, and Uniswap all have flash loan capabilities added to their lending and exchange products. The transfer-and-call pattern used by flash loans creates a huge opportunity for [reentrancy](../reentrancy/) and price manipulation attacks when in the context of even low complexity protocols. + +For instance, let's say we took the natural progression of our toy example and allowed anyone to deposit assets, granting them shares that entitles them to a proportion of generated fees. Now we would have to wonder what could happen if the flash loan borrower re-deposited borrowed assets into the pool. Without proper safeguards, it's very possible that we could double count these assets and the borrower would be able to inflate the size/value of their own shares and then drain all the assets out of the pool after the flash loan operation! + +Extreme care has to be taken any time you do any kind of arbitrary function callback, but especially if there's value associated with it. + +## Test Demo: DEX Arbitrage Borrower + +Check the [tests](../../test/FlashLoanPool.t.sol) for an illustration of how a user would use our flash loan feature. There you'll find the a borrower contract designed to perform arbitrary swap operations across different DEXes to capture a zero-capital arbitrage opportunity, with profits going to the operator. diff --git a/patterns/flash-loans/flash-loan-flow.drawio.svg b/patterns/flash-loans/flash-loan-flow.drawio.svg new file mode 100644 index 0000000..5c75979 --- /dev/null +++ b/patterns/flash-loans/flash-loan-flow.drawio.svg @@ -0,0 +1,197 @@ + + + + + + + + +
+
+
+ Operator +
+
+
+
+ + Opera... + +
+
+ + + + + +
+
+
+ onFlashLoan() +
+
+
+
+ + onFlashLoan() + +
+
+ + + + + +
+
+
+ flashLoan() +
+
+
+
+ + flashLoan() + +
+
+ + + + + +
+
+
+ transfer tokens +
+
+
+
+ + transfer tokens + +
+
+ + + + +
+
+
+ + External Protocol(s) + +
+
+
+
+ + External Protocol(s) + +
+
+ + + + + +
+
+
+ leverage tokens +
+
+
+
+ + leverage tokens + +
+
+ + + + +
+
+
+ Borrower Contract +
+
+
+
+ + Borrower Contract + +
+
+ + + + + +
+
+
+ return tokens +
+
+
+
+ + return tokens + +
+
+ + + + +
+
+
+ Lending Pool +
+
+
+
+ + Lending Pool + +
+
+ + + + + + + + + + +
+
+
+ validate repayment +
+
+
+
+ + validate repayment + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file diff --git a/test/FlashLoanPool.t.sol b/test/FlashLoanPool.t.sol new file mode 100644 index 0000000..14e4165 --- /dev/null +++ b/test/FlashLoanPool.t.sol @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import "solmate/tokens/ERC20.sol"; +import "../patterns/flash-loans/FlashLoanPool.sol"; +import "./TestUtils.sol"; + +contract TestERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol, 6) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function asIERC20() external view returns (IERC20) { + return IERC20(address(this)); + } +} + +contract FlashLoanValidator is StdAssertions { + struct Data { + address expectedOperator; + uint256 expectedAmount; + uint256 expectedFee; + IERC20 expectedToken; + } + + function validateFlashLoan( + address operator, + IERC20 token, + uint256 amount, + uint256 fee, + bytes calldata rawData + ) + external + { + Data memory data = abi.decode(rawData, (Data)); + assertEq(operator, data.expectedOperator); + assertEq(address(token), address(data.expectedToken)); + assertEq(amount, data.expectedAmount); + assertEq(fee, data.expectedFee); + } +} + +contract MintBorrower is IBorrower { + struct Data { + uint256 mintAmount; + FlashLoanValidator validator; + bytes validatorData; + } + + function onFlashLoan( + address operator, + IERC20 token, + uint256 amount, + uint256 fee, + bytes calldata rawData + ) + external + { + Data memory data = abi.decode(rawData, (Data)); + if (address(data.validator) != address(0)) { + assert(address(data.validator).code.length != 0); + data.validator.validateFlashLoan(operator, token, amount, fee, data.validatorData); + } + TestERC20(address(token)).mint(msg.sender, data.mintAmount); + } +} + +contract MockDEX { + mapping (TestERC20 => mapping (TestERC20 => uint32)) private _rates; + + function setRate(TestERC20 sellToken, TestERC20 buyToken, uint32 rate) external { + _rates[sellToken][buyToken] = rate; + } + + function swapBalance(TestERC20 sellToken, TestERC20 buyToken) external { + uint32 rate = _rates[sellToken][buyToken]; + _rates[sellToken][buyToken] = 0; + uint256 sellAmount = sellToken.balanceOf(msg.sender); + { + uint256 sellAllowance = sellToken.allowance(msg.sender, address(this)); + if (sellAllowance < sellAmount) { + sellAmount = sellAllowance; + } + } + sellToken.transferFrom(msg.sender, address(this), sellAmount); + uint256 buyAmount = sellAmount * rate * 10**buyToken.decimals() + / (10**sellToken.decimals() * 1e4); + buyToken.mint(msg.sender, buyAmount); + } +} + +contract ArbitrageBorrower is IBorrower { + FlashLoanPool public immutable POOL; + address public immutable OPERATOR; + + constructor(FlashLoanPool pool, address operator) { + POOL = pool; + OPERATOR = operator; + } + + function onFlashLoan( + address operator, + IERC20 token, + uint256 amount, + uint256 fee, + bytes calldata data + ) + external + { + // Only FlashLoanPool can call this function. + require(msg.sender == address(POOL), 'not pool'); + // Only a designated operator can trigger it. + require(operator == OPERATOR, 'not operator'); + ( + address[] memory addrs, + IERC20[] memory dstTokens, + bytes[] memory swapCalls + ) = abi.decode(data, (address[], IERC20[], bytes[])); + assert(addrs.length == swapCalls.length && addrs.length == dstTokens.length); + for (uint256 i = 0; i < addrs.length; ++i) { + IERC20 srcToken = i == 0 ? token : dstTokens[i - 1]; + IERC20 dstToken = dstTokens[i]; + // Last token must be the original token. + assert(i < addrs.length - 1 || dstToken == token); + // Grant an allowance to the target address. + srcToken.approve(addrs[i], srcToken.balanceOf(address(this))); + // Call the target. + // NOTE: that token quantities will likely need to be baked + // into the call data. Leaving dynamic quantities for a future exercise. + // WARNING: This executes ANY target + call data combo, INCLUDING an ERC20 + // transfer() call, so it's important that this contract either never holds + // assets long-term or is restricted in who can interact with it. + (bool b, bytes memory r) = addrs[i].call(swapCalls[i]); + if (!b) { + // Bubble up revert on failure. + assembly { revert(add(r, 0x20), mload(r)) } + } + // Revoke allowance. + srcToken.approve(addrs[i], 0); + // Transfer any remaining tokens to the operator. + token.transfer(msg.sender, srcToken.balanceOf(address(this))); + } + // Transfer borrowed amount + fee back to the lender. + token.transfer(msg.sender, amount + fee); + // Transfer anything remaining to the operator. + token.transfer(operator, token.balanceOf(address(this))); + } +} + +contract FlashLoanPoolTest is TestUtils, FlashLoanValidator { + FlashLoanPool pool = new FlashLoanPool(address(this)); + TestERC20[] tokens; + + constructor() { + tokens.push(new TestERC20('TEST1', 'TEST1')); + tokens.push(new TestERC20('TEST1', 'TEST1')); + tokens[0].mint(address(pool), 100e6); + tokens[1].mint(address(pool), 100e6); + } + + function _getFee(uint256 amount) private view returns (uint256) { + return pool.FEE_BPS() * (amount + 1e4-1) / 1e4; + } + + function test_canWithdraw() external { + pool.withdraw(tokens[0].asIERC20(), 1e6); + assertEq(tokens[0].balanceOf(address(this)), 1e6); + assertEq(tokens[0].balanceOf(address(pool)), 100e6 - 1e6); + pool.withdraw(tokens[1].asIERC20(), 1e6); + assertEq(tokens[1].balanceOf(address(this)), 1e6); + assertEq(tokens[1].balanceOf(address(pool)), 100e6 - 1e6); + } + + function test_notOwnerCannotWithdraw() external { + IERC20 token = tokens[0].asIERC20(); + vm.expectRevert('not owner'); + vm.prank(_randomAddress()); + pool.withdraw(token, 1e6); + } + + + function test_cannotFlashLoanWithoutFee() external { + IERC20 token = tokens[0].asIERC20(); + address operator = _randomAddress(); + MintBorrower borrower = new MintBorrower(); + uint256 amount = 1e6; + uint256 fee = _getFee(amount); + bytes memory data = abi.encode(MintBorrower.Data({ + mintAmount: fee + amount - 1, + validator: FlashLoanValidator(address(0)), + validatorData: "" + })); + vm.expectRevert('not repaid'); + vm.prank(operator); + pool.flashLoan(token, amount, borrower, data); + } + + function test_canFlashLoan() external { + IERC20 token = tokens[0].asIERC20(); + address operator = _randomAddress(); + MintBorrower borrower = new MintBorrower(); + uint256 amount = 1e6; + uint256 fee = _getFee(amount); + bytes memory data = abi.encode(MintBorrower.Data({ + mintAmount: fee + amount, + validator: this, + validatorData: abi.encode(FlashLoanValidator.Data({ + expectedOperator: operator, + expectedAmount: amount, + expectedFee: fee, + expectedToken: token + })) + })); + vm.prank(operator); + pool.flashLoan(token, amount, borrower, data); + assertEq(token.balanceOf(address(pool)), 100e6 + fee); + } + + function test_cannotFlashLoanMoreThanBalance() external { + IERC20 token = tokens[0].asIERC20(); + address operator = _randomAddress(); + MintBorrower borrower = new MintBorrower(); + uint256 amount = token.balanceOf(address(pool)) + 1; + uint256 fee = _getFee(amount); + bytes memory data = abi.encode(MintBorrower.Data({ + mintAmount: fee + amount, + validator: FlashLoanValidator(address(0)), + validatorData: "" + })); + vm.expectRevert('too much'); + vm.prank(operator); + pool.flashLoan(token, amount, borrower, data); + } + + function test_canFlashLoanToArbitrageBorrower() external { + IERC20 token = tokens[0].asIERC20(); + address operator = _randomAddress(); + ArbitrageBorrower borrower = new ArbitrageBorrower(pool, operator); + uint256 amount = token.balanceOf(address(pool)); + TestERC20[] memory tokenPath = new TestERC20[](4); + tokenPath[0] = new TestERC20('USDC', 'USDC'); // TEST1 -> USDC + tokenPath[1] = new TestERC20('MKR', 'MKR'); // USDC -> MKR + tokenPath[2] = tokenPath[0]; // MKR -> USDC (arb) + tokenPath[3] = TestERC20(address(token)); // USDC -> TEST1 + // Set up a DEX with a +0.25% MKR/USDC arb. + MockDEX dex = new MockDEX(); + dex.setRate(tokenPath[3], tokenPath[0], 0.5e4); // TEST1 -> USDC + dex.setRate(tokenPath[0], tokenPath[1], 0.01e4); // USDC -> MKR + dex.setRate(tokenPath[1], tokenPath[2], 100.25e4); // MKR -> USDC (+ 0.25%) + dex.setRate(tokenPath[2], tokenPath[3], 2e4); // USDC -> TEST1 + MockDEX[] memory dexes = new MockDEX[](tokenPath.length); + bytes[] memory callDatas = new bytes[](tokenPath.length); + for (uint256 i = 0; i < tokenPath.length; ++i) { + dexes[i] = dex; + callDatas[i] = abi.encodeCall( + MockDEX.swapBalance, + ( + tokenPath[(i + tokenPath.length - 1) % tokenPath.length], + tokenPath[i] + ) + ); + } + assertEq(token.balanceOf(operator), 0); + vm.prank(operator); + pool.flashLoan(token, amount, borrower, abi.encode(dexes, tokenPath, callDatas)); + uint256 profit = (1.0025e4 * amount / 1e4) - (amount + _getFee(amount)); + assertGt(profit, 0); + assertEq(token.balanceOf(operator), profit); + assertEq(token.balanceOf(address(pool)), 100e6 + _getFee(amount)); + } +} \ No newline at end of file