๐ Welcome to SpeedRunEthereum! ๐โโ๏ธ
You're viewing this challenge as a guest. Want to start building your portfolio?
Registration is free and only requires signing an offchain message with your wallet
๐ณ๐ฝ Over-Collateralized Lending
๐ณ Build your own lending and borrowing platform. Let's write a contract that takes collateral and lets you borrow other assets against the value of the collateral. What happens when the collateral changes in value? We will be able to borrow more if it is higher, or if it is lower, we will also build a system for liquidating the debt position.
โ Wondering how lending works onchain? Read the overview here.
First, traditional lending usually involves one party (such as a bank) offering up money and another party agreeing to pay interest over-time in order to use that money. The only way this works is because the lending party has some way to hold the borrower accountable. This requires some way to identify the borrower and a legal structure that will help settle things if the borrower decides to stop making interest payments. In the onchain world we don't have a reliable identification system (yet) so all lending is "over-collateralized". Borrowers must lock up collateral in order to take out a loan. "Over-collateralized" means you can never borrow more value than you have supplied.
๐ค I am sure you are wondering, "What is the benefit of a loan if you can't take out more than you put in?" Great question! This form of lending lacks the common use case seen in traditional lending where people may use the loan to buy a house they otherwise couldn't afford but here are a few primary use cases of permissionless lending in DeFi:
- ๐ฐ Maintaining Price Exposure ~ You may have real world bills due but you are sure that ETH is going up in value from here and it would kill you to sell to pay your bills. You could get a loan against your ETH and pay your bills. You would still have ETH locked up to come back to and all you would have to do is pay back the loan.
- ๐ Leverage ~ You could deposit ETH and borrow an asset but only use it to buy more ETH, increasing your exposure to the ETH price movements (to the upside ๐ข or the downside ๐ป๐ฐ).
- ๐ธ Tax Advantages ~ In many jurisdictions, money obtained from a loan is taxed differently than money obtained other ways. It might be advantageous to avoid outright selling of an asset and instead get a loan against it.
๐ Now that you know the background of what is and is not possible with onchain lending, let's dive in to the challenge!
๐ฌ The Lending contract accepts ETH deposits and allows depositor's to take out a loan in CORN ๐ฝ. The contract tracks each depositor's address and only allows them to borrow as long as they maintain at least 120% of the loans value in ETH. If the collateral falls in value (relative to CORN) then the borrower's position may be liquidated by anyone who pays back the loan. The liquidator has an incentive to do this because they collect a 10% fee on top of the value of the loan. This incentive ensures that loans are "guaranteed" to be closed out before they are worth less than 100% of the collateral value, which keeps the lending protocol from taking on bad debt (i.e. people walking away with borrowed assets that are worth more than the underlying collateral left in the protocol).
๐ The final deliverable is an app that allows anyone to take out a loan in CORN while making sure it is always backed by it's value in ETH. Deploy your contracts to a testnet then build and upload your app to a public web server. Submit the url on SpeedRunEthereum.com!
๐ฌ Meet other builders working on this challenge and get help in the Over-Collateralized Lending Challenge Telegram Group
Checkpoint 0: ๐ฆ Environment ๐
๐ ๏ธ Before you begin, you need to install the following tools:
- Node (v18 LTS)
- Yarn (v1 or v2+)
- Git
๐ฅ Then download the challenge to your computer and install dependencies by running:
npx create-eth@1.0.2 -e challenge-over-collateralized-lending challenge-over-collateralized-lending
cd challenge-over-collateralized-lending
๐ป in the same terminal, start your local network (a blockchain emulator in your computer):
yarn chain
๐ฐ๏ธ in a second terminal window, ๐ฐ deploy your contract (locally):
cd challenge-over-collateralized-lending
yarn deploy
๐ฑ in a third terminal window, start your ๐ฑ frontend:
cd challenge-over-collateralized-lending
yarn start
๐ฑ Open http://localhost:3000 to see the app.
๐ฉโ๐ป Restart
yarn chain
and then runyarn deploy
whenever you want to deploy new or updated contracts to your local network. If you haven't made any contract changes, you can runyarn deploy --reset
for a completely fresh deploy.
Checkpoint 1: ๐ณ๐ฝ Lending Contract
A lending platform needs these three primary functions:
- Lending
- Borrowing
- Liquidation
For this challenge we will not focus on the Lending aspect as much as the other two. We will assume that people have already supplied the Lending contract with the borrowable CORN. In a real system the borrower would be charged interest on the loan so that the lenders have an incentive to deposit assets but here we will just be focusing on the borrow side of the market.
๐ Navigate to the Debug Contracts
tab, you should see four smart contracts displayed called Corn
, CornDEX
, Lending
and MovePrice
. You don't need to worry about any of these except Lending
but here is a quick description of each:
- ๐ฝ Corn ~ This is the ERC20 token that can be borrowed
- ๐ CornDEX ~ This is the DEX contract that is used to swap between ETH and CORN but is also used as a makeshift price oracle
- ๐ฐ Lending ~ This is the contract that facilitates collateral depositing, loan creation and liquidation of loans in bad positions
- ๐ MovePrice ~ This contract is only used for making large swaps in the DEX to change the asset ratio, changing the price reported by the DEX
๐ packages/hardhat/contracts/Lending.sol
Is where you will spend most of your time.
๐ฅ๏ธ Below is what your front-end will look like with no implementation code within your smart contracts yet. The buttons will likely break because there are no functions tied to them yet!
๐ Check out the empty functions in
Lending.sol
to see aspects of each function. If you can explain how each function will work with one another, that's great! ๐
๐ฅ Goals
- ๐ Review all the
Lending.sol
functions and envision how they might work together.
Checkpoint 2: โ Adding and Removing Collateral
๐ The Lending contract naively uses the price returned by a CORN/ETH DEX contract. This makes it easy for you to change the price of CORN by "moving the market" with large swaps. Shout out to the DEX challenge! Using a DEX as the sole price oracle would never work in a production grade system but it will help to demonstrate the different market conditions that affect a lending protocol.
๐ Let's take a look at the addCollateral
function inside Lending.sol
.
โ ๏ธ It should revert with Lending_InvalidAmount()
if somebody calls it without value.
๐ It needs to record any value that gets sent to it as being collateral posted by the sender into an existing mapping called s_userCollateral
.
๐ข Let's also emit the CollateralAdded
event with depositor address, amount they deposited and the i_cornDEX.currentPrice()
which is the current value of ETH in CORN.
โ ๏ธ We are emitting the price returned by the DEX in every event solely for the front end to be able to visualize things properly.
๐ Hint
๐ This method is also very straight forward, try to solve it by following the description and when you think you have it compare to the solution below.
Solution Code
function addCollateral() public payable {
if (msg.value == 0) {
revert Lending__InvalidAmount(); // Revert if no collateral is sent
}
s_userCollateral[msg.sender] += msg.value; // Update user's collateral balance
emit CollateralAdded(msg.sender, msg.value, i_cornDEX.currentPrice()); // Emit event for collateral addition
}
๐ Very good! Now let's look at the withdrawCollateral
function. Don't want to send funds in if they can't be retrieved, now do we?!
โ ๏ธ Let's revert with Lending_InvalidAmount()
right at the start if someone attempts to use the function with the amount
parameter set to 0. We also want to revert if the sender doesn't have the amount
of collateral they are requesting.
๐ฐ Now let's reduce the sender's collateral (in the mapping) and send it back to their address.
๐ข Emit CollateralWithdrawn
with the sender's address, the amount they withdrew and the currentPrice
from the DEX.
๐ Hint
๐ This method is also very straight forward, try to solve it by following the description and when you think you have it compare to the solution below.
Solution Code
function withdrawCollateral(uint256 amount) public {
if (amount == 0 || s_userCollateral[msg.sender] < amount) {
revert Lending__InvalidAmount(); // Revert if the amount is invalid
}
// Reduce the user's collateral
uint256 newCollateral = s_userCollateral[msg.sender] - amount;
s_userCollateral[msg.sender] = newCollateral;
// Transfer the collateral to the user
(bool success, ) = payable(msg.sender).call{value: amount}("");
if (!success) {
revert Lending__TransferFailed();
}
emit CollateralWithdrawn(msg.sender, amount, i_cornDEX.currentPrice()); // Emit event for collateral withdrawal
}
๐ Excellent! Re-deploy your contract with yarn deploy --reset
. We want to do a fresh deploy of all the contracts so that they each have correct constructor parameters. Now try out your methods from the front end and see if you need to make any changes.
๐ฐ Don't forget to give yourself some ETH from the faucet!
๐ฅ Goals
- ๐งช Can you add collateral and withdraw collateral?
- ๐ฅ๏ธ Does the front end update when you do each action?
Checkpoint 3: ๐ซถ Helper Methods
๐งฉ Now we need to add four methods that we will use in other functions to get various details about a user's debt position.
๐ข Let's start with calculateCollateralValue
. This function receives the address of the user in question and returns a uint256 representing the ETH collateral, priced in CORN.
๐ก We know how to get the user's collateral and we know the price in CORN is returned by i_cornDEX.currentPrice()
. Can you figure out how to return the collateral value in CORN?
๐ Hint
๐งฎ This method just needs to return the users collateral multiplied by the price of CORN (
i_cornDEX.currentPrice()
) divided by 1e18 (since that is how many decimals CORN has).
Solution Code
function calculateCollateralValue(address user) public view returns (uint256) {
uint256 collateralAmount = s_userCollateral[user]; // Get user's collateral amount
return (collateralAmount * i_cornDEX.currentPrice()) / 1e18; // Calculate collateral value in CORN
}
๐ Let's turn our attention to the internal _calculatePositionRatio
view function.
๐ This function takes a user address and returns what we are calling the "position ratio". This is the percentage of collateral to borrowed assets with a caveat, it is returned as the percentage * 1e18. In other words, if the collateral ratio percent is 133 then the returned value would be 133000000000000000000. We do this to enable a higher amount of precision. Try to figure out the math on your own.
๐ Hint
๐ It needs to return the value of the collateral (in CORN) divided by the amount borrowed.
Solution Code
function _calculatePositionRatio(address user) internal view returns (uint256) {
uint borrowedAmount = s_userBorrowed[user]; // Get user's borrowed amount
uint collateralValue = calculateCollateralValue(user); // Calculate user's collateral value
if (borrowedAmount == 0) return type(uint256).max; // Return max if no corn is borrowed
return (collateralValue * 1e18) / borrowedAmount; // Calculate position ratio
}
๐ซ Now we will fill in the details on isLiquidatable
. This function should return a bool indicating if the position ratio is less than COLLATERAL_RATIO
. See if you can implement the logic without the hint.
๐ Hint
๐ฏ We can use the
_calculatePositionRatio
function we just made to get the current ratio. Then just a simple comparison between that and the COLLATERAL_RATIO to make sure we aren't below the acceptable liquidatable threshold.
Solution Code
function isLiquidatable(address user) public view returns (bool) {
uint256 positionRatio = _calculatePositionRatio(user); // Calculate user's position ratio
return (positionRatio * 100) < COLLATERAL_RATIO * 1e18; // Check if position is unsafe
}
โ
Lastly let's fill in a simple function called _validatePosition
. This function has one use case: revert with Lending_UnsafePositionRatio
if the user's position is liquidatable (isLiquidatable
returns exactly what we need). We can then use this function any place where we need to verify the user's ratio position hasn't been changed to a liquidatable state after updating the user's balance of borrowed assets.
Solution Code
function _validatePosition(address user) internal view {
if (isLiquidatable(user)) {
revert Lending__UnsafePositionRatio();
}
}
๐ฅ Goals
- ๐ง Do you understand why we need to multiply/divide everything by 1e18? (Search term: Solidity precision loss)
- ๐ Glance around the other methods and try to predict where we may use these methods
Checkpoint 4: ๐ฝ Let's Borrow Some CORN!
๐ก Since we added all those complex helper functions in the last step it may be helpful to import "hardhat/console.sol" and use console.logs whenever you get stuck and want to know what is happening as you execute each function.
๐ Go to the borrowCorn
function.
โ ๏ธ It should revert with Lending_InvalidAmount()
if somebody calls it without a borrowAmount
.
๐ It should add the borrowed amount to the user's balance in the s_userBorrowed
mapping.
โ
It should validate the user's position (_validatePosition
) so that it reverts if they are attempting to borrow more than they are allowed.
๐ช Then it should use the CORN token's transferFrom
function to move the tokens to the user's address.
โ ๏ธ As we mentioned above, we are only focusing on the borrow side of the market. We are just "pretending" that people have deposited the CORN in the Lending contract with the intent to make a profit but we haven't provided any real incentives for them to do so.
๐ข You should also emit the AssetBorrowed
event.
๐ Perfect! Now let's go fill out the repayCorn
function.
โ ๏ธ Revert with Lending_InvalidAmount
if the repayAmount is 0 or if it is more than the user has borrowed.
๐ Subtract the amount from the s_userBorrowed
mapping. Then use the CORN token's transferFrom
function to remove the CORN from the borrower's wallet back to the Lending contract.
๐ข And finally, emit the AssetRepaid
event.
๐ Run yarn deploy --reset
so you can play with borrowing and repaying on the front end. You can adjust the price of CORN by pressing the + and - buttons under CORN price in the top right corner. See how your open position's collateral value shifts as the price moves.
Solution Code
function borrowCorn(uint256 borrowAmount) public {
if (borrowAmount == 0) {
revert Lending__InvalidAmount(); // Revert if borrow amount is zero
}
s_userBorrowed[msg.sender] += borrowAmount; // Update user's borrowed corn balance
_validatePosition(msg.sender); // Validate user's position before borrowing
bool success = i_corn.transferFrom(address(this), msg.sender, borrowAmount); // Borrow corn to user
if (!success) {
revert Lending__BorrowingFailed(); // Revert if borrowing fails
}
emit AssetBorrowed(msg.sender, borrowAmount, i_cornDEX.currentPrice()); // Emit event for borrowing
}
function repayCorn(uint256 repayAmount) public {
if (repayAmount == 0 || repayAmount > s_userBorrowed[msg.sender]) {
revert Lending__InvalidAmount(); // Revert if repay amount is invalid
}
s_userBorrowed[msg.sender] -= repayAmount; // Reduce user's borrowed balance
bool success = i_corn.transferFrom(msg.sender, address(this), repayAmount); // Take CORN from user
if (!success) {
revert Lending__RepayingFailed(); // Revert if burning fails
}
emit AssetRepaid(msg.sender, repayAmount, i_cornDEX.currentPrice()); // Emit event for repaying
}
๐ฅ Goals
- ๐งช Can you borrow and repay CORN? Don't forget to approve the Lending contract to use your CORN before attempting to repay.
- โ What happens if you repay without having enough tokens to repay? Have you handled that well? (
Lending__RepayingFailed
might be nice to throw...) - ๐ Can you borrow more than 120% of your collateral value? It should revert if you attempt this...
Checkpoint 5: ๐ Liquidation Mechanism
๐ฆ So we have a way to deposit collateral and borrow against it. Great! But what happens if the value of our collateral goes down and the liquidation threshold is passed?
๐จ We need a liquidation mechanism! This function will liquidate the loan of the borrower (whose address is used as a param) but the caller must have enough CORN to repay the loan. Once the loan is repaid the caller is given ETH worth the value of the CORN they used + 10%. This ETH comes out of the borrower's deposited collateral. Essentially the borrower is being charged a 10% fee for allowing their loan to get in a liquidatable position. The liquidator is acting on a natural incentive to acquire some CORN from the DEX (or by taking out a loan themselves), liquidate the loan, and make a 10% profit.
๐ Let's go to the liquidate
function. We want anyone to be able to call this when a position is liquidatable. The caller must have enough CORN to repay the debt. This function should remove the borrower's debt AND the amount of collateral that is needed to cover the debt.
โ ๏ธ First let's make sure to revert if the user's position is not liquidatable with Lending__NotLiquidatable
.
โ Also, Let's make sure the caller has enough CORN to liquidate the borrower's position. If they don't, revert with Lending__InsufficientLiquidatorCorn
.
๐ Let's transfer the CORN to this contract from the liquidator. (transferFrom
).
๐งน Clear the borrower's debt completely.
๐งฎ Calculate the amount of collateral needed to cover the cost of the burned CORN and remove it from the borrower's collateral.
๐ก Keep in mind, It's not enough to simply have a liquidation mechanism. We need an incentive for people to trigger it! By providing a healthy cut of the funds and allowing liquidations when the collateral still has 20% over the actual value of the loan we are providing strong incentive-based guarantees that the protocol won't take on bad debt.
โ ๏ธ We have simplified things by not adding any APY incentives (and inversely borrowing fees). These are important incentives in real lending markets that help to keep the market balanced by encouraging people to supply collateral or as a borrower to repay a loan that is requiring a high APR because the collateral is not as safe or nearing the liquidation threshold. These fees are a great place to add logic that generates protocol revenue as well by taking some of the borrowing APR and letting it accrue to the protocol's token, passing the rest along to the supplier. These incentives, along with the liquidation system, help to make sure there is always more value in collateral than value being borrowed.
๐ฐ Now add the LIQUIDATOR_REWARD
as a percentage on top of the collateral (but never exceeding the borrower's total collateral) so that the liquidator has a nice incentive to want to liquidate that poor borrower.
๐ธ Transfer that amount of collateral to the liquidator.
๐ข Finally emit the Liquidation
event.
Solution Code
function liquidate(address user) public {
if (!isLiquidatable(user)) {
revert Lending__NotLiquidatable(); // Revert if position is not liquidatable
}
uint256 userDebt = s_userBorrowed[user]; // Get user's borrowed amount
if (i_corn.balanceOf(msg.sender) < userDebt) {
revert Lending__InsufficientLiquidatorCorn();
}
uint256 userCollateral = s_userCollateral[user]; // Get user's collateral balance
uint256 collateralValue = calculateCollateralValue(user); // Calculate user's collateral value
// transfer value of debt to the contract
i_corn.transferFrom(msg.sender, address(this), userDebt);
// Clear user's debt
s_userBorrowed[user] = 0;
// calculate collateral to purchase (maintain the ratio of debt to collateral value)
uint256 collateralPurchased = (userDebt * userCollateral) / collateralValue;
uint256 liquidatorReward = (collateralPurchased * LIQUIDATOR_REWARD) / 100;
uint256 amountForLiquidator = collateralPurchased + liquidatorReward;
amountForLiquidator = amountForLiquidator > userCollateral ? userCollateral : amountForLiquidator; // Ensure we don't exceed user's collateral
s_userCollateral[user] = userCollateral - amountForLiquidator;
// transfer 110% of the collateral needed to cover the debt to the liquidator
(bool success,) = payable(msg.sender).call{ value: amountForLiquidator }("");
if (!success) {
revert Lending__TransferFailed();
}
emit Liquidation(user, msg.sender, amountForLiquidator, userDebt, i_cornDEX.currentPrice());
}
๐ You know the drill. Run yarn deploy --reset
so you can try liquidating on the front end. It may be useful to open a private browser tab and go to localhost:3000
so you can simulate multiple accounts. You can also borrow and then switch wallets and use the swap button in the CORN wallet (on the right side of the screen) to acquire some CORN. Now adjust the price using the price controls in the CORN price module and liquidate the borrower.
๐ซด Notice how the borrower still has their borrowed CORN after they get liquidated. They get to keep their CORN since the liquidator paid their CORN debt back to the protocol on their behalf.
๐ฅ Goals
- ๐ค As long as people follow the incentives, will the protocol ever go under 100% backed loans? Look into this situation that happened to Aave
- โ What happens when a liquidator doesn't have enough CORN to repay the loan? Does it revert like it ought to?
Checkpoint 6: Final Touches and Simulation
๐ Throwback to the withdrawCollateral
function. What happens when a borrower withdraws collateral exceeding the safe position ratio? You should add a _validatePosition
check to make sure that never happens. You should add it after the s_userCollateral
mapping is updated so that it is checking the final state instead of the current state. Skip the check if they don't have any borrowed CORN.
๐ Great work! Your contract has all the necessary functionality to help people get CORN loans.
๐จ Now you get to see something real special. Run yarn deploy --reset
as you usually do. Then run:
yarn simulate
This command will spin up several bot accounts that start using your lending platform! Look at the front end and interact while they are running! You can check out packages/hardhat/scripts/marketSimulator.ts
to adjust the default settings or change the logic on the bot accounts.
๐ Keep on going and try to tackle these optional gigachad side quests. The front end doesn't have any special components for using these side quests but you can use the Debug Tab to use them
โ๏ธ Side Quest 1: Flash Loans
๐ค What if you could borrow any amount of CORN as long as it was paid back by the end of the transaction? That is exactly what a flash loan does! Flash loans are a new financial primitive that is only possible onchain.
๐ก Let's implement a fee free flash loan function!
๐งฉ Before we implement the logic we need to create a new interface called IFlashLoanRecipient
. You can define it beneath the Lending
contract but in the same file. It should have a function called executeOperation
that receives the following parameters: uint256 amount, address initiator, address extraParam
and returns a bool.
๐งฉ Interface Code
contract Lending is Ownable {
// ...
// Existing code
// ...
}
// Place this beneath so we don't have to import from another file
interface IFlashLoanRecipient {
function executeOperation(uint256 amount, address initiator, address extraParam) external returns (bool);
}
๐ There isn't any existing flashLoan
function in Lending.sol
but that is where we need one. Go ahead and define one that is public. It should receive the following parameters:
- ๐
IFlashLoanRecipient _recipient
This is because the loan recipient must be a contract that adheres to theIFlashLoanRecipient
interface -- Not your EOA. You will see why in a minute - ๐ฐ
uint256 _amount
The amount of CORN to send to the recipient contract - ๐
address _extraParam
In a real flash loan function this would probably be a struct with several optional properties allowing people to pass along any data to the recipient contract. See Aave's implementation here
๐ช The logic inside the method needs to mint the amount of CORN that is given in the parameter to the recipient address - The IFlashLoanRecipient adhering contract.
๐ Then it will call the executeOperation
function on the recipient contract and make sure that it returns true
.
๐ฅ Use the CORN token's burnFrom
method to destroy the CORN that was minted at the beginning of this function. Burn it from address(this)
since the recipient should have returned it. If they didn't this burn method will revert when we try to burn tokens that are not held by the lending contract. If it reverts then the CORN will have never been minted to the recipient - no risk of the tokens being stolen.
Solution Code
function flashLoan(IFlashLoanRecipient _recipient, uint256 _amount, address _extraParam) public {
// Send the loan to the recipient - No collateral is required since it gets repaid all in the same transaction
i_corn.mintTo(address(_recipient), _amount);
// Execute the operation - It should return the loan amount back to this contract
bool success = _recipient.executeOperation(_amount, msg.sender, _extraParam);
require(success, "Operation was unsuccessful");
// Burn the loan - Should revert if it doesn't have enough
i_corn.burnFrom(address(this), _amount);
}
๐๏ธ Now we need to create a contract that is the recipient of the CORN. Let's create a contract that uses the flashLoan
method to make it possible to liquidate loans without the liquidator needing to hold CORN tokens.
โ Keep in mind, this is just one example of how we could use the
flashLoan
function. There are so many more things you can build with flash loans!
๐ Create a new contract file and call it FlashLoanLiquidator.sol
๐งฉ See if you can figure out the correct logic to liquidate a loan inside a function called executeOperation
. It will need to utilize the DEX to swap ETH for CORN in order to repay the CORN loan after liquidating the position and receiving the ETH.
After liquidating the loan the contract should send any remaining ETH back to the original initiator of the flashLoan
function.
๐ฐ Don't forget to let the contract receive()
ether!
๐ FlashLoanLiquidator Contract Code
Here is one example of how to accomplish this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { Lending } from "./Lending.sol";
import { CornDEX } from "./CornDEX.sol";
import { Corn } from "./Corn.sol";
/**
* @notice For Side quest only
* @notice This contract is used to liquidate unsafe positions by using a flash loan to borrow CORN to liquidate the position
* then swapping the returned ETH for CORN for repaying the flash loan
*/
contract FlashLoanLiquidator {
Lending i_lending;
CornDEX i_cornDEX;
Corn i_corn;
constructor(address _lending, address _cornDEX, address _corn) {
i_lending = Lending(_lending);
i_cornDEX = CornDEX(_cornDEX);
i_corn = Corn(_corn);
}
function executeOperation(uint256 amount, address initiator, address toLiquidate) public returns (bool) {
// Approve the lending contract to spend the tokens
i_corn.approve(address(i_lending), amount);
// First liquidate to get the collateral tokens
i_lending.liquidate(toLiquidate);
// Calculate required input amount of ETH to get exactly 'amount' of tokens
uint256 ethReserves = address(i_cornDEX).balance;
uint256 tokenReserves = i_corn.balanceOf(address(i_cornDEX));
uint256 requiredETHInput = i_cornDEX.calculateXInput(amount, ethReserves, tokenReserves);
// Execute the swap
i_cornDEX.swap{value: requiredETHInput}(requiredETHInput); // Swap ETH for tokens
// Send the tokens back to Lending to repay the flash loan
i_corn.transfer(address(i_lending), i_corn.balanceOf(address(this)));
// Send the ETH back to the initiator
if (address(this).balance > 0) {
(bool success, ) = payable(initiator).call{value: address(this).balance}("");
require(success, "Failed to send ETH back to initiator");
}
return true;
}
receive() external payable {}
}
๐ Now you need to add your new contract to the deployment script. You can just add it beneath all the existing logic in packages/hardhat/deploy/00_deploy_contracts.ts
.
๐ Deployment Code
const deployContracts: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
// All existing logic...
await deploy("FlashLoanLiquidator", {
from: deployer,
args: [lending.address, cornDEX.target, cornToken.target],
log: true,
autoMine: true,
});
};
๐ Run yarn deploy --reset
.
๐ Create a debt position that is close to the liquidation line and then increase the price of CORN until the position is liquidatable.
๐ Then go to the Debug Tab and trigger the Lending.flashLoan
method with your FlashLoanLiquidator
contract address as the recipient
, the amount of CORN needed to liquidate as the amount
and the borrower's address as the extraParam
.
๐ Pretty cool, huh? You can liquidate any position without needing to have the CORN to pay back the loan!
โ๏ธ Side Quest 2: Maximum Leverage With An Iterative Borrow > Swap > Deposit Loop
๐ค What if you think the price of CORN is going down relative to ETH (Why in the world would you think that!? ๐คฃ). You could borrow CORN but then use the DEX to buy more ETH with your CORN. But wait! Now you have more ETH you could technically use it as collateral again and then you could borrow more CORN and swap to ETH and repeat that as many times as possible.
๐ You can already do this manually but what if we created a contract with some methods that make it easy?
๐๏ธ Let's start by adding a couple helper methods to the Lending.sol
contract.
๐ First let's add a method called getMaxBorrowAmount
that takes a uint256 representing the amount of ETH we have to deposit and returns the maximum amount of CORN we can expect to receive. See if you can figure it out without the solution below, then compare if you run into issues.
Solution Code
function getMaxBorrowAmount(uint256 ethCollateralAmount) public view returns (uint256) {
if (ethCollateralAmount == 0) return 0;
// Calculate collateral value in CORN terms
uint256 collateralValue = (ethCollateralAmount * i_cornDEX.currentPrice()) / 1e18;
// Calculate max borrow amount while maintaining the required collateral ratio
return (collateralValue * 100) / COLLATERAL_RATIO;
}
๐ Now let's add a method that will help us when we go to unravel a position.
Create a function called getMaxWithdrawableCollateral
that receives an address representing the user we want to query. It should return a uint256 representing the amount of ETH that the account has deposited as collateral that is OK to withdraw without putting the position into a liquidatable state. Try to figure it out yourself but feel free to peek at the solution below.
Solution Code
function getMaxWithdrawableCollateral(address user) public view returns (uint256) {
uint256 borrowedAmount = s_userBorrowed[user];
uint256 userCollateral = s_userCollateral[user];
if (borrowedAmount == 0) return userCollateral;
uint256 maxBorrowedAmount = getMaxBorrowAmount(userCollateral);
if (borrowedAmount == maxBorrowedAmount) return 0;
uint256 potentialBorrowingAmount = maxBorrowedAmount - borrowedAmount;
uint256 ethValueOfPotentialBorrowingAmount = (potentialBorrowingAmount * 1e18) / i_cornDEX.currentPrice();
return (ethValueOfPotentialBorrowingAmount * COLLATERAL_RATIO) / 100;
}
๐ Now let's create a new contract that will use those new view functions to "while loop" until we trigger a stop.
๐ Create a new file in the contracts directory called Leverage.sol
and copy the following code into it:
Solution Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import { Lending } from "./Lending.sol";
import { CornDEX } from "./CornDEX.sol";
import { Corn } from "./Corn.sol";
/**
* @notice For Side quest only
* @notice This contract is used to leverage a user's position by borrowing CORN from the Lending contract
* then borrowing more CORN from the DEX to repay the initial borrow then repeating until the user has borrowed as much as they want
*/
contract Leverage {
Lending i_lending;
CornDEX i_cornDEX;
Corn i_corn;
address public owner;
event LeveragedPositionOpened(address user, uint256 loops);
event LeveragedPositionClosed(address user, uint256 loops);
modifier onlyOwner() {
require(msg.sender == owner, "Only the owner can call this function");
_;
}
constructor(address _lending, address _cornDEX, address _corn) {
i_lending = Lending(_lending);
i_cornDEX = CornDEX(_cornDEX);
i_corn = Corn(_corn);
// Approve the DEX to spend the user's CORN
i_corn.approve(address(i_cornDEX), type(uint256).max);
}
/**
* @notice Claim ownership of the contract so that no one else can change your position or withdraw your funds
*/
function claimOwnership() public {
owner = msg.sender;
}
/**
* @notice Open a leveraged position, iteratively borrowing CORN, swapping it for ETH, and adding it as collateral
* @param reserve The amount of ETH that we will keep in the contract as a reserve to prevent liquidation
*/
function openLeveragedPosition(uint256 reserve) public payable onlyOwner {
uint256 loops = 0;
while (true) {
// Write more code here
loops++;
}
emit LeveragedPositionOpened(msg.sender, loops);
}
/**
* @notice Close a leveraged position, iteratively withdrawing collateral, swapping it for CORN, and repaying the lending contract until the position is closed
*/
function closeLeveragedPosition() public onlyOwner {
uint256 loops = 0;
while (true) {
// Write more code here
loops++;
}
emit LeveragedPositionClosed(msg.sender, loops);
}
/**
* @notice Withdraw the ETH from the contract
*/
function withdraw() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No balance to withdraw");
(bool success, ) = payable(msg.sender).call{value: balance}("");
require(success, "Failed to send Ether");
}
receive() external payable {}
}
๐งฉ Try to fill in the "while loops" in the open and close leveraged position functions.
๐ Notice how openLeveragedPosition
is payable and expects to receive all the ETH the caller wants to deposit. The only parameter is a uint256 which represents the amount of ETH the caller wants left over as collateral after looping. If none is specified then the the loan will stop right at the liquidation threshold. The smallest movement in CORN going higher could cause you to be liquidated.
๐ The while loop should add collateral to the Lending
contract and then borrow the max amount of CORN. Then it should use the DEX to swap that CORN for more ETH. Then the loop should be good to go again. Just make sure you add a condition to check if the amount of ETH is less than or equal to the reserve amount and if so, break out of the loop.
Solution Code
function openLeveragedPosition(uint256 reserve) public payable onlyOwner {
uint256 loops = 0;
while (true) {
uint256 balance = address(this).balance;
i_lending.addCollateral{value: balance}();
if (balance <= reserve) {
break;
}
uint256 maxBorrowAmount = i_lending.getMaxBorrowAmount(balance);
i_lending.borrowCorn(maxBorrowAmount);
i_cornDEX.swap(maxBorrowAmount);
loops++;
}
emit LeveragedPositionOpened(msg.sender, loops);
}
๐ closeLeveragedPosition
Should be similar except it will be withdrawing the maximum amount of collateral, swapping to CORN and repaying the debt until there isn't any more CORN left to repay.
Solution Code
function closeLeveragedPosition() public onlyOwner {
uint256 loops = 0;
while (true) {
uint256 maxWithdrawable = i_lending.getMaxWithdrawableCollateral(address(this));
i_lending.withdrawCollateral(maxWithdrawable);
require(maxWithdrawable == address(this).balance, "maxWithdrawable is not equal to balance");
i_cornDEX.swap{value:maxWithdrawable}(maxWithdrawable);
uint256 cornBalance = i_corn.balanceOf(address(this));
uint256 amountToRepay = cornBalance > i_lending.s_userBorrowed(address(this)) ? i_lending.s_userBorrowed(address(this)) : cornBalance;
if (amountToRepay > 0) {
i_lending.repayCorn(amountToRepay);
} else {
// Swap the remaining CORN to ETH since we don't want CORN exposure
i_cornDEX.swap(i_corn.balanceOf(address(this)));
break;
}
loops++;
}
emit LeveragedPositionClosed(msg.sender, loops);
}
๐จโ๐ผ The Leverage
contract has a claimOwnership
and withdraw
function so that you can claim ownership of the contract before opening the position because the position is actually owned by this contract.
๐ Lastly, add the deploy logic to the deployment script. Add it beneath all the existing logic in packages/hardhat/deploy/00_deploy_contracts.ts
.
๐ Deployment Code
const deployContracts: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
// All existing logic...
await deploy("Leverage", {
from: deployer,
args: [lending.address, cornDEX.target, cornToken.target],
log: true,
autoMine: true,
});
};
๐ Run yarn deploy --reset
to redeploy your contract and the associated contracts with new constructor parameters.
๐ Try opening a leveraged position and see how changing the reserve amount affects your tolerance to changes in the market. Leverage is powerful stuff that will blow up in your face if you aren't careful.
Checkpoint 7: ๐พ Deploy your contracts! ๐ฐ
๐ก Edit the defaultNetwork
to your choice of public EVM networks in packages/hardhat/hardhat.config.ts
๐ You will need to generate a deployer address using yarn generate
This creates a mnemonic and saves it locally.
๐ฉโ๐ Use yarn account
to view your deployer account balances.
โฝ๏ธ You will need to send ETH to your deployer address with your wallet, or get it from a public faucet of your chosen network.
๐ Run yarn deploy
to deploy your smart contract to a public network (selected in hardhat.config.ts
)
๐ฌ Hint: You can set the
defaultNetwork
inhardhat.config.ts
tosepolia
oroptimismSepolia
OR you canyarn deploy --network sepolia
oryarn deploy --network optimismSepolia
.
Checkpoint 8: ๐ข Ship your frontend! ๐
โ๏ธ Edit your frontend config in packages/nextjs/scaffold.config.ts
to change the targetNetwork
to chains.sepolia
(or chains.optimismSepolia
if you deployed to OP Sepolia)
๐ป View your frontend at http://localhost:3000 and verify you see the correct network.
๐ก When you are ready to ship the frontend app...
๐ฆ Run yarn vercel
to package up your frontend and deploy.
๐ You might need to log in to Vercel first by running
yarn vercel:login
. Once you log in (email, GitHub, etc), the default options should work.
๐ข If you want to redeploy to the same production URL you can run
yarn vercel --prod
. If you omit the--prod
flag it will deploy it to a preview/test URL.
๐ Follow the steps to deploy to Vercel. It'll give you a public URL.
๐ฆ Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default ๐ฅ
burner wallet's
are only available onhardhat
. You can enable them on every chain by settingonlyLocalBurnerWallet: false
in your frontend config (scaffold.config.ts
inpackages/nextjs/
)
Configuration of Third-Party Services for Production-Grade Apps.
๐ ๏ธ By default, ๐ Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services. This is great to complete your SpeedRunEthereum.
๐ For production-grade applications, it's recommended to obtain your own API keys (to prevent rate limiting issues). You can configure these at:
-
๐ท
ALCHEMY_API_KEY
variable inpackages/hardhat/.env
andpackages/nextjs/.env.local
. You can create API keys from the Alchemy dashboard. -
๐
ETHERSCAN_API_KEY
variable inpackages/hardhat/.env
with your generated API key. You can get your key here.
๐ฌ Hint: It's recommended to store env's for nextjs in Vercel/system env config for live apps and use .env.local for local testing.
Checkpoint 9: ๐ Contract Verification
โ
Run the yarn verify --network your_network
command to verify your contracts on etherscan ๐ฐ
๐ Search this address on Sepolia Etherscan (or Optimism Sepolia Etherscan if you deployed to OP Sepolia) to get the URL you submit to ๐โโ๏ธSpeedRunEthereum.com.
๐ Head to your next challenge here.
๐โโ๏ธ Problems, questions, comments on the stack? Post them to the ๐ scaffold-eth developers chat