// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /* ============================================================================= Yieldpad — permissionless yield markets on Robinhood Chain. A creator names a yield, points it at a strategy and launches a vault. The vault is the market: anyone deposits the underlying asset, shares track a price that only moves when real tokens arrive, and the creator earns a performance fee on the yield and nothing else. The one property everything else rests on: TVL can never be inflated by a claim. `deployedPrincipal` only ever goes *up* when tokens actually leave the vault, and only ever comes *down* when tokens actually come back (or when the strategist admits a loss). There is no function that lets anyone write a larger number into it. ========================================================================== */ interface IERC20 { function balanceOf(address) external view returns (uint256); function transfer(address, uint256) external returns (bool); function transferFrom(address, address, uint256) external returns (bool); function allowance(address, address) external view returns (uint256); function approve(address, uint256) external returns (bool); function decimals() external view returns (uint8); function symbol() external view returns (string memory); function name() external view returns (string memory); function totalSupply() external view returns (uint256); } /* Tokens on this chain are not uniformly well-behaved: some return nothing at all from transfer(). Accept an empty return, reject an explicit false. */ library Safe { function xfer(address t, address to, uint256 v) internal { (bool ok, bytes memory d) = t.call(abi.encodeWithSelector(IERC20.transfer.selector, to, v)); require(ok && (d.length == 0 || abi.decode(d, (bool))), "transfer failed"); } function xferFrom(address t, address from, address to, uint256 v) internal { (bool ok, bytes memory d) = t.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, v)); require(ok && (d.length == 0 || abi.decode(d, (bool))), "transferFrom failed"); } } /* ----------------------------------------------------------------------------- The market itself. -------------------------------------------------------------------------- */ contract YieldVault { using Safe for address; /* --- share token --- */ string public name; string public symbol; uint8 public immutable decimals; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; /* --- market shape, fixed at launch and never editable --- */ address public immutable asset; address public immutable factory; address public immutable creator; // the strategist address public immutable strategy; // the only address capital may be deployed to address public immutable protocolFeeTo; uint16 public immutable maxDeployBps; // hard ceiling on capital at the strategy; 0 = never leaves uint16 public immutable performanceFeeBps; uint256 public immutable depositCap; // 0 = uncapped uint64 public immutable launchedAt; uint16 public constant PROTOCOL_CUT_BPS = 2000; // protocol's share of the performance fee uint256 internal constant BPS = 10_000; uint256 internal constant SEED_SHARES = 1e3; // burned on the first deposit /* --- live accounting --- */ uint256 public deployedPrincipal; // asset units sitting at `strategy` uint256 public lastTotalAssets; // watermark the fee is charged against uint256 public totalYield; // lifetime gross yield recognised uint256 public totalDeposited; // lifetime gross deposits uint256 public totalWithdrawn; uint32 public depositors; mapping(address => bool) internal everDeposited; uint256 internal entered = 1; modifier lock() { require(entered == 1, "reentrant"); entered = 2; _; entered = 1; } event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); event Deposit(address indexed caller, address indexed owner, uint256 assets, uint256 shares); event Withdraw(address indexed caller, address indexed owner, uint256 assets, uint256 shares); event Report(uint256 totalAssets, uint256 gain, uint256 feeShares, uint256 pricePerShare); event Loss(uint256 from, uint256 to); event CapitalDeployed(uint256 amount, uint256 deployedPrincipal); event CapitalCollected(uint256 amount, uint256 principal, uint256 yieldAmount); event YieldFunded(address indexed from, uint256 amount); event MarkedDown(uint256 from, uint256 to); constructor( address asset_, address creator_, address strategy_, address protocolFeeTo_, string memory name_, string memory symbol_, uint16 maxDeployBps_, uint16 performanceFeeBps_, uint256 depositCap_ ) { asset = asset_; factory = msg.sender; creator = creator_; strategy = strategy_; protocolFeeTo = protocolFeeTo_; name = name_; symbol = symbol_; decimals = IERC20(asset_).decimals(); maxDeployBps = maxDeployBps_; performanceFeeBps = performanceFeeBps_; depositCap = depositCap_; launchedAt = uint64(block.timestamp); } /* --- views --------------------------------------------------------------- */ function idleAssets() public view returns (uint256) { return IERC20(asset).balanceOf(address(this)); } function totalAssets() public view returns (uint256) { return idleAssets() + deployedPrincipal; } function one() public view returns (uint256) { return 10 ** decimals; } function pricePerShare() public view returns (uint256) { uint256 s = totalSupply; return s == 0 ? one() : (totalAssets() * one()) / s; } function convertToShares(uint256 assets) public view returns (uint256) { uint256 s = totalSupply; uint256 ta = totalAssets(); return (s == 0 || ta == 0) ? assets : (assets * s) / ta; } function convertToAssets(uint256 shares) public view returns (uint256) { uint256 s = totalSupply; return s == 0 ? shares : (shares * totalAssets()) / s; } /* What a holder can take out right now — capital at the strategy is not liquid, and the vault says so rather than pretending. */ function maxWithdraw(address owner) external view returns (uint256) { uint256 v = convertToAssets(balanceOf[owner]); uint256 idle = idleAssets(); return v < idle ? v : idle; } /* Ceiling on capital that may sit at the strategy at any one time. */ function deployableNow() public view returns (uint256) { uint256 ceiling = (totalAssets() * maxDeployBps) / BPS; if (ceiling <= deployedPrincipal) return 0; uint256 room = ceiling - deployedPrincipal; uint256 idle = idleAssets(); return room < idle ? room : idle; } /* --- the market ---------------------------------------------------------- */ function deposit(uint256 assets, address receiver) external lock returns (uint256 shares) { require(assets > 0, "zero"); require(receiver != address(0), "receiver"); uint256 ta = _report(); uint256 supply = totalSupply; require(supply == 0 || ta > 0, "impaired"); if (depositCap != 0) require(ta + assets <= depositCap, "cap reached"); shares = supply == 0 ? assets : (assets * supply) / ta; require(shares > 0, "dust"); asset.xferFrom(msg.sender, address(this), assets); if (supply == 0) { /* First deposit seeds a floor of dead shares so the price per share cannot be walked up under the next depositor. */ require(shares > SEED_SHARES, "seed too small"); _mint(address(0xdEaD), SEED_SHARES); shares -= SEED_SHARES; } _mint(receiver, shares); lastTotalAssets = ta + assets; totalDeposited += assets; if (!everDeposited[receiver]) { everDeposited[receiver] = true; depositors += 1; } emit Deposit(msg.sender, receiver, assets, shares); } function withdraw(uint256 shares, address receiver) external lock returns (uint256 assets) { require(shares > 0 && shares <= balanceOf[msg.sender], "shares"); require(receiver != address(0), "receiver"); uint256 ta = _report(); assets = (shares * ta) / totalSupply; require(assets > 0, "dust"); require(assets <= idleAssets(), "not liquid"); _burn(msg.sender, shares); lastTotalAssets = ta - assets; totalWithdrawn += assets; asset.xfer(receiver, assets); emit Withdraw(msg.sender, receiver, assets, shares); } /* --- the strategy side --------------------------------------------------- */ /* Move idle capital to the declared strategy, never past the ceiling the market was launched with. */ function deployCapital(uint256 amount) external lock { require(msg.sender == creator, "not strategist"); require(strategy != address(0) && maxDeployBps > 0, "custody locked"); require(amount > 0, "zero"); uint256 ta = _report(); require(deployedPrincipal + amount <= (ta * maxDeployBps) / BPS, "over ceiling"); require(amount <= idleAssets(), "not idle"); deployedPrincipal += amount; asset.xfer(strategy, amount); // totalAssets is unchanged emit CapitalDeployed(amount, deployedPrincipal); } /* Anyone may pull capital back from the strategy once it has approved the vault. Principal is credited first; anything beyond it is yield. */ function collect(uint256 amount) external lock { require(strategy != address(0), "no strategy"); require(amount > 0, "zero"); asset.xferFrom(strategy, address(this), amount); uint256 principal = amount < deployedPrincipal ? amount : deployedPrincipal; deployedPrincipal -= principal; _report(); emit CapitalCollected(amount, principal, amount - principal); } /* Yield that comes from somewhere other than the strategy contract — an RWA coupon, an operator sweeping LP fees in, a subsidy. */ function fund(uint256 amount) external lock { require(amount > 0, "zero"); asset.xferFrom(msg.sender, address(this), amount); _report(); emit YieldFunded(msg.sender, amount); } /* The strategist can only ever write a *smaller* number here. */ function markDown(uint256 newPrincipal) external lock { require(msg.sender == creator, "not strategist"); require(newPrincipal < deployedPrincipal, "only down"); emit MarkedDown(deployedPrincipal, newPrincipal); deployedPrincipal = newPrincipal; _report(); } /* Recognise whatever arrived since the last look. Callable by anyone. */ function sync() external lock returns (uint256) { return _report(); } function _report() internal returns (uint256 ta) { ta = totalAssets(); uint256 last = lastTotalAssets; if (ta > last) { uint256 gain = ta - last; totalYield += gain; uint256 shares; uint256 fee = (gain * performanceFeeBps) / BPS; if (fee > 0 && totalSupply > 0 && ta > fee) { /* Shares worth `fee` at the post-gain price: existing holders are diluted by exactly the fee and not a wei more. */ shares = (fee * totalSupply) / (ta - fee); uint256 cut = (shares * PROTOCOL_CUT_BPS) / BPS; if (cut > 0) _mint(protocolFeeTo, cut); if (shares > cut) _mint(creator, shares - cut); } emit Report(ta, gain, shares, pricePerShare()); } else if (ta < last) { emit Loss(last, ta); } lastTotalAssets = ta; } /* --- share token plumbing ------------------------------------------------ */ function transfer(address to, uint256 v) external returns (bool) { _move(msg.sender, to, v); return true; } function approve(address spender, uint256 v) external returns (bool) { allowance[msg.sender][spender] = v; emit Approval(msg.sender, spender, v); return true; } function transferFrom(address from, address to, uint256 v) external returns (bool) { uint256 a = allowance[from][msg.sender]; if (a != type(uint256).max) { require(a >= v, "allowance"); allowance[from][msg.sender] = a - v; } _move(from, to, v); return true; } function _move(address from, address to, uint256 v) internal { require(to != address(0), "to"); uint256 b = balanceOf[from]; require(b >= v, "balance"); unchecked { balanceOf[from] = b - v; balanceOf[to] += v; } emit Transfer(from, to, v); } function _mint(address to, uint256 v) internal { totalSupply += v; unchecked { balanceOf[to] += v; } emit Transfer(address(0), to, v); } function _burn(address from, uint256 v) internal { uint256 b = balanceOf[from]; require(b >= v, "balance"); unchecked { balanceOf[from] = b - v; totalSupply -= v; } emit Transfer(from, address(0), v); } } /* ----------------------------------------------------------------------------- The launchpad. No owner, no upgrade path, no pause. The launch fee and where it goes are burned in at deploy time. -------------------------------------------------------------------------- */ contract YieldpadFactory { address public immutable feeRecipient; uint256 public immutable launchFee; uint16 public constant MAX_PERFORMANCE_FEE_BPS = 2000; address[] public vaults; mapping(address => bool) public isVault; mapping(address => address[]) public vaultsOf; /* The listing copy lives on-chain, not in a log and not in a database. Reading the catalogue is then a plain call, which survives an explorer being down and cannot drift from what was launched. */ struct Meta { string strategyKind; string description; string logo; string link; } mapping(address => Meta) internal _meta; struct LaunchParams { address asset; string name; string symbol; string strategyKind; // "lending" | "rwa" | "lp-fees" | "liquidations" | "assets" | "custom" string description; string logo; string link; address strategy; uint16 maxDeployBps; uint16 performanceFeeBps; uint256 depositCap; } event MarketLaunched( address indexed vault, address indexed creator, address indexed asset, string name, string symbol, string strategyKind, string description, string logo, string link, address strategy, uint16 maxDeployBps, uint16 performanceFeeBps, uint256 depositCap, uint64 launchedAt ); constructor(address feeRecipient_, uint256 launchFee_) { require(feeRecipient_ != address(0), "feeRecipient"); feeRecipient = feeRecipient_; launchFee = launchFee_; } function launch(LaunchParams calldata p, bytes32 salt) external payable returns (address vault) { require(msg.value >= launchFee, "launch fee"); require(p.asset != address(0), "asset"); require(bytes(p.name).length > 0 && bytes(p.name).length <= 64, "name"); require(bytes(p.symbol).length > 0 && bytes(p.symbol).length <= 16, "symbol"); require(bytes(p.description).length <= 600, "description"); require(p.maxDeployBps <= 10_000, "deploy bps"); require(p.performanceFeeBps <= MAX_PERFORMANCE_FEE_BPS, "fee bps"); require(p.maxDeployBps == 0 || p.strategy != address(0), "strategy"); require(p.strategy != address(this), "strategy"); /* The asset has to be a live ERC-20 or the vault could never price a share; this call reverts the launch if it is not. */ require(IERC20(p.asset).decimals() <= 36, "asset decimals"); vault = address(new YieldVault{ salt: keccak256(abi.encodePacked(msg.sender, salt)) }( p.asset, msg.sender, p.strategy, feeRecipient, p.name, p.symbol, p.maxDeployBps, p.performanceFeeBps, p.depositCap )); vaults.push(vault); isVault[vault] = true; vaultsOf[msg.sender].push(vault); _meta[vault] = Meta(p.strategyKind, p.description, p.logo, p.link); emit MarketLaunched( vault, msg.sender, p.asset, p.name, p.symbol, p.strategyKind, p.description, p.logo, p.link, p.strategy, p.maxDeployBps, p.performanceFeeBps, p.depositCap, uint64(block.timestamp) ); if (msg.value > 0) { (bool ok, ) = feeRecipient.call{ value: msg.value }(""); require(ok, "fee transfer"); } } function meta(address vault) external view returns (Meta memory) { return _meta[vault]; } function vaultCount() external view returns (uint256) { return vaults.length; } function creatorVaultCount(address c) external view returns (uint256) { return vaultsOf[c].length; } /* Newest first, so a front page never has to read the whole array. */ function latest(uint256 skip, uint256 limit) external view returns (address[] memory out) { uint256 n = vaults.length; if (skip >= n) return new address[](0); uint256 take = n - skip; if (take > limit) take = limit; out = new address[](take); for (uint256 i = 0; i < take; i++) out[i] = vaults[n - 1 - skip - i]; } } /* ----------------------------------------------------------------------------- A read-only lens so the whole market table is one eth_call instead of forty. Deployed separately: it holds no state and can be replaced without touching the factory or a single vault. -------------------------------------------------------------------------- */ contract YieldpadLens { struct Market { address vault; address asset; address creator; address strategy; string name; string symbol; string assetSymbol; string strategyKind; string description; string logo; string link; uint8 decimals; uint16 maxDeployBps; uint16 performanceFeeBps; uint256 depositCap; uint256 totalAssets; uint256 idleAssets; uint256 deployedPrincipal; uint256 totalSupply; uint256 pricePerShare; uint256 totalYield; uint256 totalDeposited; uint256 totalWithdrawn; uint32 depositors; uint64 launchedAt; uint256 userShares; uint256 userAssets; } function markets(address factory, address[] calldata vs, address user) external view returns (Market[] memory out) { out = new Market[](vs.length); for (uint256 i = 0; i < vs.length; i++) { YieldVault v = YieldVault(vs[i]); Market memory m; m.vault = vs[i]; m.asset = v.asset(); m.creator = v.creator(); m.strategy = v.strategy(); m.name = v.name(); m.symbol = v.symbol(); m.decimals = v.decimals(); m.maxDeployBps = v.maxDeployBps(); m.performanceFeeBps = v.performanceFeeBps(); m.depositCap = v.depositCap(); m.totalAssets = v.totalAssets(); m.idleAssets = v.idleAssets(); m.deployedPrincipal = v.deployedPrincipal(); m.totalSupply = v.totalSupply(); m.pricePerShare = v.pricePerShare(); m.totalYield = v.totalYield(); m.totalDeposited = v.totalDeposited(); m.totalWithdrawn = v.totalWithdrawn(); m.depositors = v.depositors(); m.launchedAt = v.launchedAt(); try IERC20(m.asset).symbol() returns (string memory s) { m.assetSymbol = s; } catch {} if (factory != address(0)) { YieldpadFactory.Meta memory md = YieldpadFactory(factory).meta(vs[i]); m.strategyKind = md.strategyKind; m.description = md.description; m.logo = md.logo; m.link = md.link; } if (user != address(0)) { m.userShares = v.balanceOf(user); m.userAssets = v.convertToAssets(m.userShares); } out[i] = m; } } /* The whole front page in one call: how many markets exist, and the newest `limit` of them fully expanded. */ function overview(address factory, uint256 skip, uint256 limit, address user) external view returns (uint256 total, uint256 launchFee, Market[] memory out) { YieldpadFactory f = YieldpadFactory(factory); total = f.vaultCount(); launchFee = f.launchFee(); out = this.markets(factory, f.latest(skip, limit), user); } /* Everything a wallet needs before it can deposit, in one call. */ function account(address vault, address user) external view returns ( uint256 assetBalance, uint256 assetAllowance, uint256 shares, uint256 shareValue, uint256 maxWithdrawable, uint256 pricePerShare ) { YieldVault v = YieldVault(vault); IERC20 a = IERC20(v.asset()); assetBalance = a.balanceOf(user); assetAllowance = a.allowance(user, vault); shares = v.balanceOf(user); shareValue = v.convertToAssets(shares); maxWithdrawable = v.maxWithdraw(user); pricePerShare = v.pricePerShare(); } function assetInfo(address token, address user) external view returns ( string memory name_, string memory symbol_, uint8 decimals_, uint256 balance ) { try IERC20(token).name() returns (string memory n) { name_ = n; } catch {} try IERC20(token).symbol() returns (string memory s) { symbol_ = s; } catch {} decimals_ = IERC20(token).decimals(); if (user != address(0)) balance = IERC20(token).balanceOf(user); } }