// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /* Coppice — dollars that earn, and interest that buys stocks. Deposit USDG. It goes to work in one ERC-4626 savings vault, fixed when this contract is deployed and never changeable. What you put in is your principal: only you can take it out, and nothing in this contract can spend it. What the vault earns on top of your principal is yours to harvest whenever you like — as USDG, or swapped on Uniswap v3 into any token you name, delivered straight to your wallet with a minimum you set. No owner. No fee. No pause. No upgrade. No oracle. */ interface IERC20 { function balanceOf(address) external view returns (uint256); } interface IERC4626 { function asset() external view returns (address); function balanceOf(address) external view returns (uint256); function deposit(uint256 assets, address receiver) external returns (uint256 shares); function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); function convertToAssets(uint256 shares) external view returns (uint256); function convertToShares(uint256 assets) external view returns (uint256); } /// Uniswap SwapRouter02 (IV3SwapRouter). Note: no deadline field in this struct. interface ISwapRouter02 { struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; } function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } contract Coppice { /// The dollar this contract takes and pays out. address public immutable usdg; /// The savings vault principal is kept in. Its asset must be `usdg`. IERC4626 public immutable vault; /// Where harvested interest is swapped. ISwapRouter02 public immutable router; struct Position { uint256 shares; // vault shares held for this owner uint256 principal; // USDG put in and not yet taken out } mapping(address => Position) public positions; /// Sum of every position's shares. The vault credits this contract with at /// least this many; anything above it was sent here by someone and belongs /// to no position. uint256 public totalShares; /// Sum of every position's principal. uint256 public totalPrincipal; uint256 private _lock = 1; event Deposited(address indexed owner, uint256 assets, uint256 shares); event Withdrawn(address indexed owner, uint256 assets, uint256 shares); event Harvested(address indexed owner, address indexed tokenOut, uint256 interest, uint256 amountOut); error ZeroAmount(); error Reentrancy(); error VaultAssetMismatch(); error ExceedsPrincipal(); error NothingToHarvest(); error TokenCallFailed(); error PrincipalNotCovered(); error BelowMinimum(); modifier nonReentrant() { if (_lock != 1) revert Reentrancy(); _lock = 2; _; _lock = 1; } constructor(address usdg_, IERC4626 vault_, ISwapRouter02 router_) { if (vault_.asset() != usdg_) revert VaultAssetMismatch(); usdg = usdg_; vault = vault_; router = router_; } /* ------------------------------------------------------------------ */ /* Views */ /* ------------------------------------------------------------------ */ /// What `owner`'s position is worth now, and how much of it is interest. function positionOf(address owner) external view returns (uint256 shares, uint256 principal, uint256 value, uint256 interest) { Position memory p = positions[owner]; shares = p.shares; principal = p.principal; value = shares == 0 ? 0 : vault.convertToAssets(shares); interest = value > principal ? value - principal : 0; } /* ------------------------------------------------------------------ */ /* Writes */ /* ------------------------------------------------------------------ */ /// Put `assets` USDG in. Needs an approval of at least `assets` first. function deposit(uint256 assets) external nonReentrant returns (uint256 shares) { if (assets == 0) revert ZeroAmount(); _call(usdg, abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), assets)); // transferFrom _call(usdg, abi.encodeWithSelector(0x095ea7b3, address(vault), assets)); // approve shares = vault.deposit(assets, address(this)); if (shares == 0) revert ZeroAmount(); Position storage p = positions[msg.sender]; p.shares += shares; p.principal += assets; totalShares += shares; totalPrincipal += assets; emit Deposited(msg.sender, assets, shares); } /// Take `assets` of your principal back out, as USDG. function withdraw(uint256 assets) external nonReentrant returns (uint256 shares) { if (assets == 0) revert ZeroAmount(); Position storage p = positions[msg.sender]; if (assets > p.principal) revert ExceedsPrincipal(); // Effects first. The vault rounds the shares it burns up, and that // rounding comes out of this owner's position and nobody else's: the // subtraction below reverts if the position cannot cover it. p.principal -= assets; totalPrincipal -= assets; uint256 before = vault.balanceOf(address(this)); vault.withdraw(assets, msg.sender, address(this)); shares = before - vault.balanceOf(address(this)); p.shares -= shares; totalShares -= shares; emit Withdrawn(msg.sender, assets, shares); } /// Close the position: every share redeemed, principal and any unharvested /// interest sent to you as USDG. function exit() external nonReentrant returns (uint256 assets) { Position memory p = positions[msg.sender]; if (p.shares == 0) revert ZeroAmount(); delete positions[msg.sender]; totalShares -= p.shares; totalPrincipal -= p.principal; assets = vault.redeem(p.shares, msg.sender, address(this)); emit Withdrawn(msg.sender, assets, p.shares); } /// Take the interest your principal has earned. /// /// `tokenOut == usdg` pays it out as USDG. Anything else swaps it on the /// Uniswap v3 pool `(usdg, tokenOut, fee)` and sends the proceeds to you. /// Either way you receive at least `minOut` or the call reverts. /// /// Only the part of the position worth more than the principal is redeemed, /// rounded down, and the principal is re-checked against what is left /// afterwards, so a harvest can never reach into the money you put in. function harvest(address tokenOut, uint24 fee, uint256 minOut) external nonReentrant returns (uint256 interest, uint256 amountOut) { Position storage p = positions[msg.sender]; uint256 value = p.shares == 0 ? 0 : vault.convertToAssets(p.shares); if (value <= p.principal) revert NothingToHarvest(); uint256 cut = vault.convertToShares(value - p.principal); // rounds down if (cut == 0) revert NothingToHarvest(); p.shares -= cut; totalShares -= cut; interest = vault.redeem(cut, address(this), address(this)); if (vault.convertToAssets(p.shares) < p.principal) revert PrincipalNotCovered(); if (tokenOut == usdg) { if (interest < minOut) revert BelowMinimum(); _call(usdg, abi.encodeWithSelector(0xa9059cbb, msg.sender, interest)); // transfer amountOut = interest; } else { _call(usdg, abi.encodeWithSelector(0x095ea7b3, address(router), interest)); // approve amountOut = router.exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: usdg, tokenOut: tokenOut, fee: fee, recipient: msg.sender, amountIn: interest, amountOutMinimum: minOut, sqrtPriceLimitX96: 0 }) ); } emit Harvested(msg.sender, tokenOut, interest, amountOut); } /* ------------------------------------------------------------------ */ /// A token call that tolerates tokens returning nothing, and refuses any /// that return false. function _call(address token, bytes memory data) private { (bool ok, bytes memory ret) = token.call(data); if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TokenCallFailed(); } }