// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {TarnSwap} from "./TarnSwap.sol"; /// @title TarnVault — tUSDG /// @notice Deposit USDG, hold tUSDG. Every dollar is placed in one ERC-4626 source vault fixed at deployment /// (Steakhouse USDG on Morpho), so the interest it earns raises what each tUSDG redeems for. /// /// The vault also remembers what each holder PUT IN (their principal). `harvest` pays out only the /// value above that line — as USDG, or swapped into any stock through TarnSwap — and leaves the /// principal where it is. That is the whole product: keep the dollars, spend the interest. /// /// @dev No owner, no fee, no upgrade, no pause, no sweep. Principal follows the shares: a transfer of x% of /// an account's tUSDG moves x% of its principal with it, so interest cannot be manufactured by moving /// tokens between wallets. contract TarnVault is ERC4626 { using SafeERC20 for IERC20; IERC4626 public immutable source; TarnSwap public immutable swapper; /// @notice USDG each account has put in and not yet taken out, adjusted pro rata for transfers. mapping(address => uint256) public principalOf; error NothingToHarvest(); event Harvested(address indexed account, address indexed tokenOut, uint24 fee, uint256 interest, uint256 amountOut); constructor(IERC4626 source_, TarnSwap swapper_) ERC20("Tarn Dollar", "tUSDG") ERC4626(IERC20(source_.asset())) { source = source_; swapper = swapper_; IERC20(source_.asset()).forceApprove(address(source_), type(uint256).max); } // ---------------------------------------------------------------- accounting /// @dev Six extra decimals of virtual shares: a first depositor cannot round the next one down to nothing. function _decimalsOffset() internal pure override returns (uint8) { return 6; } /// @notice USDG the vault could redeem its source shares for. Tokens sent to the vault directly are not /// counted, so a donation cannot move the share price. function totalAssets() public view override returns (uint256) { return source.previewRedeem(source.balanceOf(address(this))); } /// @notice The part of an account's tUSDG that is worth more than what it put in. function interestOf(address account) public view returns (uint256) { uint256 value = previewRedeem(balanceOf(account)); uint256 principal = principalOf[account]; return value > principal ? value - principal : 0; } // maxDeposit / maxMint / maxWithdraw / maxRedeem are OpenZeppelin's defaults on purpose. The source is a // Morpho Vault V2, which answers 0 to every max* query by design (its gates make the true limit unknowable), // so asking it would refuse every deposit. If the source cannot pay out, its own withdraw reverts. // ---------------------------------------------------------------- flows function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { super._deposit(caller, receiver, assets, shares); source.deposit(assets, address(this)); principalOf[receiver] += assets; } function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) internal override { if (caller != owner) _spendAllowance(owner, caller, shares); uint256 balance = balanceOf(owner); _burn(owner, shares); // Taking out x% of the shares takes out x% of the principal, so what is left keeps its interest. principalOf[owner] -= Math.mulDiv(principalOf[owner], shares, balance); source.withdraw(assets, receiver, address(this)); emit Withdraw(caller, receiver, owner, assets, shares); } function _update(address from, address to, uint256 value) internal override { uint256 fromBalance = from == address(0) ? 0 : balanceOf(from); super._update(from, to, value); if (from != address(0) && to != address(0) && from != to && value != 0) { uint256 moved = Math.mulDiv(principalOf[from], value, fromBalance); principalOf[from] -= moved; principalOf[to] += moved; } } /// @notice Pay out the caller's interest, leaving their principal in the vault. /// @param tokenOut USDG to take the interest as dollars, or a stock to have it bought through TarnSwap. /// @param fee The Uniswap v3 fee tier of the USDG/tokenOut pool to buy through (ignored for USDG). /// @param minOut The least the caller will accept, in tokenOut's units. /// @param deadline Unix time after which the call reverts instead of trading. function harvest(address tokenOut, uint24 fee, uint256 minOut, uint256 deadline) external returns (uint256 interest, uint256 amountOut) { interest = interestOf(msg.sender); if (interest == 0) revert NothingToHarvest(); uint256 shares = previewWithdraw(interest); uint256 balance = balanceOf(msg.sender); if (shares > balance) { shares = balance; interest = previewRedeem(shares); if (interest == 0) revert NothingToHarvest(); } // The principal is deliberately left untouched: only the shares worth the interest are burned. _burn(msg.sender, shares); emit Withdraw(msg.sender, address(this), msg.sender, interest, shares); IERC20 usdg = IERC20(asset()); if (tokenOut == address(usdg)) { if (block.timestamp > deadline) revert TarnSwap.Expired(); if (interest < minOut) revert TarnSwap.TooLittleReceived(interest, minOut); source.withdraw(interest, msg.sender, address(this)); amountOut = interest; } else { source.withdraw(interest, address(this), address(this)); usdg.forceApprove(address(swapper), interest); amountOut = swapper.swapExactIn(address(usdg), tokenOut, fee, interest, minOut, msg.sender, deadline); } emit Harvested(msg.sender, tokenOut, fee, interest, amountOut); } }