Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
cache/
out/
.env
.env.local
.env.*.local
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts
1 change: 1 addition & 0 deletions deployments/base-sepolia.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions deployments/base.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions deployments/sepolia.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
14 changes: 14 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.0",
"rev": "8987040ede9553cea20c95ad40d0455930f9c8e0"
}
},
"lib/openzeppelin-contracts": {
"tag": {
"name": "v5.6.1",
"rev": "5fd1781b1454fd1ef8e722282f86f9293cacf256"
}
}
}
16 changes: 16 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = ["lib"]
solc = "0.8.30"
optimizer = true
optimizer_runs = 200
ffi = false
fs_permissions = [{ access = "read-write", path = "./deployments" }]
fuzz = { runs = 256 }

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
base_sepolia = "${BASE_SEPOLIA_RPC_URL}"
830 changes: 830 additions & 0 deletions guide.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at 898704
1 change: 1 addition & 0 deletions lib/openzeppelin-contracts
Submodule openzeppelin-contracts added at 976783
2 changes: 2 additions & 0 deletions remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/
57 changes: 57 additions & 0 deletions script/Deploy.s.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,60 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";

import {YieldSaveVault} from "../src/YieldSaveVault.sol";

contract Deploy is Script {
function run() external returns (YieldSaveVault vault) {
uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY");
address treasury = vm.envAddress("TREASURY");
uint256 feeRate = vm.envOr("FEE_RATE_BPS", uint256(500));

(address usdc, address aUsdc, address pool, string memory network) = _loadNetworkConfig(block.chainid);

vm.startBroadcast(deployerPrivateKey);
vault = new YieldSaveVault(usdc, aUsdc, pool, treasury, feeRate);
vm.stopBroadcast();

_writeDeployment(network, address(vault));
console2.log("YieldSaveVault deployed to", address(vault));
}

function _loadNetworkConfig(uint256 chainId)
internal
view
returns (address usdc, address aUsdc, address pool, string memory network)
{
if (chainId == 11155111) {
return (
vm.envAddress("SEPOLIA_USDC"),
vm.envAddress("SEPOLIA_AUSDC"),
vm.envAddress("SEPOLIA_AAVE_POOL"),
"sepolia"
);
}

if (chainId == 84_532) {
return (
vm.envAddress("BASE_SEPOLIA_USDC"),
vm.envAddress("BASE_SEPOLIA_AUSDC"),
vm.envAddress("BASE_SEPOLIA_AAVE_POOL"),
"base-sepolia"
);
}

revert("unsupported chain");
}

function _writeDeployment(string memory network, address vault) internal {
string memory objectKey = "deployment";
string memory path = string.concat("./deployments/", network, ".json");

vm.serializeAddress(objectKey, "vault", vault);
vm.serializeUint(objectKey, "chainId", block.chainid);
string memory json = vm.serializeUint(objectKey, "block", block.number);
vm.writeJson(json, path);
}
}
24 changes: 24 additions & 0 deletions script/VerifyAddresses.s.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {Script} from "forge-std/Script.sol";
import {console2} from "forge-std/console2.sol";

contract VerifyAddresses is Script {
function run() external view {
if (block.chainid == 11155111) {
console2.log("Network: Sepolia");
console2.log("USDC:", vm.envAddress("SEPOLIA_USDC"));
console2.log("aUSDC:", vm.envAddress("SEPOLIA_AUSDC"));
console2.log("Aave Pool:", vm.envAddress("SEPOLIA_AAVE_POOL"));
return;
}

if (block.chainid == 84_532) {
console2.log("Network: Base Sepolia");
console2.log("USDC:", vm.envAddress("BASE_SEPOLIA_USDC"));
console2.log("aUSDC:", vm.envAddress("BASE_SEPOLIA_AUSDC"));
console2.log("Aave Pool:", vm.envAddress("BASE_SEPOLIA_AAVE_POOL"));
return;
}

revert("unsupported chain");
}
}
176 changes: 176 additions & 0 deletions src/YieldSaveVault.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,179 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";

import {IERC20} from "./interfaces/IERC20.sol";
import {IPool} from "./interfaces/IPool.sol";

contract YieldSaveVault is ReentrancyGuard {
uint256 public constant BPS_DENOMINATOR = 10_000;
uint256 public constant MAX_FEE_BPS = 1_000;

IERC20 public immutable usdc;
IERC20 public immutable aUsdc;
IPool public immutable aavePool;
address public immutable treasury;
uint256 public immutable feeRate;

uint256 public totalShares;
mapping(address => uint256) public userShares;
mapping(address => uint256) public userDeposits;

error ZeroAddress();
error ZeroAmount();
error InvalidFeeRate();
error InsufficientShares();
error ZeroSharesMinted();
error ERC20CallFailed();

event Deposited(address indexed user, uint256 assets, uint256 shares);
event Withdrawn(
address indexed user,
uint256 shares,
uint256 grossAssets,
uint256 fee,
uint256 payout
);

constructor(address usdc_, address aUsdc_, address aavePool_, address treasury_, uint256 feeRate_) {
if (usdc_ == address(0) || aUsdc_ == address(0) || aavePool_ == address(0) || treasury_ == address(0)) {
revert ZeroAddress();
}
if (feeRate_ > MAX_FEE_BPS) revert InvalidFeeRate();

usdc = IERC20(usdc_);
aUsdc = IERC20(aUsdc_);
aavePool = IPool(aavePool_);
treasury = treasury_;
feeRate = feeRate_;
}

function deposit(uint256 amount) external nonReentrant returns (uint256 shares) {
if (amount == 0) revert ZeroAmount();

shares = _previewDeposit(amount, _totalAssets());
if (shares == 0) revert ZeroSharesMinted();

_safeTransferFrom(usdc, msg.sender, address(this), amount);
_forceApprove(usdc, address(aavePool), amount);
aavePool.supply(address(usdc), amount, address(this), 0);

userShares[msg.sender] += shares;
userDeposits[msg.sender] += amount;
totalShares += shares;

emit Deposited(msg.sender, amount, shares);
}

function withdraw(uint256 shares) external nonReentrant returns (uint256 payout) {
if (shares == 0) revert ZeroAmount();

uint256 userShareBalance = userShares[msg.sender];
if (shares > userShareBalance) revert InsufficientShares();

(uint256 grossAssets, uint256 principalPortion, uint256 fee) = _quoteWithdraw(
msg.sender, shares, _totalAssets(), totalShares, userShareBalance
);

payout = grossAssets - fee;

userShares[msg.sender] = userShareBalance - shares;
userDeposits[msg.sender] -= principalPortion;
totalShares -= shares;

aavePool.withdraw(address(usdc), grossAssets, address(this));
_safeTransfer(usdc, msg.sender, payout);
if (fee != 0) _safeTransfer(usdc, treasury, fee);

emit Withdrawn(msg.sender, shares, grossAssets, fee, payout);
}

function getVaultBalance() external view returns (uint256) {
return _totalAssets();
}

function getUserBalance(address user) external view returns (uint256) {
uint256 shares = userShares[user];
if (shares == 0) return 0;

(uint256 payout,,) = _previewWithdrawForUser(user, shares);
return payout;
}

function previewDeposit(uint256 amount) external view returns (uint256) {
return _previewDeposit(amount, _totalAssets());
}

function previewWithdraw(uint256 shares) external view returns (uint256) {
(uint256 payout,,) = _previewWithdrawForUser(msg.sender, shares);
return payout;
}

function previewWithdrawFor(address user, uint256 shares)
external
view
returns (uint256 payout, uint256 grossAssets, uint256 fee)
{
(payout, grossAssets, fee) = _previewWithdrawForUser(user, shares);
}

function _previewWithdrawForUser(address user, uint256 shares)
internal
view
returns (uint256 payout, uint256 grossAssets, uint256 fee)
{
uint256 userShareBalance = userShares[user];
if (shares == 0 || userShareBalance == 0 || shares > userShareBalance) {
return (0, 0, 0);
}

(grossAssets,, fee) = _quoteWithdraw(user, shares, _totalAssets(), totalShares, userShareBalance);
payout = grossAssets - fee;
}

function _previewDeposit(uint256 amount, uint256 assetsBefore) internal view returns (uint256) {
if (amount == 0) return 0;
if (totalShares == 0 || assetsBefore == 0) return amount;
return amount * totalShares / assetsBefore;
}

function _quoteWithdraw(
address user,
uint256 shares,
uint256 assets,
uint256 currentTotalShares,
uint256 userShareBalance
) internal view returns (uint256 grossAssets, uint256 principalPortion, uint256 fee) {
grossAssets = shares * assets / currentTotalShares;
principalPortion = userDeposits[user] * shares / userShareBalance;

uint256 yld = grossAssets > principalPortion ? grossAssets - principalPortion : 0;
fee = yld * feeRate / BPS_DENOMINATOR;
}

function _totalAssets() internal view returns (uint256) {
return aUsdc.balanceOf(address(this));
}

function _safeTransfer(IERC20 token, address to, uint256 amount) internal {
(bool success, bytes memory data) =
address(token).call(abi.encodeCall(IERC20.transfer, (to, amount)));
if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert ERC20CallFailed();
}

function _safeTransferFrom(IERC20 token, address from, address to, uint256 amount) internal {
(bool success, bytes memory data) =
address(token).call(abi.encodeCall(IERC20.transferFrom, (from, to, amount)));
if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert ERC20CallFailed();
}

function _forceApprove(IERC20 token, address spender, uint256 amount) internal {
(bool success, bytes memory data) =
address(token).call(abi.encodeCall(IERC20.approve, (spender, 0)));
if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert ERC20CallFailed();

(success, data) = address(token).call(abi.encodeCall(IERC20.approve, (spender, amount)));
if (!success || (data.length != 0 && !abi.decode(data, (bool)))) revert ERC20CallFailed();
}
}
13 changes: 13 additions & 0 deletions src/interfaces/IERC20.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

interface IERC20 {
function totalSupply() external view returns (uint256);

function balanceOf(address account) external view returns (uint256);

function allowance(address owner, address spender) external view returns (uint256);

function transfer(address to, uint256 value) external returns (bool);

function approve(address spender, uint256 value) external returns (bool);

function transferFrom(address from, address to, uint256 value) external returns (bool);
}
5 changes: 5 additions & 0 deletions src/interfaces/IPool.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

interface IPool {
function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;

function withdraw(address asset, uint256 amount, address to) external returns (uint256);
}
19 changes: 19 additions & 0 deletions test/YieldSaveVault.t.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

import {Fixtures} from "./helpers/Fixtures.sol";

contract YieldSaveVaultSmokeTest is Fixtures {
function test_EndToEnd_DepositYieldWithdrawFlow() public {
uint256 aliceShares = _deposit(alice, 200 * USDC_UNIT);
_deposit(bob, 100 * USDC_UNIT);
_accrueYield(30 * USDC_UNIT);

uint256 alicePreview = vault.getUserBalance(alice);
uint256 bobPreview = vault.getUserBalance(bob);

assertEq(aliceShares, 200 * USDC_UNIT);
assertEq(alicePreview, 219_000_000);
assertEq(bobPreview, 109_500_000);

uint256 alicePayout = _withdraw(alice, aliceShares);
assertEq(alicePayout, 219_000_000);
}
}
Loading
Loading