// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @title MuseBankVault /// @notice Permissionless staking vault: deposit $musebank, earn $musebook. /// @dev Trading-fee revenue arrives as $musebook (the token $musebank is paired /// with) and is distributed pro-rata to stakers via accRewardsPerShare. /// More $musebank trading volume -> more fee revenue -> more per staker. /// /// Trustless by design (verify, don't trust): /// - No owner, no admin keys, no upgrades. Immutable. /// - 1:1 share pricing forever: the vault never invests or diverts deposits. /// - vMB receipt shares are transferable; reward accounting settles on /// every transfer so rewards can't be stolen via share transfers. /// - Revenue deposited while there are no stakers reverts, so rewards can /// never be locked with no claimants. /// - The vault holds no ETH and accepts none: plain ETH transfers revert. contract MuseBankVault is ERC20, ReentrancyGuard { using SafeERC20 for IERC20; /// @notice Token stakers deposit. Immutable. IERC20 public immutable musebank; /// @notice Token stakers earn ($musebook). Immutable. IERC20 public immutable rewardToken; /// @notice Accumulated $musebook revenue per staked share, scaled by PRECISION. uint256 public accRewardsPerShare; /// @notice $musebook rewards accrued but not yet claimed per user. mapping(address => uint256) public rewards; /// @notice Per-user checkpoint of accRewardsPerShare already accounted for. mapping(address => uint256) public rewardDebt; uint256 private constant PRECISION = 1e18; error NoStakers(); error ZeroAmount(); error InsufficientShares(); event Deposit(address indexed user, uint256 musebankAmount, uint256 sharesMinted); event Withdraw(address indexed user, uint256 sharesBurned, uint256 musebankAmount); event RewardsClaimed(address indexed user, uint256 rewardAmount); event RevenueReceived(address indexed from, uint256 rewardAmount, uint256 newAccRewardsPerShare); constructor(address _musebank, address _rewardToken) ERC20("Vault MuseBank", "vMB") { require(_musebank != address(0) && _rewardToken != address(0), "zero token"); require(_musebank != _rewardToken, "same token"); musebank = IERC20(_musebank); rewardToken = IERC20(_rewardToken); } /// @notice Deposit $musebook trading-fee revenue for distribution to stakers. /// @dev Pulls reward tokens from the caller via allowance. Anyone may call: /// the keeper, or a launch that pays the vault directly. Reverts when /// empty so revenue is never locked with no claimants. function notifyRevenue(uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); uint256 supply = totalSupply(); if (supply == 0) revert NoStakers(); rewardToken.safeTransferFrom(msg.sender, address(this), amount); accRewardsPerShare += (amount * PRECISION) / supply; emit RevenueReceived(msg.sender, amount, accRewardsPerShare); } /// @notice Stake $musebank and mint 1:1 vMB receipt shares. function deposit(uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); _settle(msg.sender); musebank.safeTransferFrom(msg.sender, address(this), amount); _mint(msg.sender, amount); emit Deposit(msg.sender, amount, amount); } /// @notice Burn vMB shares and return the underlying $musebank. /// @dev Pending $musebook rewards are settled first; call claim() to collect. function withdraw(uint256 shares) external nonReentrant { if (shares == 0) revert ZeroAmount(); if (balanceOf(msg.sender) < shares) revert InsufficientShares(); _settle(msg.sender); _burn(msg.sender, shares); musebank.safeTransfer(msg.sender, shares); emit Withdraw(msg.sender, shares, shares); } /// @notice Collect all accrued $musebook rewards. function claim() external nonReentrant { _settle(msg.sender); uint256 amount = rewards[msg.sender]; if (amount == 0) revert ZeroAmount(); rewards[msg.sender] = 0; rewardToken.safeTransfer(msg.sender, amount); emit RewardsClaimed(msg.sender, amount); } /// @notice View total claimable $musebook for a user (accrued + freshly earned). function pendingRewards(address user) external view returns (uint256) { uint256 gross = (balanceOf(user) * accRewardsPerShare) / PRECISION; uint256 debt = rewardDebt[user]; uint256 fresh = gross > debt ? gross - debt : 0; return rewards[user] + fresh; } /// @notice Total $musebank currently staked (1:1 with share supply). function totalStaked() external view returns (uint256) { return totalSupply(); } /// @dev Accrue freshly earned revenue into rewards[user] and checkpoint debt. function _settle(address user) internal { uint256 gross = (balanceOf(user) * accRewardsPerShare) / PRECISION; uint256 debt = rewardDebt[user]; if (gross > debt) { rewards[user] += gross - debt; } rewardDebt[user] = gross; } /// @dev Settle rewards on share transfers so a recipient can't claim the /// sender's accrued rewards. Mint/burn paths are handled by deposit/withdraw. function _update(address from, address to, uint256 value) internal override { bool isMint = from == address(0); bool isBurn = to == address(0); if (!isMint && !isBurn) { _settle(from); _settle(to); } super._update(from, to, value); if (!isMint) { rewardDebt[from] = (balanceOf(from) * accRewardsPerShare) / PRECISION; } if (!isBurn) { rewardDebt[to] = (balanceOf(to) * accRewardsPerShare) / PRECISION; } } }