// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title HabitProofX StreakVault /// @notice Immutable, no-admin vault. Holds any ERC-20 the user stakes /// (except the stable yield token) and stablecoin fee revenue. /// Isolated per-token pools. Fees from a ramp are attributed to /// the destination token of that ramp — a fake token with no ramps /// earns nothing and cannot siphon another pool. /// Yield is real fee volume, not emissions. /// Self-attested habit cycles do NOT affect payouts. /// New/increased stake of a token earns only from the next weekly epoch. /// Claim pays stablecoin only and never unstakes. /// No proxy. No owner. No pause. No token allowlist. interface IERC20Minimal { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); } contract StreakVault { uint256 public constant MAX_USER_TOKENS = 32; IERC20Minimal public immutable stable; uint256 public constant PRECISION = 1e18; uint64 public immutable deployedAt; uint64 public constant EPOCH = 7 days; uint256 private locked = 1; mapping(address => uint256) public totalStaked; mapping(address => uint256) public totalActive; mapping(address => uint256) public accRewardPerStake; mapping(address => uint256) public undistributedFees; mapping(address => mapping(address => uint256)) public staked; mapping(address => mapping(address => uint256)) public unlockEpoch; mapping(address => mapping(address => bool)) public active; mapping(address => mapping(address => uint256)) public userRewardPerStakePaid; mapping(address => uint256) public rewards; mapping(address => address[]) private _userTokens; mapping(address => mapping(address => bool)) public userHasToken; event Staked(address indexed user, address indexed token, uint256 amount, uint256 unlockEpoch); event Unstaked(address indexed user, address indexed token, uint256 amount); event FeeNotified(address indexed destToken, uint256 received, uint256 active, uint256 undistributed); event Claimed(address indexed user, uint256 payout); event Synced(address indexed user, address indexed token, bool active, uint256 totalActive); error ZeroAmount(); error ZeroAddress(); error TransferFailed(); error NothingStaked(); error NoPayout(); error Reentrant(); error TokenNotAllowed(); error TooManyTokens(); modifier nonReentrant() { if (locked != 1) revert Reentrant(); locked = 2; _; locked = 1; } constructor(address stable_) { if (stable_ == address(0)) revert ZeroAddress(); stable = IERC20Minimal(stable_); deployedAt = uint64(block.timestamp); } function currentEpoch() public view returns (uint256) { return (block.timestamp - deployedAt) / EPOCH; } function userTokenCount(address user) external view returns (uint256) { return _userTokens[user].length; } function userTokenAt(address user, uint256 i) external view returns (address) { return _userTokens[user][i]; } function pending(address user) public view returns (uint256) { uint256 acc = rewards[user]; address[] storage tokens = _userTokens[user]; for (uint256 i = 0; i < tokens.length; i++) { address token = tokens[i]; if (!active[user][token] || staked[user][token] == 0) continue; acc += (staked[user][token] * (accRewardPerStake[token] - userRewardPerStakePaid[user][token])) / PRECISION; } return acc; } function _track(address user, address token) private { if (userHasToken[user][token]) return; if (_userTokens[user].length >= MAX_USER_TOKENS) revert TooManyTokens(); userHasToken[user][token] = true; _userTokens[user].push(token); } function _activateIfReady(address user, address token) private { if (active[user][token] || staked[user][token] == 0) return; if (currentEpoch() < unlockEpoch[user][token]) return; active[user][token] = true; totalActive[token] += staked[user][token]; userRewardPerStakePaid[user][token] = accRewardPerStake[token]; } function _settle(address user, address token) private { if (active[user][token] && staked[user][token] > 0) { rewards[user] += (staked[user][token] * (accRewardPerStake[token] - userRewardPerStakePaid[user][token])) / PRECISION; } userRewardPerStakePaid[user][token] = accRewardPerStake[token]; } function _deactivate(address user, address token) private { if (!active[user][token]) return; totalActive[token] -= staked[user][token]; active[user][token] = false; } function _syncToken(address user, address token) private { _activateIfReady(user, token); _settle(user, token); emit Synced(user, token, active[user][token], totalActive[token]); } function _safeTransfer(IERC20Minimal token, address to, uint256 amount) private { (bool ok, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transfer.selector, to, amount) ); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } function _safeTransferFrom(IERC20Minimal token, address from, address to, uint256 amount) private { (bool ok, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transferFrom.selector, from, to, amount) ); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } function _pull(IERC20Minimal token, address from, uint256 amount) private returns (uint256 received) { uint256 beforeBal = token.balanceOf(address(this)); _safeTransferFrom(token, from, address(this), amount); received = token.balanceOf(address(this)) - beforeBal; if (received == 0) revert TransferFailed(); } function sync(address user) external nonReentrant { address[] storage tokens = _userTokens[user]; for (uint256 i = 0; i < tokens.length; i++) { _syncToken(user, tokens[i]); } } function stake(address token, uint256 amount) external nonReentrant { _stake(token, amount); } function _stake(address token, uint256 amount) private { if (amount == 0) revert ZeroAmount(); if (token == address(0) || token == address(stable) || token == address(this)) revert TokenNotAllowed(); _track(msg.sender, token); _activateIfReady(msg.sender, token); _settle(msg.sender, token); _deactivate(msg.sender, token); uint256 received = _pull(IERC20Minimal(token), msg.sender, amount); staked[msg.sender][token] += received; totalStaked[token] += received; unlockEpoch[msg.sender][token] = currentEpoch() + 1; emit Staked(msg.sender, token, received, unlockEpoch[msg.sender][token]); } function unstake(address token, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); if (staked[msg.sender][token] < amount) revert NothingStaked(); _activateIfReady(msg.sender, token); _settle(msg.sender, token); _deactivate(msg.sender, token); staked[msg.sender][token] -= amount; totalStaked[token] -= amount; if (staked[msg.sender][token] > 0) { unlockEpoch[msg.sender][token] = currentEpoch() + 1; } _safeTransfer(IERC20Minimal(token), msg.sender, amount); emit Unstaked(msg.sender, token, amount); } /// @notice Attribute `amount` of stablecoin fees to the destination token's pool. /// Only stakers of `destToken` earn this slice. Tokens with no ramps earn 0. function notifyFee(address destToken, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); if (destToken == address(0) || destToken == address(stable) || destToken == address(this)) { revert TokenNotAllowed(); } uint256 received = _pull(stable, msg.sender, amount); uint256 activeStake = totalActive[destToken]; if (activeStake == 0) { undistributedFees[destToken] += received; } else { uint256 pot = received + undistributedFees[destToken]; undistributedFees[destToken] = 0; accRewardPerStake[destToken] += (pot * PRECISION) / activeStake; } emit FeeNotified(destToken, received, activeStake, undistributedFees[destToken]); } /// @notice Pays accrued stablecoin yield only. Does not unstake. function claim() external nonReentrant { address[] storage tokens = _userTokens[msg.sender]; for (uint256 i = 0; i < tokens.length; i++) { _activateIfReady(msg.sender, tokens[i]); _settle(msg.sender, tokens[i]); } uint256 payout = rewards[msg.sender]; if (payout == 0) revert NoPayout(); rewards[msg.sender] = 0; _safeTransfer(stable, msg.sender, payout); emit Claimed(msg.sender, payout); } }