Solidity Project: Yield Vault
A yield vault is a contract that takes deposits, mints shares for them, and lets each share redeem for a growing amount as yield arrives. This project combines share minting, pro-rata withdrawals, and a harvest fee into one working vault.
Deposits mint shares in proportion to the vault
A share is a slice of the vault. The first deposit mints shares one for one, and later deposits mint shares in proportion to what is already there.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract ToyToken {
string public name;
address public owner;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory tokenName) {
name = tokenName;
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address to, uint256 amount) public onlyOwner {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "low balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "low balance");
require(allowance[from][msg.sender] >= amount, "low allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
return true;
}
}
contract YieldVault {
ToyToken public asset;
address public owner;
address public feeRecipient;
uint256 public totalShares;
uint256 public invested;
uint256 public constant FEE_BPS = 100;
mapping(address => uint256) public shares;
constructor(ToyToken asset_, address feeRecipient_) {
asset = asset_;
owner = msg.sender;
feeRecipient = feeRecipient_;
}
function deposit(uint256 amount) public {
uint256 balanceBefore = asset.balanceOf(address(this));
asset.transferFrom(msg.sender, address(this), amount);
uint256 minted;
if (totalShares == 0) {
minted = amount;
} else {
minted = (amount * totalShares) / balanceBefore;
}
shares[msg.sender] += minted;
totalShares += minted;
invested += amount;
}
function withdraw(uint256 shareAmount) public {
require(shares[msg.sender] >= shareAmount, "low shares");
uint256 assets = (shareAmount * asset.balanceOf(address(this))) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
invested -= assets;
asset.transfer(msg.sender, assets);
}
function harvest() public {
require(msg.sender == owner, "not owner");
uint256 balance = asset.balanceOf(address(this));
require(balance > invested, "no yield");
uint256 yieldAmount = balance - invested;
uint256 fee = (yieldAmount * FEE_BPS) / 10000;
asset.transfer(feeRecipient, fee);
invested = asset.balanceOf(address(this));
}
}
Yield raises what each share is worth
Yield is extra tokens that arrive in the vault without new shares being minted. Each old share then redeems for more than it was deposited for.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract ToyToken {
string public name;
address public owner;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory tokenName) {
name = tokenName;
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address to, uint256 amount) public onlyOwner {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "low balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "low balance");
require(allowance[from][msg.sender] >= amount, "low allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
return true;
}
}
contract YieldVault {
ToyToken public asset;
address public owner;
address public feeRecipient;
uint256 public totalShares;
uint256 public invested;
uint256 public constant FEE_BPS = 100;
mapping(address => uint256) public shares;
constructor(ToyToken asset_, address feeRecipient_) {
asset = asset_;
owner = msg.sender;
feeRecipient = feeRecipient_;
}
function deposit(uint256 amount) public {
uint256 balanceBefore = asset.balanceOf(address(this));
asset.transferFrom(msg.sender, address(this), amount);
uint256 minted;
if (totalShares == 0) {
minted = amount;
} else {
minted = (amount * totalShares) / balanceBefore;
}
shares[msg.sender] += minted;
totalShares += minted;
invested += amount;
}
function withdraw(uint256 shareAmount) public {
require(shares[msg.sender] >= shareAmount, "low shares");
uint256 assets = (shareAmount * asset.balanceOf(address(this))) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
invested -= assets;
asset.transfer(msg.sender, assets);
}
function harvest() public {
require(msg.sender == owner, "not owner");
uint256 balance = asset.balanceOf(address(this));
require(balance > invested, "no yield");
uint256 yieldAmount = balance - invested;
uint256 fee = (yieldAmount * FEE_BPS) / 10000;
asset.transfer(feeRecipient, fee);
invested = asset.balanceOf(address(this));
}
}
Harvest takes a fixed fee from the yield
Harvest is collecting the yield and paying the fee from it. The fee here is 100 basis points, which is 1 percent of the yield only.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract ToyToken {
string public name;
address public owner;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory tokenName) {
name = tokenName;
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address to, uint256 amount) public onlyOwner {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "low balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "low balance");
require(allowance[from][msg.sender] >= amount, "low allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
return true;
}
}
contract YieldVault {
ToyToken public asset;
address public owner;
address public feeRecipient;
uint256 public totalShares;
uint256 public invested;
uint256 public constant FEE_BPS = 100;
mapping(address => uint256) public shares;
constructor(ToyToken asset_, address feeRecipient_) {
asset = asset_;
owner = msg.sender;
feeRecipient = feeRecipient_;
}
function deposit(uint256 amount) public {
uint256 balanceBefore = asset.balanceOf(address(this));
asset.transferFrom(msg.sender, address(this), amount);
uint256 minted;
if (totalShares == 0) {
minted = amount;
} else {
minted = (amount * totalShares) / balanceBefore;
}
shares[msg.sender] += minted;
totalShares += minted;
invested += amount;
}
function withdraw(uint256 shareAmount) public {
require(shares[msg.sender] >= shareAmount, "low shares");
uint256 assets = (shareAmount * asset.balanceOf(address(this))) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
invested -= assets;
asset.transfer(msg.sender, assets);
}
function harvest() public {
require(msg.sender == owner, "not owner");
uint256 balance = asset.balanceOf(address(this));
require(balance > invested, "no yield");
uint256 yieldAmount = balance - invested;
uint256 fee = (yieldAmount * FEE_BPS) / 10000;
asset.transfer(feeRecipient, fee);
invested = asset.balanceOf(address(this));
}
}
Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract ToyToken {
string public name;
address public owner;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory tokenName) {
name = tokenName;
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function mint(address to, uint256 amount) public onlyOwner {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) public returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "low balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(balanceOf[from] >= amount, "low balance");
require(allowance[from][msg.sender] >= amount, "low allowance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
allowance[from][msg.sender] -= amount;
return true;
}
}
contract YieldVault {
ToyToken public asset;
address public owner;
address public feeRecipient;
uint256 public totalShares;
uint256 public invested;
uint256 public constant FEE_BPS = 100;
mapping(address => uint256) public shares;
constructor(ToyToken asset_, address feeRecipient_) {
asset = asset_;
owner = msg.sender;
feeRecipient = feeRecipient_;
}
function deposit(uint256 amount) public {
uint256 balanceBefore = asset.balanceOf(address(this));
asset.transferFrom(msg.sender, address(this), amount);
uint256 minted;
if (totalShares == 0) {
minted = amount;
} else {
minted = (amount * totalShares) / balanceBefore;
}
shares[msg.sender] += minted;
totalShares += minted;
invested += amount;
}
function withdraw(uint256 shareAmount) public {
require(shares[msg.sender] >= shareAmount, "low shares");
uint256 assets = (shareAmount * asset.balanceOf(address(this))) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
invested -= assets;
asset.transfer(msg.sender, assets);
}
function harvest() public {
require(msg.sender == owner, "not owner");
uint256 balance = asset.balanceOf(address(this));
require(balance > invested, "no yield");
uint256 yieldAmount = balance - invested;
uint256 fee = (yieldAmount * FEE_BPS) / 10000;
asset.transfer(feeRecipient, fee);
invested = asset.balanceOf(address(this));
}
}
Key points
- Share: A share is a proportional claim on everything the vault holds.
- Deposit: A deposit mints new shares in proportion to the vault's current size.
- Yield: Yield is extra tokens that arrive in the vault without minting new shares.
- Pro-rata withdrawal: A pro-rata withdrawal returns the same fraction of the vault as the fraction of shares redeemed.
- Harvest fee: The harvest fee takes 1 percent of the yield, never of the deposits.
In this example, two depositors put in 1000 tokens each, then 200 tokens of yield arrive and harvest takes a 2 token fee. Each depositor withdraws all their shares for 1099 tokens, so the 2198 tokens left split evenly between them.
Note
The yield here is donated by a transfer in the toy contract, standing in for earnings from a real strategy.
Try it Yourself
Edit the code, then press Run.
Running is simulated · Remix IDE
Exercise
A vault holds 2000 tokens for 2000 shares. Yield of 200 tokens arrives and harvest takes a 1 percent fee. How many tokens does a holder of 1000 shares get on full withdrawal?