Yield farming is the concept of incentivizing users with passive income in exchange for providing liquidity. The first step in yield farming involves adding funds to a liquidity pool, which are essentially smart contracts that contain funds. These pools provide liquidity to a marketplace where users can exchange, borrow, or lend tokens. Once you’ve added your funds to a pool, you’ve officially become a liquidity provider. In return for staking your finds in the pool, you’ll be rewarded with fees generated from the underlying DeFi platform. Reward tokens themselves can also be deposited in liquidity pools, and it’s common practice for people to shift their funds between different protocols to chase higher yields.
Open up your code editor (I'm using Visual Studio Code) and create a new directory for this project.
mkdir hw-farm
Install Node js if you have not done so.
In the project directory, install the Hardhat module:
npm i --save-dev hardhat
Open up Hardhat with npx hardhat
.
Select the Create an empty hardhat.config.js
option.
Install dependencies for TypeScript:
npm i --save-dev ts-node typescript
For testing, install the dependencies for Chai:
npm i --save-dev chai @types/node @types/mocha @types/chai
We’ll be using an ERC20 token as both the staking token and as the yield rewarded to users. OpenZeppelin offers various contract libraries for developers. They also offer excellent testing tools. Install the dependencies for contracts and testing tools:
npm i --save-dev @openzeppelin/contracts @openzeppelin/test-helpers
During testing, we’ll need to simulate the passing of time. Install the dependcies needed for OpenZeppelin’s time.increase()
function:
npm i --save-dev @nomiclabs/hardhat-web3 @nomiclabs/hardhat-waffle
Change the hardhat.config
to TypeScript:
mv hardhat.config.js hardhat.config.ts
Next change the Solidity version and include the hardhat-waffle
and hardhat-web3
imports in the hardhat.config.ts
:
import "@nomiclabs/hardhat-waffle";
import "@nomiclabs/hardhat-web3"
export default {
solidity: "0.8.4",
};
In this tutorial, we will rewards users with HWTokens for staking the MockDai tokens. The HW
are just my initials, you can change them to whatever you like.
In the project directory hw-farm
, create a contracts
folder. In the contracts
folder, create a new Solidity file HWToken.sol
.
mkdir contracts
To build the ERC20 HW token contract, import the ERC20
contract from OpenZeppelin while also importing OpenZeppelin’s AccessControl.sol
contract.
The AccessControl
contract allows us to implement role-based access control mechanisms. In our case, we only want users/contracts with the MINTER
role to be able to mint new tokens.
After declaring the imports, we define the MINTER
role by using a bytes32
identifier and exposed it in the external API using a public constant
hash digest.
In the constructor, we assign the DEFAULT_ADMIN_ROLE
to the creator of the contract.
We will override the mint()
function to enforce a check that the caller has the MINTER
role.
pragma solidity 0.8.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract HWToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor() ERC20("HWToken", "HWT") {
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
function mint(address to, uint256 amount) public {
require(hasRole(MINTER_ROLE, msg.sender), "Caller is not the minter");
_mint(to, amount);
}
}
In our yield farming contract, users will be staking MockERC20
tokens, to receive passive HWToken
rewards. We named our staking token MockDai
, but you can use any name you wish by using parameters in the constructor.
In the contracts
folder, create a mocks
folder.
In the mocks
folder, create a new file MockERC20.sol
.
Import OpenZeppelin’s ERC20.sol
contract, and input the following:
pragma solidity 0.8.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(
string memory name,
string memory ticker
) ERC20(name, ticker) {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
In our yield farming contract, there will be 3 core function. We need to allow users to stake their funds, unstake their funds, and withdraw their yield.
In the contracts
folder, create a file HWFarm.sol
.
By now, your project structure should look somethings like this:
In HWFarm.sol
, import both the HWToken
contract and OpenZeppelin’s IERC20
contract.
We declare the following state variable mappings:
Variable | Purpose |
---|---|
stakingBalance |
Mapping of user address to the user staking balance (MockDai) |
isStaking |
Mapping of user address to the user staking status |
startTime |
Mapping of user address to the timestamp of the user most recent stake. Use to calculate the yield of a users' staking. |
hwBalance |
Mapping of user address to the user reward HWToken balance |
We also define events for the 3 core functions for the front-end.
pragma solidity 0.8.4;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "./HWToken.sol";
contract HWFarm {
// userAddress => stakingBalance
mapping(address => uint256) public stakingBalance;
// userAddress => isStaking boolean
mapping(address => bool) public isStaking;
// userAddress => timeStamp
mapping(address => uint256) public startTime;
// userAddress => hwBalance
mapping(address => uint256) public hwBalance;
string public name = "TokenFarm";
IERC20 public daiToken;
hwToken public hwToken;
event Stake(address indexed from, uint256 amount);
event Unstake(address indexed from, uint256 amount);
event YieldWithdraw(address indexed to, uint256 amount);
constructor(
IERC20 _daiToken,
HWToken _hwToken
) {
daiToken = _daiToken;
hwToken = _hwToken;
}
/// Core function shells
stake() public {}
unstake() public {}
withdrawYield() public {}
}
Declare a name
variable to identify the contract.
string public name = "HW Farm";
Declare state variables preceded with the type (i.e. IERC20
, HWToken
) and visibility (public).
IERC20 public daiToken;
HWToken public hwToken;
Use the following convention in our contracts:
- Type - Use PascalCasing
- State Declaration - Use camelCasing
- Constructor Parameter - Use _underscoreCamelCasing
The stake()
function first requires that the amount
parameter is greater than 0 and that the user holds enough mock DAI for the transaction.
The function then checks if the user already staked DAI. If so, the contract adds the unrealized yield to the hwBalance
. This ensures the accrued yield is not loss.
Then contract then calls the IERC20
transferFrom
function. The user first must approve the contract’s request to move their funds.
The function then updates the stakingBalance
, startTime
, and isStaking
mappings to reflect the new staking.
Finally, it emits the Stake
event to allow the front-end to listen for the said event.
function stake(uint256 amount) public {
require(
amount > 0 && daiToken.balanceOf(msg.sender) >= amount,
"You cannot stake zero tokens");
if(isStaking[msg.sender] == true){
uint256 toTransfer = calculateYieldTotal(msg.sender);
hwBalance[msg.sender] += toTransfer;
}
daiToken.transferFrom(msg.sender, address(this), amount);
stakingBalance[msg.sender] += amount;
startTime[msg.sender] = block.timestamp;
isStaking[msg.sender] = true;
emit Stake(msg.sender, amount);
}
The unstake()
function requires the isStaking
mapping to equate to true (which only happens when the stake
function is previously called) and requires that the requested amount to unstake isn’t greater than the user’s staked balance.
We get the yield from previous staking by calling the calculateYieldTotal
function and storing the result in yieldTransfer
.
Then we follow the checks-effects-interactions pattern by setting balanceTransfer
to equal the amount
to unstake and setting the amount
to 0. This prevents users from abusing the function with re-entrancy.
We then reduce the stakingBalance
mapping and transfer the mock DAI back to the user.
Next, we update the hwBalance
mapping. This mapping constitutes the user’s unrealized yield; therefore, if the user already held an unrealized yield balance, the new balance includes the prior balance with the current balance.
Finally, we check whether the user still has staking tokens in the contract. If the user does not, the isStaking
mapping is set to false
.
Note that Solidity version >= 0.8.0 includes
SafeMath
already integrated. If you’re using Solidity < 0.8.0, I highly encourage you to use aSafeMath
library to prevent overflows.
function unstake(uint256 amount) public {
require(
isStaking[msg.sender] = true &&
stakingBalance[msg.sender] >= amount,
"Nothing to unstake"
);
uint256 yieldTransfer = calculateYieldTotal(msg.sender);
startTime[msg.sender] = block.timestamp;
uint256 balanceTransfer = amount;
amount = 0;
stakingBalance[msg.sender] -= balanceTransfer;
daiToken.transfer(msg.sender, balanceTransfer);
hwBalance[msg.sender] += yieldTransfer;
if(stakingBalance[msg.sender] == 0){
isStaking[msg.sender] = false;
}
emit Unstake(msg.sender, amount);
}
The withdrawYield()
function requires that either the calculateYieldTotal
function or the reward HWToken
hwBalance
holds a balance for the user.
If there is a balance, this means that the user staked mock DAI more than once. The contract then adds the old hwBalance
to the running yield total we received from the calculateYieldTotal
.
Notice that the contract follows the checks-effects-interactions pattern; where
oldBalance
is assigned the accuredhwBalance
. Immediately thereafter,hwBalance
is set to zero (to prevent re-entrancy).
The startTime
is set to the current timestamp in order to reset the accruing yield.
Finally, the contract evokes the hwToken.mint()
function which transfers the reward HWToken
directly to the user.
function withdrawYield() public {
uint256 toTransfer = calculateYieldTotal(msg.sender);
require(
toTransfer > 0 ||
hwBalance[msg.sender] > 0,
"Nothing to withdraw"
);
if(hwBalance[msg.sender] != 0){
uint256 oldBalance = hwBalance[msg.sender];
hwBalance[msg.sender] = 0;
toTransfer += oldBalance;
}
startTime[msg.sender] = block.timestamp;
hwToken.mint(msg.sender, toTransfer);
emit YieldWithdraw(msg.sender, toTransfer);
}
The calculateYieldTime()
function return the time elapsed since a user's most recent stake. It subtracts the startTime
timestamp by the current timestamp to derive the time that should be used in the calculation of the user's yield.
function calculateYieldTime(address user) public view returns(uint256){
uint256 end = block.timestamp;
uint256 totalTime = end - startTime[user];
return totalTime;
}
The calculateYieldTotal()
function takes the return value from the calculateYieldTime
function and multiplies it by 10¹⁸.
This is necessary as Solidity does not handle floating point or fractional numbers. By returning the timestamp difference as a BigNumber, Solidity can provide much more precision.
The rate
variable equates to 86,400 which equals the number of seconds in a single day.
The calclated yield is such that the user receives 100% of their staked mock DAI quantity every 24 hours in HWToken
s.
The time
variable is divided by the hardcoded rate (86400). The resulting quotient is multiplied by the mock DAI staking balance of the user and subsequently divided by 10¹⁸.
In a more traditional yield farm, the rate is a factor of the user’s staked percentage of the pool instead of time.
function calculateYieldTotal(address user) public view returns(uint256) {
uint256 time = calculateYieldTime(user) * 10**18;
uint256 rate = 86400;
uint256 timeRate = time / rate;
uint256 rawYield = (stakingBalance[user] * timeRate) / 10**18;
return rawYield;
}
Taking the time to create tests early on saves you (and/or your team) from future technical debt. I’d like to suggest testing your code as you write it. Unit testing functions as you write them can save you a ton of time refactoring in the future.
To be continued...