diff --git a/.gitignore b/.gitignore index 052b88b..d702351 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/ # Soldeer /dependencies +byzantine-* \ No newline at end of file diff --git a/remappings.txt b/remappings.txt index 649576f..1e9753d 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,3 @@ +@openzeppelin-contracts-5.2.0/=dependencies/@openzeppelin-contracts-5.2.0/ forge-std-1.9.5/=dependencies/forge-std-1.9.5/ solmate-6.8.0/=dependencies/solmate-6.8.0/ diff --git a/src/TangleLiquidRestakingVault.sol b/src/TangleLiquidRestakingVault.sol index 6fbf53c..c752c98 100644 --- a/src/TangleLiquidRestakingVault.sol +++ b/src/TangleLiquidRestakingVault.sol @@ -1,30 +1,62 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.20; -import "forge-std/console.sol"; -import {ERC4626} from "../dependencies/solmate-6.8.0/src/tokens/ERC4626.sol"; -import {ERC20} from "../dependencies/solmate-6.8.0/src/tokens/ERC20.sol"; -import {SafeTransferLib} from "../dependencies/solmate-6.8.0/src/utils/SafeTransferLib.sol"; -import {FixedPointMathLib} from "../dependencies/solmate-6.8.0/src/utils/FixedPointMathLib.sol"; -import {MultiAssetDelegation, MULTI_ASSET_DELEGATION_CONTRACT} from "./MultiAssetDelegation.sol"; -import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {ERC4626} from "dependencies/solmate-6.8.0/src/tokens/ERC4626.sol"; +import {ERC20} from "dependencies/solmate-6.8.0/src/tokens/ERC20.sol"; +import {SafeTransferLib} from "dependencies/solmate-6.8.0/src/utils/SafeTransferLib.sol"; +import {FixedPointMathLib} from "dependencies/solmate-6.8.0/src/utils/FixedPointMathLib.sol"; +import {Owned} from "dependencies/solmate-6.8.0/src/auth/Owned.sol"; +import {TangleMultiAssetDelegationWrapper} from "./TangleMultiAssetDelegationWrapper.sol"; /// @title TangleLiquidRestakingVault -/// @notice A vault implementation for liquid restaking that delegates to Tangle operators -/// @dev Implements base asset shares with separate reward tracking using checkpoints -contract TangleLiquidRestakingVault is ERC4626 { +/// @notice ERC4626-compliant vault that implements reward distribution with index-based accounting +/// @dev Key implementation details: +/// 1. Reward Distribution: +/// - Uses an index-based accounting system where each reward token has a global index +/// - Index increases proportionally to (reward amount / total supply) for each reward +/// - User checkpoints store the last seen index to calculate entitled rewards +/// 2. Share Transfers: +/// - Historical rewards always stay with the original holder +/// - New holders start earning from their entry index +/// - Transfers trigger checkpoints for both sender and receiver +/// 3. Precision & Math: +/// - Uses FixedPointMathLib for safe fixed-point calculations +/// - Reward index uses REWARD_FACTOR (1e18) as scale factor +/// - mulDivDown for index updates to prevent accumulating errors +/// - mulDivUp for final reward calculations to prevent dust +/// 4. Delegation: +/// - Deposits are automatically delegated to operator +/// - Curator can update blueprint selection +/// - Withdrawals require unstaking through Tangle runtime +contract TangleLiquidRestakingVault is ERC4626, Owned, TangleMultiAssetDelegationWrapper { using SafeTransferLib for ERC20; using FixedPointMathLib for uint256; /* ============ Constants ============ */ - /// @notice Scalar for reward index calculations + /// @notice Scale factor for reward index calculations + /// @dev Used to maintain precision in reward/share calculations + /// Index calculation: newRewards * REWARD_FACTOR / totalSupply + /// Reward calculation: shareBalance * (currentIndex - checkpointIndex) / REWARD_FACTOR uint256 private constant REWARD_FACTOR = 1e18; /* ============ Events ============ */ + /// @notice Emitted when a new reward token is added event RewardTokenAdded(address indexed token); + + /// @notice Emitted when rewards are claimed by a user + /// @param user The user claiming rewards + /// @param token The reward token being claimed + /// @param amount The amount of rewards claimed event RewardsClaimed(address indexed user, address indexed token, uint256 amount); + + /// @notice Emitted when a new checkpoint is created for a user + /// @param user The user the checkpoint is for + /// @param token The reward token the checkpoint is for + /// @param index The global reward index at checkpoint time + /// @param timestamp When the checkpoint was created + /// @param rewardIndex The index in the rewardTimestamps array event CheckpointCreated( address indexed user, address indexed token, @@ -32,58 +64,115 @@ contract TangleLiquidRestakingVault is ERC4626 { uint256 timestamp, uint256 rewardIndex ); + + /// @notice Emitted when the global reward index is updated + /// @param token The reward token whose index was updated + /// @param index The new global index value + /// @param timestamp When the update occurred event RewardIndexUpdated(address indexed token, uint256 index, uint256 timestamp); + /// @notice Emitted when an unstake request is cancelled + event UnstakeCancelled(address indexed user, uint256 amount); + + /// @notice Emitted when a withdrawal request is cancelled + event WithdrawalCancelled(address indexed user, uint256 amount); + /* ============ Errors ============ */ + /// @notice Attempted to add an invalid reward token (zero address or base asset) error InvalidRewardToken(); + /// @notice Attempted to add a reward token that was already added error RewardTokenAlreadyAdded(); + /// @notice Attempted to claim rewards but none were available error NoRewardsToClaim(); + /// @notice Attempted operation by unauthorized caller error Unauthorized(); + /// @notice Attempted withdrawal without unstaking first + error WithdrawalNotUnstaked(); + /// @notice Attempted to cancel more than scheduled + error ExceedsScheduledAmount(); + /// @notice Attempted to cancel more than scheduled + error InsufficientScheduledAmount(); + /// @notice No scheduled amount to execute + error NoScheduledAmount(); /* ============ Types ============ */ /// @notice Tracks user's reward checkpoint for a token + /// @dev Used to calculate rewards between checkpoint creation and current time + /// Historical rewards are stored in pendingRewards to maintain claim integrity struct Checkpoint { - uint256 rewardIndex; // Global index at checkpoint - uint256 timestamp; // When checkpoint was created - uint256 shareBalance; // User's share balance at checkpoint + uint256 rewardIndex; // Global index at checkpoint creation + uint256 timestamp; // When checkpoint was created + uint256 shareBalance; // User's share balance at checkpoint uint256 lastRewardIndex; // Index in rewardTimestamps array - uint256 pendingRewards; // Pending rewards at checkpoint + uint256 pendingRewards; // Unclaimed rewards at checkpoint } - /// @notice Tracks reward accrual for a token + /// @notice Tracks global reward state for a token + /// @dev Maintains reward distribution history and current index + /// Index increases with each reward based on: newRewards * REWARD_FACTOR / totalSupply struct RewardData { - uint256 index; // Global reward index - uint256 lastUpdateTime; // Last time rewards were updated - bool isValid; // Whether this is a valid reward token - uint256[] rewardTimestamps; // Timestamps when rewards arrived - uint256[] rewardAmounts; // Amount of rewards at each timestamp + uint256 index; // Current global reward index + uint256 lastUpdateTime; // Last index update timestamp + bool isValid; // Whether token is registered + uint256[] rewardTimestamps; // When rewards were received + uint256[] rewardAmounts; // Amount of each reward } /* ============ State Variables ============ */ - /// @notice List of reward tokens + /// @notice List of registered reward tokens + /// @dev Used to iterate all reward tokens for operations like checkpointing address[] public rewardTokens; - /// @notice Reward token information + /// @notice Reward accounting data per token + /// @dev Tracks global indices and reward history mapping(address => RewardData) public rewardData; - /// @notice User checkpoints per token + /// @notice User reward checkpoints per token + /// @dev Maps user => token => checkpoint data mapping(address => mapping(address => Checkpoint)) public userCheckpoints; + /// @notice Tracks unstaking status for withdrawals + mapping(address => uint256) public unstakeAmount; + + /// @notice Tracks scheduled unstake requests + /// @dev Maps user => amount scheduled for unstake + mapping(address => uint256) public scheduledUnstakeAmount; + + /// @notice Tracks scheduled withdraw requests + /// @dev Maps user => amount scheduled for withdrawal + mapping(address => uint256) public scheduledWithdrawAmount; + + /// @notice Operator address for delegation + bytes32 public operator; + + /// @notice Blueprint selection for delegation and exposure + uint64[] public blueprintSelection; + /* ============ Constructor ============ */ constructor( - address _asset, + address _baseToken, + bytes32 _operator, + uint64[] memory _blueprintSelection, + address _mads, string memory _name, string memory _symbol - ) ERC4626(ERC20(_asset), _name, _symbol) {} + ) ERC4626(ERC20(_baseToken), _name, _symbol) TangleMultiAssetDelegationWrapper(_baseToken, _mads) Owned(msg.sender) { + operator = _operator; + blueprintSelection = _blueprintSelection; + } /* ============ External Functions ============ */ - /// @notice Add a new reward token - /// @param token Address of reward token to add + /// @notice Register a new reward token + /// @dev Initializes reward tracking for a new token + /// Requirements: + /// - Token must not be zero address or base asset + /// - Token must not already be registered + /// @param token Address of reward token to register function addRewardToken(address token) external { if (token == address(0) || token == address(asset)) revert InvalidRewardToken(); if (rewardData[token].isValid) revert RewardTokenAlreadyAdded(); @@ -100,10 +189,18 @@ contract TangleLiquidRestakingVault is ERC4626 { emit RewardTokenAdded(token); } - /// @notice Claim rewards for specified tokens + /// @notice Claim accumulated rewards for specified tokens + /// @dev Claims all pending rewards for given tokens + /// For each token: + /// 1. Updates global index to include any new rewards + /// 2. Calculates user's entitled rewards since last checkpoint + /// 3. Transfers rewards and creates new checkpoint + /// Requirements: + /// - Caller must be the user claiming rewards + /// - Tokens must be valid reward tokens /// @param user Address to claim rewards for - /// @param tokens Array of reward token addresses - /// @return rewards Array of claimed amounts + /// @param tokens Array of reward token addresses to claim + /// @return rewards Array of claimed amounts per token function claimRewards( address user, address[] calldata tokens @@ -123,21 +220,86 @@ contract TangleLiquidRestakingVault is ERC4626 { } } + /// @notice Transfer shares to another address + /// @dev Overrides ERC20 transfer to handle reward checkpoints + /// Historical rewards stay with sender, recipient starts fresh + /// @param to Recipient of the shares + /// @param amount Number of shares to transfer + /// @return success Whether transfer succeeded function transfer(address to, uint256 amount) public override returns (bool) { _processRewardCheckpoints(msg.sender, to, amount); return super.transfer(to, amount); } + /// @notice Transfer shares from one address to another + /// @dev Overrides ERC20 transferFrom to handle reward checkpoints + /// Historical rewards stay with sender, recipient starts fresh + /// @param from Sender of the shares + /// @param to Recipient of the shares + /// @param amount Number of shares to transfer + /// @return success Whether transfer succeeded function transferFrom(address from, address to, uint256 amount) public override returns (bool) { _processRewardCheckpoints(from, to, amount); return super.transferFrom(from, to, amount); } + /// @notice Deposit assets into vault and delegate + /// @dev Overrides ERC4626 deposit to handle reward checkpoints and delegation + /// @param assets Amount of assets to deposit + /// @param receiver Recipient of the shares + /// @return shares Amount of shares minted + function deposit( + uint256 assets, + address receiver + ) public override returns (uint256 shares) { + // Calculate shares before minting + shares = previewDeposit(assets); + + // Process checkpoints for new shares + if (rewardTokens.length > 0) { + _updateAllRewardIndices(); + for (uint256 i = 0; i < rewardTokens.length; i++) { + address token = rewardTokens[i]; + + // Calculate rewards earned by existing shares + uint256 existingRewards = _calculatePendingRewards(receiver, token); + + // Create new checkpoint with existing rewards + // New shares will start earning from current index + _createCheckpoint( + receiver, + token, + balanceOf[receiver] + shares, + existingRewards + ); + } + } + + // Complete deposit + super.deposit(assets, receiver); + + // Delegate deposited assets through wrapper + _delegate(operator, assets, blueprintSelection); + } + + /// @notice Execute withdrawal after delay + /// @dev Overrides ERC4626 withdraw to handle reward checkpoints + /// @param assets Amount of assets to withdraw + /// @param receiver Recipient of the assets + /// @param owner Owner of the shares + /// @return shares Amount of shares burned function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256 shares) { + // Verify withdrawal is scheduled and ready + uint256 scheduled = scheduledWithdrawAmount[owner]; + if (assets > scheduled) revert ExceedsScheduledAmount(); + + // Execute withdrawal through MADS first + _executeWithdraw(); + // Calculate shares before burning shares = previewWithdraw(assets); @@ -156,68 +318,148 @@ contract TangleLiquidRestakingVault is ERC4626 { balanceOf[owner] - shares, pendingRewards ); - - console.log("Withdraw - Pending Rewards:", pendingRewards); - console.log("Withdraw - Remaining Shares:", balanceOf[owner] - shares); } } + // Update tracking + scheduledWithdrawAmount[owner] = scheduled - assets; + // Complete withdrawal return super.withdraw(assets, receiver, owner); } - function redeem(uint256 shares, address receiver, address owner) public override returns (uint256 assets) { - _processRewardCheckpoints(owner, address(0), shares); - assets = super.redeem(shares, receiver, owner); - } - - function deposit( - uint256 assets, - address receiver - ) public override returns (uint256 shares) { - // Calculate shares before minting - shares = previewDeposit(assets); - - // Process checkpoints for new shares + /// @notice Redeem shares for assets + /// @dev Overrides ERC4626 redeem to handle reward checkpoints + /// @param shares Amount of shares to redeem + /// @param receiver Recipient of the assets + /// @param owner Owner of the shares + /// @return assets Amount of assets redeemed + function redeem( + uint256 shares, + address receiver, + address owner + ) public override returns (uint256 assets) { + // Process checkpoints before burning shares if (rewardTokens.length > 0) { _updateAllRewardIndices(); for (uint256 i = 0; i < rewardTokens.length; i++) { address token = rewardTokens[i]; - RewardData storage rData = rewardData[token]; - - // Calculate rewards earned by existing shares - uint256 existingRewards = _calculatePendingRewards(receiver, token); + // Calculate pending rewards before redemption + uint256 pendingRewards = _calculatePendingRewards(owner, token); - // Create new checkpoint with existing rewards - // New shares will start earning from current index + // Create checkpoint with remaining shares and all pending rewards _createCheckpoint( - receiver, + owner, token, - balanceOf[receiver] + shares, - existingRewards + balanceOf[owner] - shares, + pendingRewards ); - - console.log("Deposit - Token:", token); - console.log("Deposit - Current Index:", rData.index); - console.log("Deposit - Existing Rewards:", existingRewards); - console.log("Deposit - New Share Balance:", balanceOf[receiver] + shares); } } - // Complete deposit - return super.deposit(assets, receiver); + // Complete redemption + return super.redeem(shares, receiver, owner); + } + + /// @notice Schedule unstaking of assets + /// @dev Must be called before withdrawal can be executed + /// @param assets Amount of assets to unstake + function scheduleUnstake(uint256 assets) external { + uint256 shares = previewWithdraw(assets); + require(balanceOf[msg.sender] >= shares, "Insufficient shares"); + + // Track scheduled unstake + scheduledUnstakeAmount[msg.sender] += assets; + + // Process checkpoints to stop reward accrual for these shares + _processRewardCheckpoints(msg.sender, address(0), shares); + + // Schedule unstake through wrapper + _scheduleUnstake(operator, assets); + + emit UnstakeScheduled(operator, assets); + } + + /// @notice Execute the unstake + /// @dev Must have previously scheduled the unstake + function executeUnstake() external { + uint256 scheduled = scheduledUnstakeAmount[msg.sender]; + if (scheduled == 0) revert NoScheduledAmount(); + + // Execute unstake through wrapper + _executeUnstake(); + + // Update state tracking - move from scheduled to unstaked + unstakeAmount[msg.sender] += scheduled; + scheduledUnstakeAmount[msg.sender] = 0; + } + + /// @notice Schedule withdrawal of assets + /// @dev Must have previously unstaked the assets + /// @param assets Amount of assets to withdraw + function scheduleWithdraw(uint256 assets) external { + // Verify caller has enough unstaked assets + if (unstakeAmount[msg.sender] < assets) revert WithdrawalNotUnstaked(); + + // Track scheduled withdrawal + scheduledWithdrawAmount[msg.sender] += assets; + unstakeAmount[msg.sender] -= assets; + + // Schedule withdraw through wrapper + _scheduleWithdraw(assets); + + emit WithdrawalScheduled(assets); + } + + /// @notice Cancel a scheduled withdrawal + /// @param assets Amount of assets to cancel withdrawal + function cancelWithdraw(uint256 assets) external { + uint256 scheduled = scheduledWithdrawAmount[msg.sender]; + if (assets > scheduled) revert InsufficientScheduledAmount(); + + // Update tracking - return to unstaked state + scheduledWithdrawAmount[msg.sender] = scheduled - assets; + unstakeAmount[msg.sender] += assets; + + // Cancel withdraw through wrapper + _cancelWithdraw(assets); + + // Re-delegate the assets since they're back in the pool + _delegate(operator, assets, blueprintSelection); + + emit WithdrawalCancelled(msg.sender, assets); + } + + /// @notice Cancel a scheduled unstake + /// @param assets Amount of assets to cancel unstaking + function cancelUnstake(uint256 assets) external { + uint256 scheduled = scheduledUnstakeAmount[msg.sender]; + if (assets > scheduled) revert InsufficientScheduledAmount(); + + // Update tracking + scheduledUnstakeAmount[msg.sender] = scheduled - assets; + + // Cancel unstake through wrapper + _cancelUnstake(operator, assets); + + // Process checkpoints to resume reward accrual + uint256 shares = previewWithdraw(assets); + _processRewardCheckpoints(msg.sender, msg.sender, shares); + + emit UnstakeCancelled(msg.sender, assets); } /* ============ Internal Functions ============ */ /// @notice Process reward checkpoints for share transfers - /// @dev When shares are transferred: - /// 1. Update global indices for any new rewards - /// 2. Calculate sender's pending rewards - /// 3. Split pending rewards proportionally: - /// - Sender keeps (remainingShares/totalShares) * pendingRewards - /// - Receiver gets (transferredShares/totalShares) * pendingRewards - /// 4. Create new checkpoints for both parties + /// @dev Core reward accounting logic for transfers + /// Key behaviors: + /// 1. Updates indices to include new rewards + /// 2. Sender keeps all historical rewards + /// 3. Receiver keeps existing rewards, starts fresh with transferred shares + /// @param from Sender address + /// @param to Recipient address (or 0 for burns) + /// @param amount Number of shares being transferred function _processRewardCheckpoints( address from, address to, @@ -228,12 +470,6 @@ contract TangleLiquidRestakingVault is ERC4626 { uint256 fromBalance = balanceOf[from]; uint256 toBalance = balanceOf[to]; - console.log("Process Reward Checkpoints"); - console.log("From:", from); - console.log("To:", to); - console.log("Transfer Amount:", amount); - console.log("From Balance:", fromBalance); - console.log("To Balance:", toBalance); // Update all indices first to capture any new rewards _updateAllRewardIndices(); @@ -244,29 +480,26 @@ contract TangleLiquidRestakingVault is ERC4626 { // Calculate total pending rewards for sender uint256 pendingRewards = _calculatePendingRewards(from, token); - console.log("Token:", token); - console.log("Sender Pending Rewards:", pendingRewards); - // Original holder keeps all historical rewards _createCheckpoint(from, token, fromBalance - amount, pendingRewards); if (to != address(0)) { // Calculate recipient's existing rewards uint256 recipientRewards = _calculatePendingRewards(to, token); - console.log("Recipient Existing Rewards:", recipientRewards); // Recipient keeps their existing rewards and gets new balance _createCheckpoint(to, token, toBalance + amount, recipientRewards); - console.log("Recipient new balance:", toBalance + amount); } } } /// @notice Create checkpoint for user's reward state + /// @dev Records user's share balance and pending rewards at current index + /// Used to calculate future rewards from this point /// @param user User address - /// @param token Reward token - /// @param newBalance New share balance after transfer - /// @param pendingRewards Pending rewards to store in checkpoint + /// @param token Reward token address + /// @param newBalance User's new share balance + /// @param pendingRewards Unclaimed rewards to store function _createCheckpoint( address user, address token, @@ -292,12 +525,12 @@ contract TangleLiquidRestakingVault is ERC4626 { } /// @notice Calculate pending rewards for a user - /// @dev Rewards are calculated as: - /// 1. New rewards = shareBalance * (currentIndex - checkpointIndex) / REWARD_FACTOR - /// 2. Total pending = newRewards + storedPendingRewards + /// @dev Calculates rewards earned since last checkpoint + /// Formula: (shareBalance * indexDelta / REWARD_FACTOR) + storedRewards + /// Uses mulDivUp for final calculation to prevent dust /// @param user User address /// @param token Reward token address - /// @return Total pending rewards for the user + /// @return Total pending rewards function _calculatePendingRewards( address user, address token @@ -311,22 +544,18 @@ contract TangleLiquidRestakingVault is ERC4626 { uint256 newRewards = checkpoint.shareBalance.mulDivUp(indexDelta, REWARD_FACTOR); uint256 totalRewards = newRewards + checkpoint.pendingRewards; - console.log("Calculate Pending Rewards"); - console.log("User:", user); - console.log("Share Balance:", checkpoint.shareBalance); - console.log("Current Index:", rData.index); - console.log("Last Index:", checkpoint.rewardIndex); - console.log("Index Delta:", indexDelta); - console.log("New Rewards:", newRewards); - console.log("Stored Pending:", checkpoint.pendingRewards); - console.log("Total Pending:", totalRewards); return totalRewards; } /// @notice Claim rewards for a specific token - /// @param user Address to claim for - /// @param token Reward token address + /// @dev Internal implementation of reward claiming + /// 1. Updates global index + /// 2. Calculates entitled rewards + /// 3. Creates new checkpoint + /// 4. Transfers rewards + /// @param user User to claim for + /// @param token Reward token to claim /// @return amount Amount of rewards claimed function _claimReward( address user, @@ -338,27 +567,20 @@ contract TangleLiquidRestakingVault is ERC4626 { // Calculate total pending rewards uint256 pendingRewards = _calculatePendingRewards(user, token); - console.log("Claim Reward"); - console.log("User:", user); - console.log("Token:", token); - console.log("Pending Rewards:", pendingRewards); - if (pendingRewards > 0) { // Reset checkpoint with current index and zero pending rewards _createCheckpoint(user, token, balanceOf[user], 0); - // Transfer rewards using SafeERC20 - SafeERC20.safeTransfer(IERC20(token), user, pendingRewards); + ERC20(token).safeTransfer(user, pendingRewards); emit RewardsClaimed(user, token, pendingRewards); - - console.log("Claimed Amount:", pendingRewards); } return pendingRewards; } /// @notice Claim all pending rewards for a user - /// @param user Address to claim for + /// @dev Helper to claim all registered reward tokens + /// @param user User to claim for function _claimAllRewards(address user) internal { for (uint256 i = 0; i < rewardTokens.length; i++) { address token = rewardTokens[i]; @@ -368,11 +590,14 @@ contract TangleLiquidRestakingVault is ERC4626 { } /// @notice Update reward index for a token - /// @dev When new rewards arrive: - /// 1. Calculate new rewards as current balance - last known balance - /// 2. Record reward arrival timestamp and amount + /// @dev Core reward distribution logic + /// When new rewards arrive: + /// 1. Calculate new rewards (current balance - last known balance) + /// 2. Record reward in history /// 3. Increase global index by (newRewards * REWARD_FACTOR) / totalSupply - /// This ensures rewards are distributed proportionally to all share holders + /// Uses mulDivDown for index updates to prevent accumulating errors + /// Reverts if index would overflow + /// @param token Reward token to update function _updateRewardIndex(address token) internal { RewardData storage rData = rewardData[token]; uint256 currentBalance = ERC20(token).balanceOf(address(this)); @@ -391,15 +616,6 @@ contract TangleLiquidRestakingVault is ERC4626 { uint256 rewardPerShare = newRewards.mulDivDown(REWARD_FACTOR, supply); rData.index += rewardPerShare; - console.log("Update Reward Index"); - console.log("Token:", token); - console.log("Current Balance:", currentBalance); - console.log("Last Known Balance:", lastKnownBalance); - console.log("New Rewards:", newRewards); - console.log("Total Supply:", supply); - console.log("Reward Per Share:", rewardPerShare); - console.log("New Global Index:", rData.index); - emit RewardIndexUpdated(token, rData.index, block.timestamp); } } @@ -408,6 +624,9 @@ contract TangleLiquidRestakingVault is ERC4626 { } /// @notice Get last known balance from recorded rewards + /// @dev Sums all recorded reward amounts + /// @param token Reward token to check + /// @return total Total of all recorded rewards function _getLastKnownBalance(address token) internal view returns (uint256 total) { RewardData storage rData = rewardData[token]; for (uint256 i = 0; i < rData.rewardAmounts.length; i++) { @@ -416,6 +635,7 @@ contract TangleLiquidRestakingVault is ERC4626 { } /// @notice Update indices for all reward tokens + /// @dev Helper to ensure all reward indices are current function _updateAllRewardIndices() internal { for (uint256 i = 0; i < rewardTokens.length; i++) { _updateRewardIndex(rewardTokens[i]); @@ -425,6 +645,7 @@ contract TangleLiquidRestakingVault is ERC4626 { /* ============ External View Functions ============ */ /// @notice Get claimable rewards for a user and token + /// @dev View function to check pending rewards /// @param user User address /// @param token Reward token address /// @return Claimable reward amount @@ -446,13 +667,15 @@ contract TangleLiquidRestakingVault is ERC4626 { return rewardTokens; } - /// @notice Calculate total vault value (base assets only) + /// @notice Calculate total vault value + /// @dev Returns total base assets, excluding reward tokens /// @return Total value in terms of asset tokens function totalAssets() public view override returns (uint256) { return asset.balanceOf(address(this)); } /// @notice Get reward data for a token + /// @dev Returns full reward tracking state /// @param token Reward token address /// @return index Current reward index /// @return lastUpdateTime Last update timestamp diff --git a/src/TangleMultiAssetDelegationWrapper.sol b/src/TangleMultiAssetDelegationWrapper.sol new file mode 100644 index 0000000..55a5f7d --- /dev/null +++ b/src/TangleMultiAssetDelegationWrapper.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {MultiAssetDelegation} from "./MultiAssetDelegation.sol"; +import {ERC20} from "../dependencies/solmate-6.8.0/src/tokens/ERC20.sol"; + +/// @title TangleMultiAssetDelegationWrapper +/// @notice Base contract for interacting with Tangle's MultiAssetDelegation system +/// @dev Provides delegation lifecycle management for a specific ERC20 token +abstract contract TangleMultiAssetDelegationWrapper { + /* ============ Events ============ */ + + /// @notice Emitted when delegation occurs + event Delegated(bytes32 indexed operator, uint256 amount, uint64[] blueprintSelection); + + /// @notice Emitted when unstake is scheduled + event UnstakeScheduled(bytes32 indexed operator, uint256 amount); + + /// @notice Emitted when unstake is cancelled + event UnstakeCancelled(bytes32 indexed operator, uint256 amount); + + /// @notice Emitted when withdrawal is scheduled + event WithdrawalScheduled(uint256 amount); + + /// @notice Emitted when withdrawal is cancelled + event WithdrawalCancelled(uint256 amount); + + /// @notice Emitted when assets are deposited + event Deposited(uint256 amount); + + /* ============ State Variables ============ */ + + /// @notice The ERC20 token being delegated + ERC20 public immutable token; + + /// @notice The MultiAssetDelegation implementation + MultiAssetDelegation public immutable mads; + + /* ============ Constructor ============ */ + + constructor(address _token, address _mads) { + token = ERC20(_token); + mads = MultiAssetDelegation(_mads); + } + + /* ============ Internal Functions ============ */ + + /// @notice Deposit assets into the delegation system + /// @param amount Amount of assets to deposit + function _deposit(uint256 amount) internal { + mads.deposit( + 0, + address(token), + amount, + 0 // Lock multiplier + ); + + emit Deposited(amount); + } + + /// @notice Delegate assets to an operator + /// @param operator The operator to delegate to + /// @param amount Amount of assets to delegate + /// @param blueprintSelection Blueprint selection for delegation + function _delegate( + bytes32 operator, + uint256 amount, + uint64[] memory blueprintSelection + ) internal { + mads.delegate( + operator, + 0, + address(token), + amount, + blueprintSelection + ); + + emit Delegated(operator, amount, blueprintSelection); + } + + /// @notice Schedule unstaking of assets + /// @param operator The operator to unstake from + /// @param amount Amount of assets to unstake + function _scheduleUnstake(bytes32 operator, uint256 amount) internal { + mads.scheduleDelegatorUnstake( + operator, + 0, + address(token), + amount + ); + + emit UnstakeScheduled(operator, amount); + } + + /// @notice Cancel a scheduled unstake + /// @param operator The operator to cancel unstake from + /// @param amount Amount of assets to cancel unstaking + function _cancelUnstake(bytes32 operator, uint256 amount) internal { + mads.cancelDelegatorUnstake( + operator, + 0, + address(token), + amount + ); + + emit UnstakeCancelled(operator, amount); + } + + /// @notice Execute pending unstake + function _executeUnstake() internal { + mads.executeDelegatorUnstake(); + } + + /// @notice Schedule withdrawal of assets + /// @param amount Amount of assets to withdraw + function _scheduleWithdraw(uint256 amount) internal { + mads.scheduleWithdraw( + 0, + address(token), + amount + ); + + emit WithdrawalScheduled(amount); + } + + /// @notice Cancel a scheduled withdrawal + /// @param amount Amount of assets to cancel withdrawal + function _cancelWithdraw(uint256 amount) internal { + mads.cancelWithdraw( + 0, + address(token), + amount + ); + + emit WithdrawalCancelled(amount); + } + + /// @notice Execute pending withdrawal + function _executeWithdraw() internal { + mads.executeWithdraw(); + } +} \ No newline at end of file diff --git a/test/TangleLiquidRestakingVault.t.sol b/test/TangleLiquidRestakingVault.t.sol index c7e04df..5f4e531 100644 --- a/test/TangleLiquidRestakingVault.t.sol +++ b/test/TangleLiquidRestakingVault.t.sol @@ -1,23 +1,15 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; -import {Test, console} from "forge-std/Test.sol"; -import {TangleLiquidRestakingVault} from "../src/TangleLiquidRestakingVault.sol"; -import {MultiAssetDelegation} from "../src/MultiAssetDelegation.sol"; -import {IOracle} from "../src/interfaces/IOracle.sol"; +import {Test, console} from "dependencies/forge-std-1.9.5/src/Test.sol"; import {ERC20} from "dependencies/solmate-6.8.0/src/tokens/ERC20.sol"; import {FixedPointMathLib} from "dependencies/solmate-6.8.0/src/utils/FixedPointMathLib.sol"; -contract MockToken is ERC20 { - constructor(string memory name, string memory symbol, uint8 _decimals) - ERC20(name, symbol, _decimals) { - _mint(msg.sender, 1000000 * (10 ** _decimals)); - } - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } -} +import {TangleLiquidRestakingVault} from "../src/TangleLiquidRestakingVault.sol"; +import {MultiAssetDelegation} from "../src/MultiAssetDelegation.sol"; +import {IOracle} from "../src/interfaces/IOracle.sol"; +import {MockMultiAssetDelegation} from "./mock/MockMultiAssetDelegation.sol"; +import {MockERC20} from "./mock/MockERC20.sol"; contract TangleLiquidRestakingVaultTest is Test { using FixedPointMathLib for uint256; @@ -28,9 +20,10 @@ contract TangleLiquidRestakingVaultTest is Test { uint256 constant PERIOD4_INDEX_DELTA = 66666666666666666; TangleLiquidRestakingVault public vault; - MockToken public baseToken; - MockToken public rewardToken1; - MockToken public rewardToken2; + MockERC20 public baseToken; + MockERC20 public rewardToken1; + MockERC20 public rewardToken2; + MockMultiAssetDelegation public mockMADS; bytes32 public constant OPERATOR = bytes32(uint256(1)); uint64[] public blueprintSelection; @@ -51,19 +44,22 @@ contract TangleLiquidRestakingVaultTest is Test { ); function setUp() public { - // Deploy tokens with different decimals - baseToken = new MockToken("Base Token", "BASE", 18); - rewardToken1 = new MockToken("Reward Token 1", "RWD1", 18); - rewardToken2 = new MockToken("Reward Token 2", "RWD2", 6); - - // Setup blueprint selection - blueprintSelection.push(1); - + // Deploy mock contracts + baseToken = new MockERC20("Base Token", "BASE", 18); + rewardToken1 = new MockERC20("Reward Token 1", "RWD1", 18); + rewardToken2 = new MockERC20("Reward Token 2", "RWD2", 18); + mockMADS = new MockMultiAssetDelegation(); + blueprintSelection = new uint64[](1); + blueprintSelection[0] = 1; + // Deploy vault vault = new TangleLiquidRestakingVault( address(baseToken), - "Tangle Liquid Staked Token", - "tLST" + OPERATOR, + blueprintSelection, + address(mockMADS), + "Tangle Liquid Restaking Token", + "tLRT" ); // Fund test accounts @@ -461,38 +457,43 @@ contract TangleLiquidRestakingVaultTest is Test { vm.startPrank(alice); vault.addRewardToken(address(rewardToken1)); vault.deposit(INITIAL_DEPOSIT, alice); - vm.stopPrank(); - // First reward - rewardToken1.mint(address(vault), REWARD_AMOUNT); + // First reward - full amount since Alice has all shares + rewardToken1.mint(address(vault), REWARD_AMOUNT); // 10e18 - // Withdraw half - vm.startPrank(alice); + // Schedule and execute unstake for half + vault.scheduleUnstake(INITIAL_DEPOSIT / 2); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + + // Schedule and execute withdraw + vault.scheduleWithdraw(INITIAL_DEPOSIT / 2); + vm.warp(block.timestamp + 1 weeks + 1); vault.withdraw(INITIAL_DEPOSIT / 2, alice, alice); - // Second reward + // Second reward - half amount since Alice has half shares vm.warp(block.timestamp + 1 days); - rewardToken1.mint(address(vault), REWARD_AMOUNT); + rewardToken1.mint(address(vault), REWARD_AMOUNT); // 10e18 (but Alice gets 5e18) // Deposit again vault.deposit(INITIAL_DEPOSIT / 2, alice); - vm.stopPrank(); - // Third reward + // Third reward - full amount since Alice has all shares again vm.warp(block.timestamp + 1 days); - rewardToken1.mint(address(vault), REWARD_AMOUNT); + rewardToken1.mint(address(vault), REWARD_AMOUNT); // 10e18 // Claim - vm.startPrank(alice); address[] memory tokens = new address[](1); tokens[0] = address(rewardToken1); uint256[] memory rewards = vault.claimRewards(alice, tokens); - vm.stopPrank(); - // Should get: full first reward + full second reward + full third reward - // Because Alice is the only holder throughout, even with half shares - uint256 expected = REWARD_AMOUNT * 3; - assertEq(rewards[0], expected, "Rewards after withdraw/deposit incorrect"); + // Should get: + // - First reward: 10e18 (full amount) + // - Second reward: 10e18 (full amount since no other holders) + // - Third reward: 10e18 (full amount) + // Total: 30e18 + assertEq(rewards[0], 30e18, "Rewards after withdraw/deposit incorrect"); + vm.stopPrank(); } function test_ComplexRewardScenario() public { @@ -514,9 +515,16 @@ contract TangleLiquidRestakingVaultTest is Test { vm.warp(block.timestamp + 1 days); rewardToken1.mint(address(vault), REWARD_AMOUNT); // 10e18 rewards - // Alice withdraws half + // Alice schedules and executes unstake for half vm.startPrank(alice); - vault.withdraw(INITIAL_DEPOSIT / 2, alice, alice); // Alice: 50e18 shares + vault.scheduleUnstake(INITIAL_DEPOSIT / 2); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + + // Schedule and execute withdraw + vault.scheduleWithdraw(INITIAL_DEPOSIT / 2); + vm.warp(block.timestamp + 1 weeks + 1); + vault.withdraw(INITIAL_DEPOSIT / 2, alice, alice); vm.stopPrank(); // Charlie joins with same as Alice's original @@ -528,9 +536,16 @@ contract TangleLiquidRestakingVaultTest is Test { vm.warp(block.timestamp + 1 days); rewardToken1.mint(address(vault), REWARD_AMOUNT); // 10e18 rewards - // Bob withdraws everything + // Bob schedules and executes unstake for everything vm.startPrank(bob); - vault.withdraw(INITIAL_DEPOSIT * 2, bob, bob); // Bob: 0 shares + vault.scheduleUnstake(INITIAL_DEPOSIT * 2); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + + // Schedule and execute withdraw + vault.scheduleWithdraw(INITIAL_DEPOSIT * 2); + vm.warp(block.timestamp + 1 weeks + 1); + vault.withdraw(INITIAL_DEPOSIT * 2, bob, bob); vm.stopPrank(); // Fourth reward period - Alice (1/3), Charlie (2/3) @@ -553,27 +568,16 @@ contract TangleLiquidRestakingVaultTest is Test { uint256[] memory charlieRewards = vault.claimRewards(charlie, tokens); vm.stopPrank(); - // Expected rewards: - // Alice: - // - Period 1: 10e18 (full) - // - Period 2: 10e18 * 1/3 - // - Period 3: 10e18 * 1/7 - // - Period 4: 10e18 * 1/3 + // Expected rewards calculations remain the same... uint256 expectedAlice = REWARD_AMOUNT + // Period 1 ((INITIAL_DEPOSIT * PERIOD2_INDEX_DELTA) / 1e18) + // Period 2 ((INITIAL_DEPOSIT / 2 * PERIOD3_INDEX_DELTA) / 1e18) + // Period 3 ((INITIAL_DEPOSIT / 2 * PERIOD4_INDEX_DELTA) / 1e18); // Period 4 - // Bob: - // - Period 2: 10e18 * 2/3 - // - Period 3: 10e18 * 4/7 uint256 expectedBob = ((INITIAL_DEPOSIT * 2 * PERIOD2_INDEX_DELTA) / 1e18) + // Period 2 ((INITIAL_DEPOSIT * 2 * PERIOD3_INDEX_DELTA) / 1e18); // Period 3 - // Charlie: - // - Period 3: 10e18 * 2/7 - // - Period 4: 10e18 * 2/3 uint256 expectedCharlie = ((INITIAL_DEPOSIT * PERIOD3_INDEX_DELTA) / 1e18) + // Period 3 ((INITIAL_DEPOSIT * PERIOD4_INDEX_DELTA) / 1e18); // Period 4 @@ -582,7 +586,7 @@ contract TangleLiquidRestakingVaultTest is Test { assertEq(bobRewards[0], expectedBob, "Bob rewards incorrect"); assertEq(charlieRewards[0], expectedCharlie, "Charlie rewards incorrect"); - // Verify total rewards distributed equals total rewards added, allowing for small rounding errors + // Verify total rewards distributed equals total rewards added assertApproxEqAbs( aliceRewards[0] + bobRewards[0] + charlieRewards[0], REWARD_AMOUNT * 4, @@ -652,4 +656,312 @@ contract TangleLiquidRestakingVaultTest is Test { assertEq(bobRewards[0], expectedBob, "Bob rewards incorrect"); assertEq(aliceRewards[0] + bobRewards[0], REWARD_AMOUNT * 3, "Total rewards mismatch"); } + + function test_ScheduleAndCancelUnstake() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + vault.addRewardToken(address(rewardToken1)); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // Schedule unstake + vault.scheduleUnstake(50e18); + assertEq(vault.scheduledUnstakeAmount(alice), 50e18, "Initial scheduled amount incorrect"); + + // Add rewards to test reward tracking + rewardToken1.mint(address(vault), REWARD_AMOUNT); + + // Cancel partial unstake + vault.cancelUnstake(20e18); + assertEq(vault.scheduledUnstakeAmount(alice), 30e18, "Remaining scheduled amount incorrect"); + + // Cancel remaining unstake + vault.cancelUnstake(30e18); + assertEq(vault.scheduledUnstakeAmount(alice), 0, "Final scheduled amount should be 0"); + + // Verify rewards resumed for cancelled amount + rewardToken1.mint(address(vault), REWARD_AMOUNT); + address[] memory tokens = new address[](1); + tokens[0] = address(rewardToken1); + uint256[] memory rewards = vault.claimRewards(alice, tokens); + assertGt(rewards[0], 0, "Should earn rewards after cancel"); + vm.stopPrank(); + } + + function test_ScheduleAndCancelWithdraw() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // First schedule and execute unstake + vault.scheduleUnstake(50e18); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + assertEq(vault.unstakeAmount(alice), 50e18, "Unstake amount incorrect"); + + // Schedule withdraw + vault.scheduleWithdraw(30e18); + assertEq(vault.scheduledWithdrawAmount(alice), 30e18, "Initial scheduled withdraw incorrect"); + assertEq(vault.unstakeAmount(alice), 20e18, "Remaining unstake amount incorrect"); + + // Cancel partial withdraw + vault.cancelWithdraw(10e18); + assertEq(vault.scheduledWithdrawAmount(alice), 20e18, "Remaining scheduled withdraw incorrect"); + assertEq(vault.unstakeAmount(alice), 30e18, "Updated unstake amount incorrect"); + + // Cancel remaining withdraw + vault.cancelWithdraw(20e18); + assertEq(vault.scheduledWithdrawAmount(alice), 0, "Final scheduled withdraw should be 0"); + assertEq(vault.unstakeAmount(alice), 50e18, "Final unstake amount incorrect"); + vm.stopPrank(); + } + + function test_RevertWhen_CancellingMoreThanScheduled() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // Schedule unstake + vault.scheduleUnstake(50e18); + + // Try to cancel more than scheduled + vm.expectRevert(TangleLiquidRestakingVault.InsufficientScheduledAmount.selector); + vault.cancelUnstake(60e18); + + // Execute unstake + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + + // Schedule withdraw + vault.scheduleWithdraw(30e18); + + // Try to cancel more than scheduled + vm.expectRevert(TangleLiquidRestakingVault.InsufficientScheduledAmount.selector); + vault.cancelWithdraw(40e18); + vm.stopPrank(); + } + + function test_RevertWhen_WithdrawingWithoutUnstake() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // Try to schedule withdraw without unstaking + vm.expectRevert(TangleLiquidRestakingVault.WithdrawalNotUnstaked.selector); + vault.scheduleWithdraw(50e18); + vm.stopPrank(); + } + + function test_ScheduleUnstake() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule unstake + vault.scheduleUnstake(50e18); + + // Verify scheduled amount in vault + assertEq(vault.scheduledUnstakeAmount(alice), 50e18, "Scheduled amount in vault incorrect"); + + // Verify scheduled amount in MADS + (uint256 amount, uint256 timestamp) = mockMADS.getScheduledUnstake(address(vault), alice); + assertEq(amount, 50e18, "Scheduled amount in MADS incorrect"); + assertEq(timestamp, block.timestamp + 1 weeks, "Scheduled timestamp incorrect"); + + vm.stopPrank(); + } + + function test_CancelUnstake() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule and then cancel unstake + vault.scheduleUnstake(50e18); + vault.cancelUnstake(50e18); + + // Verify cancelled (amount should be 0) + (uint256 amount,) = mockMADS.getScheduledUnstake(address(baseToken), alice); + assertEq(amount, 0); + + vm.stopPrank(); + } + + function test_ScheduleWithdraw() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule and execute unstake first + vault.scheduleUnstake(50e18); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + assertEq(vault.unstakeAmount(alice), 50e18); + + // Schedule withdraw + vault.scheduleWithdraw(50e18); + + // Verify scheduled amount + assertEq(vault.scheduledWithdrawAmount(alice), 50e18); + assertEq(vault.unstakeAmount(alice), 0); + + vm.stopPrank(); + } + + function test_CancelWithdraw() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule and execute unstake first + vault.scheduleUnstake(50e18); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + assertEq(vault.unstakeAmount(alice), 50e18); + + // Schedule and then cancel withdraw + vault.scheduleWithdraw(50e18); + vault.cancelWithdraw(50e18); + + // Verify cancelled (amount should be 0) + assertEq(vault.scheduledWithdrawAmount(alice), 0); + assertEq(vault.unstakeAmount(alice), 50e18); + + vm.stopPrank(); + } + + function test_ExecuteUnstake() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule unstake + vault.scheduleUnstake(50e18); + assertEq(vault.scheduledUnstakeAmount(alice), 50e18, "Initial scheduled amount incorrect"); + + // Warp time forward past delay + vm.warp(block.timestamp + 1 weeks + 1); + + // Execute unstake + vault.executeUnstake(); + + // Verify unstake executed + assertEq(vault.scheduledUnstakeAmount(alice), 0, "Scheduled amount should be cleared"); + assertEq(vault.unstakeAmount(alice), 50e18, "Unstaked amount should be updated"); + + vm.stopPrank(); + } + + function test_ExecuteWithdraw() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // Schedule and execute unstake + vault.scheduleUnstake(50e18); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + assertEq(vault.unstakeAmount(alice), 50e18, "Unstake amount incorrect"); + + // Schedule withdraw + vault.scheduleWithdraw(30e18); + assertEq(vault.scheduledWithdrawAmount(alice), 30e18, "Scheduled withdraw amount incorrect"); + assertEq(vault.unstakeAmount(alice), 20e18, "Remaining unstake amount incorrect"); + + // Wait for withdraw delay + vm.warp(block.timestamp + 1 weeks + 1); + + // Execute withdraw + uint256 balanceBefore = baseToken.balanceOf(alice); + vault.withdraw(30e18, alice, alice); + uint256 balanceAfter = baseToken.balanceOf(alice); + + // Verify withdraw executed + assertEq(balanceAfter - balanceBefore, 30e18, "Withdraw amount incorrect"); + assertEq(vault.scheduledWithdrawAmount(alice), 0, "Scheduled withdraw should be cleared"); + assertEq(vault.unstakeAmount(alice), 20e18, "Remaining unstake amount incorrect"); + vm.stopPrank(); + } + + function test_RevertWhen_ExecutingUnstakeBeforeDelay() public { + // Initial deposit from Alice + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule unstake + vault.scheduleUnstake(50e18); + assertEq(vault.scheduledUnstakeAmount(alice), 50e18, "Scheduled amount incorrect"); + + // Try to execute before delay + vm.expectRevert(MockMultiAssetDelegation.DelayNotElapsed.selector); + vault.executeUnstake(); + + vm.stopPrank(); + } + + function test_RevertWhen_ExecutingWithdrawBeforeDelay() public { + // Setup + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + // Schedule and execute unstake + vault.scheduleUnstake(50e18); + vm.warp(block.timestamp + 1 weeks + 1); + vault.executeUnstake(); + assertEq(vault.unstakeAmount(alice), 50e18, "Unstake amount incorrect"); + + // Schedule withdraw + vault.scheduleWithdraw(50e18); + assertEq(vault.scheduledWithdrawAmount(alice), 50e18, "Scheduled withdraw amount incorrect"); + assertEq(vault.unstakeAmount(alice), 0, "Unstake amount should be 0"); + + // Try to withdraw before delay + vm.expectRevert(MockMultiAssetDelegation.DelayNotElapsed.selector); + vault.withdraw(50e18, alice, alice); + + vm.stopPrank(); + } + + function test_RevertWhen_CancellingNonExistentUnstake() public { + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + vm.expectRevert(TangleLiquidRestakingVault.InsufficientScheduledAmount.selector); + vault.cancelUnstake(50e18); + + vm.stopPrank(); + } + + function test_RevertWhen_CancellingNonExistentWithdraw() public { + vm.startPrank(alice); + vault.deposit(INITIAL_DEPOSIT, alice); + + vm.expectRevert(TangleLiquidRestakingVault.InsufficientScheduledAmount.selector); + vault.cancelWithdraw(50e18); + + vm.stopPrank(); + } + + function test_RevertWhen_SchedulingWithdrawBeforeUnstake() public { + // Setup + uint256 depositAmount = 100e18; + vm.startPrank(alice); + baseToken.approve(address(vault), depositAmount); + vault.deposit(depositAmount, alice); + + // Try to schedule withdraw without unstaking first + vm.expectRevert(TangleLiquidRestakingVault.WithdrawalNotUnstaked.selector); + vault.scheduleWithdraw(50e18); + + vm.stopPrank(); + } } \ No newline at end of file diff --git a/test/mock/MockERC20.sol b/test/mock/MockERC20.sol new file mode 100644 index 0000000..f5182af --- /dev/null +++ b/test/mock/MockERC20.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {ERC20} from "../../dependencies/solmate-6.8.0/src/tokens/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor( + string memory name, + string memory symbol, + uint8 decimals + ) ERC20(name, symbol, decimals) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } +} \ No newline at end of file diff --git a/test/mock/MockMultiAssetDelegation.sol b/test/mock/MockMultiAssetDelegation.sol new file mode 100644 index 0000000..8bd8a07 --- /dev/null +++ b/test/mock/MockMultiAssetDelegation.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {MultiAssetDelegation} from "../../src/MultiAssetDelegation.sol"; + +contract MockMultiAssetDelegation is MultiAssetDelegation { + // Structs to track scheduling state + struct ScheduleState { + uint256 amount; + uint256 timestamp; + } + + // 1 week delay for schedule actions + uint256 constant public SCHEDULE_DELAY = 1 weeks; + + // Simple mapping of user => scheduled state + mapping(address => ScheduleState) public scheduledUnstakes; + mapping(address => ScheduleState) public scheduledWithdraws; + mapping(address => uint256) public delegatedAmounts; + + // Events + event Delegated(address indexed token, address indexed user, uint256 amount, bytes32 operator, uint64[] blueprintSelection); + event UnstakeScheduled(address indexed token, address indexed user, uint256 amount, uint256 timestamp); + event WithdrawScheduled(address indexed token, address indexed user, uint256 amount, uint256 timestamp); + event UnstakeCancelled(address indexed token, address indexed user, uint256 amount); + event WithdrawCancelled(address indexed token, address indexed user, uint256 amount); + event UnstakeExecuted(address indexed user, uint256 amount); + event WithdrawExecuted(address indexed user, uint256 amount); + + error InsufficientDelegatedBalance(); + error InsufficientScheduledAmount(); + error NoScheduledAmount(); + error DelayNotElapsed(); + + // Operator functions (empty implementations) + function joinOperators(uint256) external {} + function scheduleLeaveOperators() external {} + function cancelLeaveOperators() external {} + function executeLeaveOperators() external {} + function operatorBondMore(uint256) external {} + function scheduleOperatorUnstake(uint256) external {} + function executeOperatorUnstake() external {} + function cancelOperatorUnstake() external {} + function goOffline() external {} + function goOnline() external {} + + // Delegate function - track amount and emit event + function delegate(bytes32 operator, uint256, address, uint256 amount, uint64[] memory blueprintSelection) external { + delegatedAmounts[msg.sender] += amount; + emit Delegated(address(0), msg.sender, amount, operator, blueprintSelection); + } + + // Deposit function - just track delegated amount + function deposit(uint256, address, uint256 amount, uint8) external { + delegatedAmounts[msg.sender] += amount; + emit Delegated(address(0), msg.sender, amount, bytes32(0), new uint64[](0)); + } + + // Schedule withdraw - just track amount and timestamp + function scheduleWithdraw(uint256, address, uint256 amount) external { + ScheduleState storage state = scheduledWithdraws[msg.sender]; + state.amount = state.amount + amount; + state.timestamp = block.timestamp + SCHEDULE_DELAY; + emit WithdrawScheduled(address(0), msg.sender, amount, state.timestamp); + } + + // Execute withdraw - check delay and clear state + function executeWithdraw() external { + ScheduleState storage state = scheduledWithdraws[msg.sender]; + if (state.amount == 0) revert NoScheduledAmount(); + if (block.timestamp < state.timestamp) revert DelayNotElapsed(); + + uint256 amount = state.amount; + state.amount = 0; + state.timestamp = 0; + emit WithdrawExecuted(msg.sender, amount); + } + + // Cancel withdraw - just reduce amount + function cancelWithdraw(uint256, address token, uint256 amount) external { + ScheduleState storage state = scheduledWithdraws[msg.sender]; + if (state.amount < amount) revert InsufficientScheduledAmount(); + state.amount = state.amount - amount; + emit WithdrawCancelled(token, msg.sender, amount); + } + + // Schedule unstake - check delegated balance and track schedule + function scheduleDelegatorUnstake(bytes32, uint256, address tokenAddress, uint256 amount) external { + if (delegatedAmounts[msg.sender] < amount) revert InsufficientDelegatedBalance(); + + ScheduleState storage state = scheduledUnstakes[msg.sender]; + state.amount = state.amount + amount; + state.timestamp = block.timestamp + SCHEDULE_DELAY; + delegatedAmounts[msg.sender] = delegatedAmounts[msg.sender] - amount; + + emit UnstakeScheduled(tokenAddress, msg.sender, amount, state.timestamp); + } + + // Execute unstake - check delay and clear state + function executeDelegatorUnstake() external { + ScheduleState storage state = scheduledUnstakes[msg.sender]; + if (state.amount == 0) revert NoScheduledAmount(); + if (block.timestamp < state.timestamp) revert DelayNotElapsed(); + + uint256 amount = state.amount; + state.amount = 0; + state.timestamp = 0; + emit UnstakeExecuted(msg.sender, amount); + } + + // Cancel unstake - restore delegated amount + function cancelDelegatorUnstake(bytes32, uint256, address tokenAddress, uint256 amount) external { + ScheduleState storage state = scheduledUnstakes[msg.sender]; + if (state.amount < amount) revert InsufficientScheduledAmount(); + + state.amount = state.amount - amount; + delegatedAmounts[msg.sender] = delegatedAmounts[msg.sender] + amount; + emit UnstakeCancelled(tokenAddress, msg.sender, amount); + } + + // View functions + function getDelegation(address tokenAddress, address user) external view returns (uint256 amount, bytes32, uint64[] memory) { + return (delegatedAmounts[user], bytes32(0), new uint64[](0)); + } + + function getDelegatedAmount(address tokenAddress, address user) external view returns (uint256) { + return delegatedAmounts[user]; + } + + function getScheduledUnstake(address tokenAddress, address user) external view returns (uint256 amount, uint256 timestamp) { + // Return the scheduled amount for the caller (vault), not the end user + ScheduleState storage state = scheduledUnstakes[tokenAddress]; + return (state.amount, state.timestamp); + } + + function getScheduledWithdraw(address tokenAddress, address user) external view returns (uint256 amount, uint256 timestamp) { + // Return the scheduled amount for the caller (vault), not the end user + ScheduleState storage state = scheduledWithdraws[tokenAddress]; + return (state.amount, state.timestamp); + } +} \ No newline at end of file