diff --git a/.gitmodules b/.gitmodules index ed5585b..db612ee 100644 --- a/.gitmodules +++ b/.gitmodules @@ -25,3 +25,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/Vectorized/solady diff --git a/.vscode/settings.json b/.vscode/settings.json index e0f8f63..7dd29b8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,9 +5,9 @@ "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, - "solidity.compileUsingRemoteVersion": "v0.8.17+commit.7dd6d404", + "solidity.compileUsingRemoteVersion": "v0.8.19+commit.7dd6d404", "solidity.formatter": "forge", "solidity.linter": "solhint", "solidity.packageDefaultDependenciesContractsDirectory": "src", "solidity.packageDefaultDependenciesDirectory": "lib" -} +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index f755528..90174fb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -5,11 +5,11 @@ bytecode_hash = "none" fuzz = { runs = 1_000 } gas_reports = ["*"] libs = ["lib"] +evm_version = "shanghai" # optimizer = true (default) optimizer_runs = 200 fs_permissions = [{ access = "read-write", path = "./" }] -solc = "0.8.19" - +auto_detect_solc = true [profile.ci] verbosity = 4 diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..513f581 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 513f581675374706dbe947284d6b12d19ce35a2a diff --git a/remappings.txt b/remappings.txt index 838b824..4bb8f7c 100644 --- a/remappings.txt +++ b/remappings.txt @@ -2,6 +2,7 @@ forge-test/=lib/prb-test/src/ forge-std/=lib/forge-std/src/ math/=lib/prb-math/src/ core/=src/ +solady/=lib/solady/src solmate/=lib/solmate/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/ openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ diff --git a/script/Run.s.sol b/script/Run.s.sol new file mode 100644 index 0000000..bf2df3f --- /dev/null +++ b/script/Run.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +// solhint-disable no-console + +pragma solidity 0.8.20; + +import { Script, console2 } from "forge-std/Script.sol"; + +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { MultiValidatorFactory } from "core/multi-validator/Factory.sol"; +import { FlashUnstake, TenderSwap } from "core/multi-validator/FlashUnstake.sol"; + +import { LPT } from "core/adapters/LivepeerAdapter.sol"; + +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol"; +import { FlashUnstake } from "core/multi-validator/FlashUnstake.sol"; + +address constant TENDERIZER_1 = 0x4b7339E599a599DBd7829a8ECA0d233ED4F7eA09; +address constant TENDERIZER_2 = 0xFB32bF22B4F004a088c1E7d69e29492f5D7CD7E1; +address constant TENDERIZER_3 = 0x6DFd5Cee0Ed2ec24Fdc814Ad857902DE01c065d6; +address constant LIVEPEER_MINTER = 0xc20DE37170B45774e6CD3d2304017fc962f27252; + +contract MultiValidatorLST_Deploy is Script { + bytes32 private constant salt = bytes32(uint256(1)); + + MultiValidatorFactory factory; + // MultiValidatorLST lst; + + function run() public { + uint256 privKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(privKey); + address lst = 0x312d7CD23148DA9Baac94b43f4E8557fCcFe824F; + LPT.approve(lst, type(uint256).max); + MultiValidatorLST(lst).deposit(msg.sender, 10 ether); + + uint256 bal = MultiValidatorLST(lst).balanceOf(msg.sender); + + MultiValidatorLST(lst).approve(0x59b86cf4d8B566602a687Bd9A2979792e73316d9, type(uint256).max); + (uint256 out, uint256 fee) = FlashUnstake(0x59b86cf4d8B566602a687Bd9A2979792e73316d9).flashUnstakeQuote( + lst, 0x686962481543d543934903C3FE8bDe8c5dB9Bd97, 1 ether + ); + console2.log("Quote out: %s", out); + console2.log("fee: %s", fee); + + (out, fee) = FlashUnstake(0x59b86cf4d8B566602a687Bd9A2979792e73316d9).flashUnstake( + lst, 0x686962481543d543934903C3FE8bDe8c5dB9Bd97, 1 ether, out - 1 + ); + console2.log("Successfully flash unstaked"); + vm.stopBroadcast(); + } +} diff --git a/script/XYZ_Faucet.s.sol b/script/XYZ_Faucet.s.sol index ef08244..9771a46 100644 --- a/script/XYZ_Faucet.s.sol +++ b/script/XYZ_Faucet.s.sol @@ -44,7 +44,7 @@ contract XYZ_Faucet is Script { cooldown = cooldown != 0 ? cooldown : 1 days; requestAmount = requestAmount != 0 ? requestAmount : 1000 ether; - address faucet = address(new TokenFaucet{salt: salt}(token, requestAmount, cooldown)); + address faucet = address(new TokenFaucet{ salt: salt }(token, requestAmount, cooldown)); token.transfer(faucet, seedAmount); console2.log("Faucet: ", faucet); diff --git a/script/multi-validator/MultiValidatorFactory.deploy.s.sol b/script/multi-validator/MultiValidatorFactory.deploy.s.sol new file mode 100644 index 0000000..f6b098a --- /dev/null +++ b/script/multi-validator/MultiValidatorFactory.deploy.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +// solhint-disable no-console + +pragma solidity >=0.8.19; + +import { MultiValidatorFactory } from "core/multi-validator/Factory.sol"; + +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { Script, console2 } from "forge-std/Script.sol"; + +import { FlashUnstake } from "core/multi-validator/FlashUnstake.sol"; + +contract MultiValidatorFactory_Deploy is Script { + bytes32 private constant salt = bytes32(uint256(1)); + MultiValidatorFactory factory; + + function run() public { + uint256 privKey = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(privKey); + console2.log("Deploying MultiValidatorFactory..."); + address factoryImpl = address(new MultiValidatorFactory()); + factory = MultiValidatorFactory(address(new ERC1967Proxy{ salt: salt }(address(factoryImpl), ""))); + factory.initialize(); + console2.log("MultiValidatorFactory deployed at: %s", address(factory)); + + // deploy flash unstake wrapper + address flashUnstake = address(new FlashUnstake()); + console2.log("FlashUnstake deployed at: %s", flashUnstake); + + vm.stopBroadcast(); + } +} diff --git a/script/multi-validator/MultiValidatorLST.deploy.s.sol b/script/multi-validator/MultiValidatorLST.deploy.s.sol new file mode 100644 index 0000000..3358aac --- /dev/null +++ b/script/multi-validator/MultiValidatorLST.deploy.s.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { Script, console2 } from "forge-std/Script.sol"; + +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { Registry } from "core/registry/Registry.sol"; + +contract MultiValidatorLST_Upgrade is Script { + bytes32 private constant salt = bytes32(uint256(1)); + + function run() public { + uint256 privateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(privateKey); + + MultiValidatorLST lst = new MultiValidatorLST{ salt: salt }(Registry(0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE)); + console2.log("MultiValidatorLST deployed at: %s", address(lst)); + + vm.stopBroadcast(); + } +} diff --git a/script/multi-validator/MultiValidatorLST.s.sol b/script/multi-validator/MultiValidatorLST.s.sol new file mode 100644 index 0000000..847655e --- /dev/null +++ b/script/multi-validator/MultiValidatorLST.s.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +// solhint-disable no-console + +pragma solidity >=0.8.19; + +import { Script, console2 } from "forge-std/Script.sol"; + +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { MultiValidatorFactory } from "core/multi-validator/Factory.sol"; +import { FlashUnstake } from "core/multi-validator/FlashUnstake.sol"; + +import { LPT } from "core/adapters/LivepeerAdapter.sol"; + +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol"; + +address constant LIVEPEER_MINTER = 0xc20DE37170B45774e6CD3d2304017fc962f27252; + +contract MultiValidatorLST_Deploy is Script { + bytes32 private constant salt = bytes32(uint256(1)); + + address[] tenderizers = [ + 0x4b7339E599a599DBd7829a8ECA0d233ED4F7eA09, + 0xFB32bF22B4F004a088c1E7d69e29492f5D7CD7E1, + 0x6DFd5Cee0Ed2ec24Fdc814Ad857902DE01c065d6, + 0xbEb81a62E9A8463C22a3f999846F3E3FB2e2002A, + 0x3a3D463fb8241DA6051eb4DAB2200C8b99691315, + 0x109eA4859a99B3347db5025A920f63Ab0EF3de42, + 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6, + 0xFBc4435A3CebC1F4bd9c56aC95cfA37dfC142f5F, + 0x43ef285F5e27D8CA978A7e577f4dDF52147EB77b, + 0x47cd6B7e7308Fb062586e5185B4F3Ee7E224eefe, + 0x9b6DB9Cc6E479dd28471B9C899890C20377DA200, + 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd, + 0x03572207d14bed3dd50E0d48CfaD44bDDB8BF4B7 + ]; + + MultiValidatorFactory factory; + MultiValidatorLST lst; + + function run() public { + uint256 privKey = vm.envUint("PRIVATE_KEY"); + + vm.startBroadcast(privKey); + address me = vm.addr(privKey); + + console2.log("Deploying MultiValidatorFactory..."); + address factoryImpl = address(new MultiValidatorFactory()); + factory = + MultiValidatorFactory(address(new ERC1967Proxy{ salt: bytes32("MultiValidatorLSTFactory") }(address(factoryImpl), ""))); + factory.initialize(); + + console2.log("MultiValidatorFactory deployed at: %s", address(factory)); + console2.log("Factory owner: %s", factory.owner()); + + console2.log("Deploying MultiValidatorLST..."); + + lst = MultiValidatorLST(factory.deploy(address(LPT))); + + console2.log("MultiValidatorLST deployed at: %s", address(lst)); + + // deploy flash unstake wrapper + address flashUnstake = address(new FlashUnstake()); + console2.log("FlashUnstake deployed at: %s", flashUnstake); + + lst.setFee(0.05e6); // 5% fee + + for (uint256 i = 0; i < tenderizers.length; i++) { + lst.addValidator(payable(tenderizers[i]), 1_000_000 ether); // 2M Stake + } + + vm.stopBroadcast(); + } +} diff --git a/script/multi-validator/multi-validator-testnet.sh b/script/multi-validator/multi-validator-testnet.sh new file mode 100755 index 0000000..7e90dbb --- /dev/null +++ b/script/multi-validator/multi-validator-testnet.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -x +pkill -9 anvil +nohup bash -c "anvil --fork-url https://arb-mainnet.alchemyapi.io/v2/ISHp9nyZwKlfoSfS3-Hv-05CRiklcRBt --hardfork shanghai --chain-id 5000 --block-base-fee-per-gas 0 &" >/dev/null 2>&1 && sleep 5 + +forge build + +curl -H "Content-Type: application/json" -X POST --data '{"jsonrpc":"2.0","id":67,"method":"anvil_setCode","params": ["0x4e59b44847b379578588920ca78fbf26c0b4956c","0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3"]}' 127.0.0.1:8545 + +export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +forge script script/MultiValidatorLST.s.sol:MultiValidatorLST_Deploy --rpc-url http://127.0.0.1:8545 --broadcast -vvv + +LPT=0x289ba1701C2F088cf0faf8B3705246331cB8A839 +MINTER=0xc20DE37170B45774e6CD3d2304017fc962f27252 +AMOUNT=100000000000000000000000 +ME=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +cast rpc anvil_impersonateAccount $MINTER +cast send $LPT --from $MINTER "transfer(address,uint256)(bool)" $ME $AMOUNT --unlocked + +# init round +cast send 0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f --from $ME "initializeRound()" --unlocked + +read -r -d '' _ =0.8.19; +import {console2} from "forge-std/Test.sol"; +/// @title AVL Tree Library +/// @notice Provides an AVL (balanced binary search) tree implementation for sorting nodes by their `divergence`. +/// @dev The tree is keyed by `int200 divergence`. When `divergence` values are equal, nodes are ordered by `id`. +/// Nodes are inserted and balanced according to AVL rotation rules. + +library AVLTree { + // ============================================================ + // Errors + // ============================================================ + /// @notice Thrown when an operation is performed on an empty tree. + error TreeEmpty(); + + /// @notice Thrown when a requested node ID does not exist in the tree. + error NodeNotFound(); + + /// @notice Thrown when an invalid balance factor is detected (mainly for debugging). + error InvalidBalance(); + + /// @notice Thrown when insertion is invalid (e.g. tree size limit reached). + error InvalidInsertion(); + + /// @notice Thrown when attempting to insert a node that already exists. + error NodeAlreadyExists(); + + // ============================================================ + // Structures + // ============================================================ + /// @notice Represents a single node in the AVL tree. + /// @dev `height` is used for AVL balance calculations. `divergence` is the sorting key. + struct Node { + uint24 left; // Supports 16.7M nodes + uint24 right; // Supports 16.7M nodes + uint8 height; // More than enough for max height + int200 divergence; // Supports 10^59, more than enough for any practical purpose + } + + /// @notice Represents the entire AVL tree data structure. + /// @dev `first` and `last` track the nodes with min and max divergence respectively. + struct Tree { + uint24 root; + uint24 first; + uint24 last; + uint24 size; + uint24 positiveNodes; + uint24 negativeNodes; + int200 negDivergence; + int200 posDivergence; + mapping(uint24 => Node) nodes; + } + + /// @notice Insert a new node with a given id and divergence into the tree. + /// @dev Reverts if a node with the same id already exists. + /// @param tree The tree storage pointer. + /// @param id The unique node identifier. + /// @param divergence The divergence value for sorting. + /// @return success True if the insertion was successful. + function insert(Tree storage tree, uint24 id, int200 divergence) public returns (bool) { + if (tree.size >= type(uint24).max) revert InvalidInsertion(); + if (hasNode(tree, id)) revert NodeAlreadyExists(); + + // Update tree stats + tree.size = tree.size + 1; + if (divergence > 0) { + tree.positiveNodes++; + tree.posDivergence += divergence; + } else if (divergence < 0) { + tree.negativeNodes++; + tree.negDivergence += divergence; + } + + // Create new node + Node memory newNode = Node({ left: 0, right: 0, height: 1, divergence: divergence }); + +// Handle first insertion +if (tree.size == 1) { + tree.root = id; + tree.first = id; + tree.last = id; + tree.nodes[id] = newNode; + return true; +} + + tree.root = _insertRecursive(tree, tree.root, id, newNode); + return true; + } + + /// @notice Inserts a node recursively into the AVL tree and rebalances if necessary. + /// @param tree The tree storage pointer. + /// @param nodeId The current node ID being checked. + /// @param newId The new node's ID. + /// @param newNode The new node structure to insert. + /// @return uint24 The updated subtree root after insertion. +function _insertRecursive(Tree storage tree, uint24 nodeId, uint24 newId, Node memory newNode) internal returns (uint24) { + // Handle base case + if (nodeId == 0) { + tree.nodes[newId] = newNode; + return newId; + } + + // Recursive insertion + Node storage current = tree.nodes[nodeId]; + if (newNode.divergence < current.divergence) { + current.left = _insertRecursive(tree, current.left, newId, newNode); + + // Update first pointer if this is a new minimum + if (newNode.divergence < tree.nodes[tree.first].divergence) { + tree.first = newId; + } + } else { + current.right = _insertRecursive(tree, current.right, newId, newNode); + + // Update last pointer if this is a new maximum + if (newNode.divergence > tree.nodes[tree.last].divergence || + (newNode.divergence == tree.nodes[tree.last].divergence && newId > tree.last)) { + tree.last = newId; + } + } + + return rebalanceNode(tree, nodeId); +} + + /// @notice Removes the node with the given `id` from the tree. + /// @dev Reverts if the tree is empty or if the node does not exist. Updates stats and rebalances the tree. + /// @param tree The tree storage pointer. + /// @param id The unique node identifier to remove. + /// @return success True if the removal was successful. + function remove(Tree storage tree, uint24 id) public returns (bool) { + if (tree.size == 0) revert TreeEmpty(); + if (!hasNode(tree, id)) revert NodeNotFound(); + + Node storage node = tree.nodes[id]; + _updateDivergenceStats(tree, node.divergence, 0); + + tree.root = _removeRecursive(tree, tree.root, id); + tree.size--; + + // Update first/last if necessary + if (id == tree.first) { + tree.first = _findMin(tree, tree.root); + } + if (id == tree.last) { + tree.last = _findMax(tree, tree.root); + } + + return true; + } + + /// @notice Removes a node recursively from the AVL tree and rebalances if needed. + /// @param tree The tree storage pointer. + /// @param nodeId The current subtree root being examined. + /// @param id The ID of the node to remove. + /// @return uint24 The updated subtree root after removal. + function _removeRecursive(Tree storage tree, uint24 nodeId, uint24 id) internal returns (uint24) { + if (nodeId == 0) return 0; + + Node storage current = tree.nodes[nodeId]; + + if (id < nodeId) { + current.left = _removeRecursive(tree, current.left, id); + } else if (id > nodeId) { + current.right = _removeRecursive(tree, current.right, id); + } else { + // Node to delete found + if (current.left == 0 || current.right == 0) { + // One child or leaf + uint24 temp = current.left == 0 ? current.right : current.left; + if (temp == 0) { + // No child + delete tree.nodes[nodeId]; + return 0; + } else { + // One child + tree.nodes[nodeId] = tree.nodes[temp]; + delete tree.nodes[temp]; + return temp; + } + } else { + // Two children + uint24 temp = _findMin(tree, current.right); + current.divergence = tree.nodes[temp].divergence; + current.right = _removeRecursive(tree, current.right, temp); + } + } + + return rebalanceNode(tree, nodeId); + } + + /// @notice Updates the divergence of an existing node. + /// @dev If necessary, remove and re-insert the node for efficiency. Otherwise, update in place and rebalance. + /// @param tree The tree storage pointer. + /// @param id The unique node identifier. + /// @param newDivergence The new divergence value. + /// @return success True if the update was successful. + function updateDivergence(Tree storage tree, uint24 id, int200 newDivergence) external returns (bool) { + Node storage node = tree.nodes[id]; + if (!hasNode(tree, id)) revert NodeNotFound(); + int200 oldDivergence = node.divergence; + console2.log("oldDivergence", oldDivergence); + console2.log("newDivergence", newDivergence); + if (oldDivergence == newDivergence) return true; + + // Update tree statistics + _updateDivergenceStats(tree, oldDivergence, newDivergence); + + // Determine if delete+reinsert is more efficient + uint256 levelChange = _estimateLevelChange(oldDivergence, newDivergence); + if (levelChange > node.height / 2) { + // Delete and reinsert + remove(tree, id); + return insert(tree, id, newDivergence); + } else { + // Update in place + node.divergence = newDivergence; + tree.root = _rebalanceRecursive(tree, tree.root, id); + return true; + } + } + + /// @notice Rebalances the AVL tree starting from a given node after an update. + /// @param tree The tree storage pointer. + /// @param nodeId The current subtree root. + /// @param targetId The node ID whose divergence was updated. + /// @return uint24 The updated subtree root after rebalancing. + function _rebalanceRecursive(Tree storage tree, uint24 nodeId, uint24 targetId) internal returns (uint24) { + if (nodeId == 0) return 0; + + Node storage current = tree.nodes[nodeId]; + + if (targetId < nodeId) { + current.left = _rebalanceRecursive(tree, current.left, targetId); + } else if (targetId > nodeId) { + current.right = _rebalanceRecursive(tree, current.right, targetId); + } + // If targetId == nodeId, we've found our node and will rebalance up + + return rebalanceNode(tree, nodeId); + } + + /// @notice Rebalances a single node if needed using AVL rotations. + /// @param tree The tree storage pointer. + /// @param nodeId The node to rebalance. + /// @return uint24 The new root of the subtree after rebalancing. + function rebalanceNode(Tree storage tree, uint24 nodeId) internal returns (uint24) { + if (nodeId == 0) return 0; + + Node storage node = tree.nodes[nodeId]; + + // Update height + uint8 leftHeight = node.left == 0 ? 0 : tree.nodes[node.left].height; + uint8 rightHeight = node.right == 0 ? 0 : tree.nodes[node.right].height; + node.height = max(leftHeight, rightHeight) + 1; + + // Get balance factor + int8 balance = int8(rightHeight) - int8(leftHeight); + + // Left Heavy + if (balance < -1) { + uint24 left = node.left; + int8 leftBalance = getBalance(tree, left); + + if (leftBalance <= 0) { + // Left-Left Case + return rightRotate(tree, nodeId); + } else { + // Left-Right Case + node.left = leftRotate(tree, left); + return rightRotate(tree, nodeId); + } + } + + // Right Heavy + if (balance > 1) { + uint24 right = node.right; + int8 rightBalance = getBalance(tree, right); + + if (rightBalance >= 0) { + // Right-Right Case + return leftRotate(tree, nodeId); + } else { + // Right-Left Case + node.right = rightRotate(tree, right); + return leftRotate(tree, nodeId); + } + } + + // No rebalancing needed + return nodeId; + } + + /// @notice Find the most divergent nodes in a specified direction (positive or negative). + /// @dev Returns up to `count` nodes. If `positive` is true, returns the most positive divergences; otherwise, most negative. + /// @param tree The tree storage pointer. + /// @param positive True for positive divergence, false for negative. + /// @param count The number of nodes to return (max 3). + /// @return ids The array of node IDs. + /// @return divergences The array of divergences corresponding to the returned nodes. + function findMostDivergent( + Tree storage tree, + bool positive, + uint24 count + ) + public + view + returns (uint24[] memory ids, int200[] memory divergences) + { + if (tree.size == 0) revert TreeEmpty(); + + ids = new uint24[](count); + divergences = new int200[](count); + uint8 found = 0; + + uint24 current = positive ? tree.last : tree.first; + while (found < count) { + Node memory node = tree.nodes[current]; + if ((positive && node.divergence <= 0) || (!positive && node.divergence >= 0)) break; + + ids[found] = current; + divergences[found] = node.divergence; + found++; + + current = positive ? findPredecessor(tree, current) : findSuccessor(tree, current); + } + + return (ids, divergences); + } + + /// @notice Finds the predecessor of a given node (the closest node with a key less than the given node's key). + /// @param tree The tree storage pointer. + /// @param nodeId The ID of the node for which to find the predecessor. + /// @return uint24 The predecessor node ID, or 0 if none exists. + function findPredecessor(Tree storage tree, uint24 nodeId) public view returns (uint24) { + Node storage node = tree.nodes[nodeId]; + + // If there's a left subtree, the predecessor is the maximum node in that subtree. + if (node.left != 0) { + return _findMax(tree, node.left); + } + + // Otherwise, we search from the root. The predecessor is the node with the largest key + // that is strictly less than (node.divergence, nodeId). + uint24 predecessor = 0; + uint24 current = tree.root; + while (current != 0) { + Node storage cnode = tree.nodes[current]; + + // Compare by divergence first; if equal, then by ID. + if (cnode.divergence < node.divergence || (cnode.divergence == node.divergence && current < nodeId)) { + // current is a valid predecessor candidate, since it's strictly less + predecessor = current; + current = cnode.right; // look for a larger one that might still be less + } else { + // current is not less, so we move left to find smaller nodes + current = cnode.left; + } + } + return predecessor; + } + + /// @notice Finds the successor of a given node (the closest node with a key greater than the given node's key). + /// @param tree The tree storage pointer. + /// @param nodeId The ID of the node for which to find the successor. + /// @return uint24 The successor node ID, or 0 if none exists. + function findSuccessor(Tree storage tree, uint24 nodeId) public view returns (uint24) { + Node storage node = tree.nodes[nodeId]; + + // If there's a right subtree, the successor is the minimum node in that subtree. + if (node.right != 0) { + return _findMin(tree, node.right); + } + + // Otherwise, we search from the root. The successor is the node with the smallest key + // that is strictly greater than (node.divergence, nodeId). + uint24 successor = 0; + uint24 current = tree.root; + while (current != 0) { + Node storage cnode = tree.nodes[current]; + + // Compare by divergence first; if equal, then by ID. + if (cnode.divergence > node.divergence || (cnode.divergence == node.divergence && current > nodeId)) { + // current is a valid successor candidate, since it's strictly greater + successor = current; + current = cnode.left; // look for a smaller one that might still be greater + } else { + // current is not greater, move right to find a larger node + current = cnode.right; + } + } + return successor; + } + + /// @notice Returns whether a node with the given `id` exists in the tree. + /// @param tree The tree storage pointer. + /// @param id The unique node identifier. + /// @return exists True if the node exists, false otherwise. + function hasNode(Tree storage tree, uint24 id) internal view returns (bool) { + return tree.nodes[id].height != 0; + } + + /// @notice Estimates the level change in the tree if a node's divergence sign changes. + /// @dev This is a simplified heuristic. + /// @param oldValue The old divergence value. + /// @param newValue The new divergence value. + /// @return uint256 The estimated level change. + function _estimateLevelChange(int200 oldValue, int200 newValue) internal pure returns (uint256) { + if (oldValue == newValue) return 0; + if (oldValue < 0 && newValue < 0) return 0; + if (oldValue > 0 && newValue > 0) return 0; + return 1; // Simplified for now, could be more sophisticated + } + + /// @notice Finds the minimum node starting from a given subtree root. + /// @param tree The tree storage pointer. + /// @param nodeId The subtree root. + /// @return uint24 The node ID with the minimum divergence in that subtree. + function _findMin(Tree storage tree, uint24 nodeId) internal view returns (uint24) { + if (nodeId == 0) return 0; + while (tree.nodes[nodeId].left != 0) { + nodeId = tree.nodes[nodeId].left; + } + return nodeId; + } + + /// @notice Finds the maximum node starting from a given subtree root. + /// @param tree The tree storage pointer. + /// @param nodeId The subtree root. + /// @return uint24 The node ID with the maximum divergence in that subtree. + function _findMax(Tree storage tree, uint24 nodeId) internal view returns (uint24) { + if (nodeId == 0) return 0; + while (tree.nodes[nodeId].right != 0) { + nodeId = tree.nodes[nodeId].right; + } + return nodeId; + } + + // Core missing utility functions + function max(uint8 a, uint8 b) internal pure returns (uint8) { + return a > b ? a : b; + } + + /// @notice Gets the balance factor of a node. + /// @dev The balance is (height of right subtree - height of left subtree). + /// @param tree The tree storage pointer. + /// @param nodeId The node ID for which to get the balance. + /// @return int8 The balance factor. + function getBalance(Tree storage tree, uint24 nodeId) internal view returns (int8) { + if (nodeId == 0) return 0; + + Node storage node = tree.nodes[nodeId]; + uint8 leftHeight = node.left == 0 ? 0 : tree.nodes[node.left].height; + uint8 rightHeight = node.right == 0 ? 0 : tree.nodes[node.right].height; + + return int8(rightHeight) - int8(leftHeight); + } + + /// @notice Performs a right rotation on the subtree rooted at `y`. + /// @param tree The tree storage pointer. + /// @param y The root of the subtree to rotate. + /// @return uint24 The new root of the rotated subtree. + function rightRotate(Tree storage tree, uint24 y) internal returns (uint24) { + uint24 x = tree.nodes[y].left; + uint24 T2 = tree.nodes[x].right; + + // Perform rotation + tree.nodes[x].right = y; + tree.nodes[y].left = T2; + + // Update heights + uint8 leftHeight = tree.nodes[y].left == 0 ? 0 : tree.nodes[tree.nodes[y].left].height; + uint8 rightHeight = tree.nodes[y].right == 0 ? 0 : tree.nodes[tree.nodes[y].right].height; + tree.nodes[y].height = max(leftHeight, rightHeight) + 1; + + leftHeight = tree.nodes[x].left == 0 ? 0 : tree.nodes[tree.nodes[x].left].height; + rightHeight = tree.nodes[x].right == 0 ? 0 : tree.nodes[tree.nodes[x].right].height; + tree.nodes[x].height = max(leftHeight, rightHeight) + 1; + + return x; + } + + /// @notice Performs a left rotation on the subtree rooted at `x`. + /// @param tree The tree storage pointer. + /// @param x The root of the subtree to rotate. + /// @return uint24 The new root of the rotated subtree. + function leftRotate(Tree storage tree, uint24 x) internal returns (uint24) { + uint24 y = tree.nodes[x].right; + uint24 T2 = tree.nodes[y].left; + + // Perform rotation + tree.nodes[y].left = x; + tree.nodes[x].right = T2; + + // Update heights + uint8 leftHeight = tree.nodes[x].left == 0 ? 0 : tree.nodes[tree.nodes[x].left].height; + uint8 rightHeight = tree.nodes[x].right == 0 ? 0 : tree.nodes[tree.nodes[x].right].height; + tree.nodes[x].height = max(leftHeight, rightHeight) + 1; + + leftHeight = tree.nodes[y].left == 0 ? 0 : tree.nodes[tree.nodes[y].left].height; + rightHeight = tree.nodes[y].right == 0 ? 0 : tree.nodes[tree.nodes[y].right].height; + tree.nodes[y].height = max(leftHeight, rightHeight) + 1; + + return y; + } + + /// @notice Updates the tree statistics when a node's divergence changes. + /// @param tree The tree storage pointer. + /// @param oldDivergence The old divergence value. + /// @param newDivergence The new divergence value. + function _updateDivergenceStats(Tree storage tree, int200 oldDivergence, int200 newDivergence) internal { + // Remove old stats + if (oldDivergence > 0) { + tree.positiveNodes--; + tree.posDivergence -= oldDivergence; + } else if (oldDivergence < 0) { + tree.negativeNodes--; + tree.negDivergence -= oldDivergence; + } + + // Add new stats + if (newDivergence > 0) { + tree.positiveNodes++; + tree.posDivergence += newDivergence; + } else if (newDivergence < 0) { + tree.negativeNodes++; + tree.negDivergence += newDivergence; + } + } + + /// @notice Returns the node structure for a given `id`. + /// @param tree The tree storage pointer. + /// @param id The unique node identifier. + /// @return node The requested node. + function getNode(Tree storage tree, uint24 id) external view returns (Node memory) { + return tree.nodes[id]; + } + + /// @notice Returns the size of the tree (number of nodes). + /// @param tree The tree storage pointer. + /// @return uint24 The total number of nodes in the tree. + function getSize(Tree storage tree) external view returns (uint24) { + return tree.size; + } + + /// @notice Returns global statistics about the tree. + /// @param tree The tree storage pointer. + /// @return size The total number of nodes in the tree. + /// @return positiveNodes The number of nodes with positive divergence. + /// @return negativeNodes The number of nodes with negative divergence. + /// @return posDivergence The sum of all positive divergences. + /// @return negDivergence The sum of all negative divergences. + function getTreeStats(Tree storage tree) + external + view + returns (uint24 size, uint24 positiveNodes, uint24 negativeNodes, int200 posDivergence, int200 negDivergence) + { + return (tree.size, tree.positiveNodes, tree.negativeNodes, tree.posDivergence, tree.negDivergence); + } + + function getFirst(Tree storage tree) external view returns (uint24) { + return tree.first; + } + + function getLast(Tree storage tree) external view returns (uint24) { + return tree.last; + } + + /// @notice Returns the bounds of the tree (root, first, and last node IDs). + /// @param tree The tree storage pointer. + /// @return root The root node ID. + /// @return first The node ID with the smallest divergence. + /// @return last The node ID with the largest divergence. + function getTreeBounds(Tree storage tree) external view returns (uint24 root, uint24 first, uint24 last) { + return (tree.root, tree.first, tree.last); + } +} diff --git a/src/multi-validator/Factory.sol b/src/multi-validator/Factory.sol new file mode 100644 index 0000000..b271a7a --- /dev/null +++ b/src/multi-validator/Factory.sol @@ -0,0 +1,50 @@ +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { OwnableUpgradeable } from "openzeppelin-contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { UnstakeNFT } from "core/multi-validator/UnstakeNFT.sol"; +import { Registry } from "core/registry/Registry.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; + +contract MultiValidatorFactory is Initializable, UUPSUpgradeable, OwnableUpgradeable { + Registry constant registry = Registry(0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE); + address private immutable initialImpl; + address private immutable initialUnstakeNFTImpl; + + constructor() { + _disableInitializers(); + initialImpl = address(new MultiValidatorLST{ salt: bytes32(uint256(0)) }(registry)); + initialUnstakeNFTImpl = address(new UnstakeNFT{ salt: bytes32(uint256(0)) }()); + } + + function initialize() external initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + transferOwnership(registry.treasury()); + } + + function deploy(address token) external onlyOwner returns (address) { + string memory symbol = ERC20(token).symbol(); + address stProxy = + address(new ERC1967Proxy{ salt: keccak256(bytes(string.concat("MultiValidator", symbol))) }(initialImpl, "")); + + address unstProxy = address( + new ERC1967Proxy{ salt: keccak256(bytes(string.concat("UnstakeNFT", symbol))) }( + initialUnstakeNFTImpl, abi.encodeCall(UnstakeNFT.initialize, (token, stProxy)) + ) + ); + + MultiValidatorLST(stProxy).initialize(token, UnstakeNFT(unstProxy), registry.treasury()); + + UnstakeNFT(unstProxy).transferOwnership(registry.treasury()); + + return stProxy; + } + + ///@dev required by the OZ UUPS module + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner { } +} diff --git a/src/multi-validator/FlashUnstake.sol b/src/multi-validator/FlashUnstake.sol new file mode 100644 index 0000000..ce5bc37 --- /dev/null +++ b/src/multi-validator/FlashUnstake.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.20; + +import { Multicallable } from "solady/utils/Multicallable.sol"; +import { SelfPermit } from "core/utils/SelfPermit.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { ERC721Receiver } from "core/utils/ERC721Receiver.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; + +interface TenderSwap { + function quote(address asset, uint256 amount) external view returns (uint256 out, uint256 fee); + function swap(address asset, uint256 amount, uint256 minOut) external returns (uint256 out, uint256 fee); + + function quoteMultiple( + address[] calldata assets, + uint256[] calldata amounts + ) + external + view + returns (uint256 out, uint256 fee); + + function swapMultiple( + address[] calldata assets, + uint256[] calldata amounts, + uint256 minOut + ) + external + returns (uint256 out, uint256 fee); +} + +contract FlashUnstake is ERC721Receiver, Multicallable, SelfPermit { + using SafeTransferLib for address; + using FixedPointMathLib for uint256; + + error Slippage(); + + function flashUnstake( + address token, // multi validator LST + address tenderSwap, // TenderSwap address for the asset + uint256 amount, // amount to flash unstake + uint256 minOut // min amount to receive + ) + external + returns (uint256 out, uint256 fees) + { + token.safeTransferFrom(msg.sender, address(this), amount); + MultiValidatorLST lst = MultiValidatorLST(token); + (address[] memory tTokens, uint256[] memory amounts) = lst.unwrap(amount, amount.mulWad(lst.exchangeRate())); + + uint256 l = tTokens.length; + + if (l == 0) revert(); + + if (l == 1) { + uint256 bal = ERC20(tTokens[0]).balanceOf(address(this)); + amount = bal < amounts[0] ? bal : amounts[0]; + tTokens[0].safeApprove(tenderSwap, amount); + (out, fees) = TenderSwap(tenderSwap).swap(tTokens[0], amount, minOut); + } else { + uint256 bal = ERC20(tTokens[0]).balanceOf(address(this)); + for (uint256 i = 0; i < l; ++i) { + amounts[i] = bal < amounts[i] ? bal : amounts[i]; + tTokens[i].safeApprove(tenderSwap, amounts[i]); + } + (out, fees) = TenderSwap(tenderSwap).swapMultiple(tTokens, amounts, minOut); + } + if (out < minOut) revert Slippage(); + lst.token().safeTransfer(msg.sender, out); + } + + function flashUnstakeQuote( + address token, + address tenderSwap, + uint256 amount + ) + external + view + returns (uint256 out, uint256 fees) + { + (address[] memory tTokens, uint256[] memory amounts) = MultiValidatorLST(token).previewUnwrap(amount); + uint256 l = tTokens.length; + + if (l == 0) revert(); + if (l == 1) { + (out, fees) = TenderSwap(tenderSwap).quote(tTokens[0], amounts[0]); + } else { + (out, fees) = TenderSwap(tenderSwap).quoteMultiple(tTokens, amounts); + } + } +} diff --git a/src/multi-validator/MultiValidatorLST.sol b/src/multi-validator/MultiValidatorLST.sol new file mode 100644 index 0000000..4224c9d --- /dev/null +++ b/src/multi-validator/MultiValidatorLST.sol @@ -0,0 +1,506 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { OwnableUpgradeable } from "openzeppelin-contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { AccessControlUpgradeable } from "openzeppelin-contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { Multicallable } from "solady/utils/Multicallable.sol"; +import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; +import { SelfPermit } from "core/utils/SelfPermit.sol"; +import { ERC721Receiver } from "core/utils/ERC721Receiver.sol"; + +import { ERC20 } from "solady/tokens/ERC20.sol"; + +import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; + +import { AVLTree } from "core/multi-validator/AVLTree.sol"; + +import { UnstakeNFT } from "core/multi-validator/UnstakeNFT.sol"; +import { Registry } from "core/registry/Registry.sol"; + +contract MultiValidatorLST is + ERC20, + ERC721Receiver, + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable, + Multicallable, + SelfPermit +{ + using SafeTransferLib for address; + using FixedPointMathLib for uint256; + using AVLTree for AVLTree.Tree; + + bytes32 constant MINTER_ROLE = keccak256("MINTER"); + bytes32 constant UPGRADE_ROLE = keccak256("UPGRADE"); + bytes32 constant GOVERNANCE_ROLE = keccak256("GOVERNANCE"); + + uint256 constant MAX_FEE = 0.1e6; // 10% + uint256 constant FEE_WAD = 1e6; // 100% + + struct UnstakeRequest { + uint256 amount; // expected amount to receive + uint64 createdAt; // block timestamp + address[] tTokens; // addresses of the tTokens unstaked + uint256[] unlockIDs; // IDs of the unlocks + } + + error DepositTooSmall(); + error BalanceNotZero(); + error UnstakeSlippage(); + error RebalanceFailed(address target, bytes data, uint256 value); + error InvalidTenderizer(address tToken); + + // Events + event Deposit(address indexed sender, uint256 amount, uint256 shares); + event Unstake(address indexed sender, uint256 unstakeID, uint256 shares, uint256 amount); + event Unwrap(address indexed sender, uint256 shares, uint256 amount); + event Withdraw(address indexed sender, uint256 unstakeID, uint256 amount); + event ValidatorAdded(uint256 indexed id, address tToken, uint256 target); + event ValidatorRemoved(uint256 indexed id); + event WeightsUpdated(uint256[] ids, uint256[] weights); + event Rebalanced(uint256 indexed id, uint256 amount, bool isDeposit); + + // Struct to track validator share info + struct StakingPool { + address payable tToken; // Address of validator share token + uint256 target; // Target weight (basis points) + uint256 balance; // Current balance of tTokens + } + + // === IMMUTABLES === + Registry immutable registry; + + // === GLOBAL STATE === + address public token; // Underlying asset (e.g. ETH) + UnstakeNFT unstakeNFT; + uint256 public fee; // Stored as fixed point (1e18) + uint256 public totalAssets; + uint256 private lastUnstakeID; + uint256 public exchangeRate = FixedPointMathLib.WAD; // Stored as fixed point (1e18) + + mapping(uint24 id => StakingPool) public stakingPools; + mapping(uint256 unstakeID => UnstakeRequest) private unstakeRequests; + AVLTree.Tree public stakingPoolTree; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(Registry _registry) { + _disableInitializers(); + // Set initial state + registry = _registry; + } + + function name() public view override returns (string memory) { + return string.concat("Steaked ", ERC20(token).symbol()); + } + + function symbol() public view override returns (string memory) { + return string.concat("st", ERC20(token).symbol()); + } + + function getUnstakeRequest(uint256 id) external view returns (UnstakeRequest memory) { + return unstakeRequests[id]; + } + + function initialize(address _token, UnstakeNFT _unstakeNFT, address treasury) external initializer { + __AccessControl_init(); + _grantRole(UPGRADE_ROLE, treasury); + _grantRole(GOVERNANCE_ROLE, treasury); + + _setRoleAdmin(GOVERNANCE_ROLE, GOVERNANCE_ROLE); + _setRoleAdmin(MINTER_ROLE, GOVERNANCE_ROLE); + // Only allow UPGRADE_ROLE to add new UPGRADE_ROLE memebers + // If all members of UPGRADE_ROLE are revoked, contract upgradability is revoked + _setRoleAdmin(UPGRADE_ROLE, UPGRADE_ROLE); + + token = _token; + unstakeNFT = _unstakeNFT; + exchangeRate = FixedPointMathLib.WAD; + } + + // Core functions for deposits + function deposit(address receiver, uint256 assets) external returns (uint256 shares) { + // Transfer assets from sender + token.safeTransferFrom(msg.sender, address(this), assets); + + // Stake assets + uint24 count = 3; + + (, uint24 positiveNodes, uint24 negativeNodes,, int200 negDivergence) = stakingPoolTree.getTreeStats(); + + uint256 negDiv_ = uint256(int256(-(negDivergence))); + + uint256 received; + int200 totalDivergence = 0; + if (assets <= negDiv_) { + uint24 maxCount = negativeNodes > count ? count : negativeNodes; + StakingPool[] memory items = new StakingPool[](maxCount); + + (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); + for (uint24 i = 0; i < maxCount; i++) { + StakingPool storage pool = stakingPools[validatorIDs[i]]; + items[i] = StakingPool(pool.tToken, pool.target, pool.balance); + totalDivergence += int200(int256((pool.target - pool.balance))); + } + + for (uint24 i = 0; i < maxCount; i++) { + uint256 amount = uint256(int256(assets) * int256(items[i].target - items[i].balance) / int256(totalDivergence)); + ERC20(token).approve(items[i].tToken, amount); + uint256 tTokens = Tenderizer(items[i].tToken).deposit(address(this), amount); + StakingPool storage pool = stakingPools[validatorIDs[i]]; + pool.balance += tTokens; + received += tTokens; + + // Rebalance tree + int200 d; + if (pool.balance < pool.target) { + d = -int200(uint200(pool.target - pool.balance)); + } else { + d = int200(uint200(pool.balance - pool.target)); + } + stakingPoolTree.updateDivergence(validatorIDs[i], d); + } + } else { + uint24 maxCount = negativeNodes > count ? count : negativeNodes; + (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); + uint256[] memory depositAmounts = new uint256[](maxCount); + for (uint24 i = 0; i < maxCount; i++) { + StakingPool storage pool = stakingPools[validatorIDs[i]]; + depositAmounts[i] = pool.target - pool.balance; + } + + // Fill the remaining between a set of validators all above surplus, start with the one least in surplus + maxCount = positiveNodes > count ? count : positiveNodes; + (validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); + StakingPool[] memory items = new StakingPool[](maxCount); + + for (uint24 i = 0; i < maxCount; i++) { + StakingPool storage pool = stakingPools[validatorIDs[i]]; + items[i] = StakingPool(pool.tToken, pool.target, pool.balance); + + // IN THEORY: This set should all have positive divergence, so we can use the absolute values + // instead of the signed integers. + totalDivergence += int200(int256((pool.balance - pool.target))); + } + + for (uint24 i = 0; i < maxCount; i++) { + int256 div = int256(items[i].target - items[i].balance); + int256 amount = int256(depositAmounts[i] + assets); + amount = amount * div / int256(totalDivergence); + ERC20(token).approve(items[i].tToken, uint256(amount)); + uint256 tTokens = Tenderizer(items[i].tToken).deposit(address(this), uint256(amount)); + StakingPool storage pool = stakingPools[validatorIDs[i]]; + pool.balance += tTokens; + received += tTokens; + + // Rebalance tree + int200 d; + if (pool.balance < pool.target) { + d = -int200(uint200(pool.target - pool.balance)); + } else { + d = int200(uint200(pool.balance - pool.target)); + } + stakingPoolTree.updateDivergence(validatorIDs[i], d); + } + } + + totalAssets += received; + // Calculate shares based on current exchange rate + shares = received.divWad(exchangeRate); + if (shares == 0) revert DepositTooSmall(); + // Mint shares to receiver + _mint(receiver, shares); + + emit Deposit(msg.sender, assets, shares); + } + + // TODO: Improve strategy of how much to draw from each validator with divergence ratio + function unstake(uint256 shares, uint256 minAmount) external returns (uint256 unstakeID) { + // Get unstakeID + unstakeID = ++lastUnstakeID; + + // Calculate amount of tokens that need to be unstaked + uint256 amount = shares.mulWad(exchangeRate); + if (amount < minAmount) revert UnstakeSlippage(); + + // Burn shares to prevent re-entrancy (after calculating amount !!) + _burn(msg.sender, shares); + + uint256 k = stakingPoolTree.getSize(); + uint256 maxDrawdown = (totalAssets - amount) / k; + address[] memory tTokens = new address[](k); + uint256[] memory unlockIDs = new uint256[](k); + // Start looping the tree from top to bottom + uint256 remaining = amount; + uint24 id = stakingPoolTree.getLast(); + + for (uint256 i = 0; i < k; i++) { + StakingPool storage pool = stakingPools[id]; + if (maxDrawdown >= pool.balance) { + id = stakingPoolTree.findPredecessor(id); + continue; + } + uint256 max = pool.balance - maxDrawdown; // Edge case with rounding + uint256 draw = max < remaining ? max : remaining; + tTokens[i] = pool.tToken; + pool.balance -= draw; + unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); + + // Get next id before updating + uint24 nextId = stakingPoolTree.findPredecessor(id); + + int200 d; + if (pool.balance < pool.target) { + d = -int200(uint200(pool.target - pool.balance)); + } else { + d = int200(uint200(pool.balance - pool.target)); + } + stakingPoolTree.updateDivergence(id, d); + if (draw == remaining) { + break; + } + remaining -= draw; + id = nextId; + } + + // Create unstake NFT (needed to claim withdrawal) + unstakeID = unstakeNFT.mintNFT(msg.sender); + unstakeRequests[unstakeID] = + UnstakeRequest({ amount: amount, createdAt: uint64(block.timestamp), tTokens: tTokens, unlockIDs: unlockIDs }); + + // Update state + totalAssets -= amount; + + emit Unstake(msg.sender, unstakeID, shares, amount); + } + + // TODO: make non-reentrant + function withdraw(uint256 unstakeID) external returns (uint256 amountReceived) { + UnstakeRequest storage request = unstakeRequests[unstakeID]; + + unstakeNFT.burnNFT(msg.sender, unstakeID); + + uint256 l = request.tTokens.length; + // TODO: should we send withdrawals to our contract as an intermediate step ? + for (uint256 i = 0; i < l; i++) { + if (request.tTokens[i] == address(0)) continue; + amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); + } + delete unstakeRequests[unstakeID]; + + emit Withdraw(msg.sender, unstakeID, amountReceived); + } + + // Can be used to flash unstake and sell the resulting assets in TenderSwap + function unwrap(uint256 shares, uint256 minAmount) external returns (address[] memory tTokens, uint256[] memory amounts) { + // Calculate amount of tokens that need to be unstaked + uint256 amount = shares.mulWad(exchangeRate); + if (amount < minAmount) revert UnstakeSlippage(); + + // Burn shares to prevent re-entrancy (after calculating amount !!) + _burn(msg.sender, shares); + + uint256 k = stakingPoolTree.getSize(); + uint256 maxDrawdown = (totalAssets - amount) / k; + tTokens = new address[](k); + amounts = new uint256[](k); + // Start looping the tree from top to bottom + uint256 remaining = amount; + uint24 id = stakingPoolTree.getLast(); + uint256 index = 0; + + for (uint256 i = 0; i < k; i++) { + StakingPool storage pool = stakingPools[id]; + if (maxDrawdown >= pool.balance) { + id = stakingPoolTree.findPredecessor(id); + // Didn't use this element so reduce the resulting array + // sizes by 1 + if (tTokens.length > 0) { + assembly { + mstore(tTokens, sub(mload(tTokens), 1)) + mstore(amounts, sub(mload(amounts), 1)) + } + } + continue; + } + uint256 max = pool.balance - maxDrawdown; // Edge case with rounding + uint256 draw = max < remaining ? max : remaining; + tTokens[index] = pool.tToken; + amounts[index] = draw; + index++; + pool.balance -= draw; + + // Get next id before updating + uint24 nextId = stakingPoolTree.findPredecessor(id); + + int200 d; + if (pool.balance < pool.target) { + d = -int200(uint200(pool.target - pool.balance)); + } else { + d = int200(uint200(pool.balance - pool.target)); + } + stakingPoolTree.updateDivergence(id, d); + SafeTransferLib.safeTransfer(pool.tToken, msg.sender, draw); + if (draw == remaining) { + break; + } + remaining -= draw; + if (remaining == 0) { + break; + } + id = nextId; + } + + // End truncate unused elements + if (tTokens.length > 0) { + assembly { + mstore(tTokens, index) + mstore(amounts, index) + } + } + + // Update state + totalAssets -= amount; + + emit Unwrap(msg.sender, shares, amount); + } + + function previewUnwrap(uint256 shares) external view returns (address[] memory tTokens, uint256[] memory amounts) { + if (shares > totalSupply()) revert InsufficientBalance(); + uint256 amount = shares.mulWad(exchangeRate); + + uint256 k = stakingPoolTree.getSize(); + uint256 maxDrawdown = (totalAssets - amount) / k; + tTokens = new address[](k); + amounts = new uint256[](k); + uint24 id = stakingPoolTree.getLast(); + uint256 remaining = amount; + uint256 index = 0; + + for (uint256 i = 0; i < k; i++) { + StakingPool storage pool = stakingPools[id]; + if (maxDrawdown >= pool.balance) { + id = stakingPoolTree.findPredecessor(id); + // Didn't use this element so reduce the resulting array + // sizes by 1 + if (tTokens.length > 0) { + assembly { + mstore(tTokens, sub(mload(tTokens), 1)) + mstore(amounts, sub(mload(amounts), 1)) + } + } + continue; + } + uint256 max = pool.balance - maxDrawdown; // Edge case with rounding + uint256 draw = max < remaining ? max : remaining; + tTokens[index] = pool.tToken; + amounts[index] = draw; + index++; + remaining -= draw; + if (remaining == 0) { + break; + } + id = stakingPoolTree.findPredecessor(id); + } + + // End truncate unused elements + if (tTokens.length > 0) { + assembly { + mstore(tTokens, index) + mstore(amounts, index) + } + } + } + + function claimValidatorRewards(uint24 id) external { + // Update the balance of the validator + StakingPool storage pool = stakingPools[id]; + Tenderizer tenderizer = Tenderizer(pool.tToken); + uint256 newBalance = tenderizer.balanceOf(address(this)); + uint256 currentBalance = pool.balance; + if (newBalance > currentBalance) { + uint256 fees = newBalance - currentBalance * fee / FEE_WAD; + totalAssets += newBalance - currentBalance - fees; + if (fees > 0) _mint(registry.treasury(), fees); + } else { + totalAssets -= currentBalance - newBalance; + } + + int200 d; + if (newBalance < pool.target) { + d = -int200(uint200(pool.target - newBalance)); + } else { + d = int200(uint200(newBalance - pool.target)); + } + + pool.balance = newBalance; + exchangeRate = totalAssets.divWad(totalSupply()); + + // Will revert if node doesn't exist in the tree + stakingPoolTree.updateDivergence(id, d); + } + + // Governance functions + function addValidator(address payable tToken, uint200 target) external onlyRole(GOVERNANCE_ROLE) { + // TODO: Validate tToken + if (!registry.isTenderizer(tToken)) revert InvalidTenderizer(tToken); + // TODO: would this work for ID ? + uint24 id = stakingPoolTree.getSize(); + stakingPools[id] = StakingPool(tToken, target, 0); + // TODO: if we consider the target as the validators full stake (including its total delegation) we would + // need to initialise that here + stakingPoolTree.insert(id, -int200(target)); + + emit ValidatorAdded(id, tToken, target); + } + + function validatorCount() external view returns (uint256) { + return stakingPoolTree.getSize(); + } + + // This only updates the divergence of the current validator. Depending on the weighting used the divergences + // for other validators may also need to be updated. The contract currently uses lazy-updating of divergences + // when validators are next accessed. + function removeValidator(uint24 id) external onlyRole(GOVERNANCE_ROLE) { + // TODO: move the stake from this validator or require that this validator has no balance left + if (stakingPools[id].balance > 0) revert BalanceNotZero(); + stakingPoolTree.remove(id); + delete stakingPools[id]; + + emit ValidatorRemoved(id); + } + + function updateTarget(uint24 id, uint200 target) external onlyRole(GOVERNANCE_ROLE) { + StakingPool storage pool = stakingPools[id]; + // update divergence, will revert if node doesn't exist in the tree + int200 d; + if (pool.balance < target) { + d = -int200(target - uint200(pool.balance)); + } else { + d = int200(target - uint200(pool.balance)); + } + stakingPoolTree.updateDivergence(id, d); + pool.target = target; + } + + function setFee(uint256 _fee) external onlyRole(GOVERNANCE_ROLE) { + if (_fee > MAX_FEE) revert(); + fee = _fee; + } + + // Override required by UUPSUpgradeable + function _authorizeUpgrade(address) internal override onlyRole(UPGRADE_ROLE) { } +} diff --git a/src/multi-validator/UnstakeNFT.sol b/src/multi-validator/UnstakeNFT.sol new file mode 100644 index 0000000..c367874 --- /dev/null +++ b/src/multi-validator/UnstakeNFT.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +import { ERC721 } from "solady/tokens/ERC721.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; + +import { OwnableUpgradeable } from "openzeppelin-contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "openzeppelin-contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "openzeppelin-contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { Strings } from "openzeppelin-contracts/utils/Strings.sol"; + +import { Base64 } from "core/unlocks/Base64.sol"; + +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; + +pragma solidity >=0.8.19; + +interface GetUnstakeRequest { + function getUnstakeRequest(uint256 id) external view returns (MultiValidatorLST.UnstakeRequest memory); +} + +contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC721 { + using Strings for uint256; + using Strings for address; + + error NotOwner(uint256 id, address caller, address owner); + error NotMinter(address caller); + error InvalidID(uint256 id); + + uint256 lastID; + address token; + address minter; // ctToken + + constructor() ERC721() { + _disableInitializers(); + } + + modifier onlyMinter() { + if (msg.sender != minter) { + revert NotMinter(msg.sender); + } + _; + } + + function name() public view override returns (string memory) { + return string.concat("Unsteaking ", ERC20(token).name()); + } + + function symbol() public view override returns (string memory) { + return string.concat("Unst", ERC20(token).symbol()); + } + + function initialize(address _token, address _minter) external initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + token = _token; + minter = _minter; + } + + function getRequest(uint256 id) public view returns (MultiValidatorLST.UnstakeRequest memory) { + return GetUnstakeRequest(minter).getUnstakeRequest(id); + } + + function mintNFT(address to) external onlyMinter returns (uint256 unstakeID) { + unstakeID = ++lastID; + _safeMint(to, unstakeID); + } + + function burnNFT(address from, uint256 id) external { + if (ownerOf(id) != from) { + revert NotOwner(id, msg.sender, from); + } + _burn(id); + } + + function setMinter(address _minter) external onlyOwner { + minter = _minter; + } + + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (ownerOf(tokenId) == address(0)) { + revert InvalidID(tokenId); + } + return json(getRequest(tokenId)); + } + + /** + * @notice Returns the JSON metadata for a given unlock + * @param data metadata for the token + */ + function json(MultiValidatorLST.UnstakeRequest memory data) public view returns (string memory) { + return string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + abi.encodePacked( + '{"name":', symbol(), '"description":', name(), ",", '"attributes":[', _serializeMetadata(data), "]}" + ) + ) + ) + ); + } + + function svg(MultiValidatorLST.UnstakeRequest memory data) external pure returns (string memory) { + return string( + abi.encodePacked( + '", + Base64.encode( + abi.encodePacked( + "", + // "", + // data.token.toHexString(), + // '', + '>', + data.amount.toString(), + '', + uint256(data.createdAt).toString(), + "" + ) + ) + ) + ); + } + + function _serializeMetadata(MultiValidatorLST.UnstakeRequest memory data) + internal + pure + returns (string memory metadataString) + { + metadataString = string( + abi.encodePacked( + '{"trait_type": "createdAt", "value":', + uint256(data.createdAt).toString(), + "},", + '{"trait_type": "amount", "value":', + data.amount.toString(), + "}," + ) + ); + // '{"trait_type": "token", "value":"', + // data.token.toHexString(), + // '"},' + } + + ///@dev required by the OZ UUPS module + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner { } +} diff --git a/src/tenderizer/Tenderizer.sol b/src/tenderizer/Tenderizer.sol index a89210b..c211f56 100644 --- a/src/tenderizer/Tenderizer.sol +++ b/src/tenderizer/Tenderizer.sol @@ -30,7 +30,6 @@ import { addressToString } from "core/utils/Utils.sol"; * @notice Liquid staking vault for native liquid staking * @dev Uses full type safety and unstructured storage */ - contract Tenderizer is TenderizerImmutableArgs, TenderizerEvents, TToken, Multicall, SelfPermit { error InsufficientAssets(); diff --git a/src/tendertoken/Wrapper.sol b/src/tendertoken/Wrapper.sol new file mode 100644 index 0000000..ab4616c --- /dev/null +++ b/src/tendertoken/Wrapper.sol @@ -0,0 +1,47 @@ +pragma solidity ^0.8.19; + +import { WtToken } from "core/tendertoken/wTToken.sol"; +import { Registry } from "core/registry/Registry.sol"; +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +Registry constant REGISTRY = Registry(0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE); + +contract Wrapper { + error NotTToken(); + + event NewWrappedToken(address tToken, address wtToken); + + mapping(address tToken => WtToken wtToken) private wrappers; + + function createWrappedToken(address tToken) public { + if (address(wrappers[tToken]) != address(0)) revert(); + if (!REGISTRY.isTenderizer(tToken)) revert NotTToken(); + WtToken wtToken = new WtToken(tToken); + wrappers[tToken] = wtToken; + emit NewWrappedToken(tToken, address(wtToken)); + } + + function wrap(address tToken, uint256 amount) external returns (address, uint256) { + WtToken wtToken = wrappers[tToken]; + if (address(wtToken) == address(0)) createWrappedToken(tToken); + + ERC20(tToken).transferFrom(msg.sender, address(this), amount); + ERC20(tToken).approve(address(wtToken), amount); + + uint256 wrapped = wtToken.wrap(amount); + ERC20(wtToken).transfer(msg.sender, wrapped); + return (address(wtToken), wrapped); + } + + function unwrap(address wtToken, uint256 amount) external returns (address, uint256) { + ERC20(wtToken).transferFrom(msg.sender, address(this), amount); + uint256 unwrapped = WtToken(wtToken).unwrap(amount); + address tToken = address(WtToken(wtToken).tToken()); + ERC20(tToken).transfer(msg.sender, unwrapped); + return (tToken, unwrapped); + } + + function wrappedToken(address tToken) external view returns (address) { + return address(wrappers[tToken]); + } +} diff --git a/src/tendertoken/WtToken.sol b/src/tendertoken/WtToken.sol new file mode 100644 index 0000000..5225dfa --- /dev/null +++ b/src/tendertoken/WtToken.sol @@ -0,0 +1,53 @@ +pragma solidity ^0.8.19; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +interface ITToken { + function transferFrom(address, address, uint256) external; + function transfer(address, uint256) external; + function convertToShares(uint256) external view returns (uint256); + function convertToAssets(uint256) external view returns (uint256); +} + +contract WtToken is ERC20 { + event Wrap(address indexed tToken, uint256 amount, uint256 wrappedAmount); + event Unwrap(address indexed tToken, uint256 amount, uint256 unwrappedAmount); + + ITToken public tToken; + + constructor(address _tToken) ERC20("name", "symbol", 18) { + tToken = ITToken(_tToken); + } + + function wrap(uint256 amount) external returns (uint256) { + uint256 shares = tToken.convertToShares(amount); + _mint(msg.sender, shares); + tToken.transferFrom(msg.sender, address(this), amount); + emit Wrap(address(tToken), amount, shares); + return shares; + } + + function unwrap(uint256 amount) external returns (uint256) { + uint256 amountFromShares = tToken.convertToAssets(amount); + _burn(msg.sender, amount); + tToken.transfer(msg.sender, amountFromShares); + emit Unwrap(address(tToken), amount, amountFromShares); + return amountFromShares; + } + + function getWtTokenByTToken(uint256 amount) external view returns (uint256) { + return tToken.convertToShares(amount); + } + + function getTTokenByWtToken(uint256 amount) external view returns (uint256) { + return tToken.convertToAssets(amount); + } + + function tTokenPerWtToken() external view returns (uint256) { + return tToken.convertToAssets(1 ether); + } + + function wtTokenPerTToken() external view returns (uint256) { + return tToken.convertToShares(1 ether); + } +} diff --git a/test/fork-tests/Fixture.sol b/test/fork-tests/Fixture.sol index 5b20408..0f8352c 100644 --- a/test/fork-tests/Fixture.sol +++ b/test/fork-tests/Fixture.sol @@ -35,18 +35,18 @@ struct TenderizerFixture { function tenderizerFixture() returns (TenderizerFixture memory) { bytes32 salt = bytes32(uint256(1)); - Registry registry = new Registry{salt: salt}(); - address registryProxy = address(new ERC1967Proxy{salt: salt}(address(registry), "")); + Registry registry = new Registry{ salt: salt }(); + address registryProxy = address(new ERC1967Proxy{ salt: salt }(address(registry), "")); - Renderer renderer = new Renderer{salt: salt}(); - ERC1967Proxy rendererProxy = new ERC1967Proxy{salt: salt}(address(renderer), abi.encodeCall(renderer.initialize, ())); - Unlocks unlocks = new Unlocks{salt: salt}(address(registryProxy), address(rendererProxy)); + Renderer renderer = new Renderer{ salt: salt }(); + ERC1967Proxy rendererProxy = new ERC1967Proxy{ salt: salt }(address(renderer), abi.encodeCall(renderer.initialize, ())); + Unlocks unlocks = new Unlocks{ salt: salt }(address(registryProxy), address(rendererProxy)); - Tenderizer tenderizer = new Tenderizer{salt: salt}(registryProxy, address(unlocks)); + Tenderizer tenderizer = new Tenderizer{ salt: salt }(registryProxy, address(unlocks)); Registry(registryProxy).initialize(address(tenderizer), address(unlocks)); - Factory factory = new Factory{salt: salt}(address(registryProxy)); + Factory factory = new Factory{ salt: salt }(address(registryProxy)); Registry(registryProxy).grantRole(FACTORY_ROLE, address(factory)); diff --git a/test/multi-validator/MultiValidatorLST_GRT.t.sol b/test/multi-validator/MultiValidatorLST_GRT.t.sol new file mode 100644 index 0000000..31f4a5c --- /dev/null +++ b/test/multi-validator/MultiValidatorLST_GRT.t.sol @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { Test, console2 } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol"; + +import { MultiValidatorFactory } from "core/multi-validator/Factory.sol"; +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { UnstakeNFT } from "core/multi-validator/MultiValidatorLST.sol"; + +import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; + +import { ERC721Receiver } from "core/utils/ERC721Receiver.sol"; + +import { GRT } from "core/adapters/GraphAdapter.sol"; + +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; + +// Existing GRT Tenderizer addresses on Arbitrum +address constant TENDERIZER_1 = 0x3458BB72b03B5ca1Cf5fd8C7a071B828Ea27d75b; +address constant TENDERIZER_2 = 0x28196d85e9c373f51CB13F95860aC02F6D184E58; +address constant TENDERIZER_3 = 0x9e0c3A2d1DdC81017083409bDD3f9bA07a3191D4; +address constant TENDERIZER_4 = 0xeab62Fb116f2e1f766A8a64094389553a00C2F68; +address constant TENDERIZER_5 = 0x6469F96a6E9aB573cC4805be369d1da4BF6d1769; +address constant TENDERIZER_6 = 0xCC1e5ed617eE900Fd02AddD679ef2bBDC5F910Bc; +address constant TENDERIZER_7 = 0xfF14e5D8ce40666eE9394cf036f3024D92e181d3; +address constant TENDERIZER_8 = 0x08a60C1173b00f3e00b95B8d146c1Acd0b06B6D6; +address constant TENDERIZER_9 = 0x71B27d308A8ae816D6e583e5f4992110d12f2b92; +address constant TENDERIZER_10 = 0xC6a97c176b809A30F3e3e41B8e822D86d3349916; +address constant TENDERIZER_11 = 0xF157AE69D25931E386E51B653be31E19598c6545; +address constant TENDERIZER_12 = 0x8CB1fDcD22c4cA8f477E2a2B841D56C5cF09b081; +address constant TENDERIZER_13 = 0x27Fe8C05aD08c48A854118ecA2703cb3B7b4651d; + +address constant alice = address(0x5678); +address constant bob = address(0x9ABC); +address constant registry = 0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE; + +// Livepeer specific +address constant minter = 0xc20DE37170B45774e6CD3d2304017fc962f27252; // GRT Minter address + +// ILivepeerRounds constant ROUNDS = ILivepeerRounds(address(LIVEPEER_ROUNDS)); +// uint256 constant ROUND_LENGTH = 6377; // round length in blocks +address constant GOVERNOR = 0x8C6de8F8D562f3382417340A6994601eE08D3809; + +// interface ILivepeerRounds is ILivepeerRoundsManager { +// function initializeRound() external; +// } + +contract MultiValidatorLSTTest is Test, ERC721Receiver { + // function _processRounds(uint256 rounds) internal { + // for (uint256 i = 0; i < rounds; i++) { + // uint256 currentRoundStartBlock = ROUNDS.currentRoundStartBlock(); + // vm.roll(currentRoundStartBlock + ROUND_LENGTH); + // ROUNDS.initializeRound(); + // } + // } + address immutable MINTER_ROLE = makeAddr("MINTER_ROLE"); + + address immutable deployer = 0xc1cFab553835D74717c4499793EEa6Ef198A3031; + + MultiValidatorFactory factory; + + MultiValidatorLST lst; + + function mintTokens(address to, uint256 amount) public { + vm.prank(MINTER_ROLE); + MockERC20(address(GRT)).mint(to, amount); + } + + function setUp() public { + vm.createSelectFork(vm.envString("ARBITRUM_RPC"), 326_906_474); + // Use labeled addresses for better test output + vm.label(TENDERIZER_1, "Tenderizer1"); + vm.label(TENDERIZER_2, "Tenderizer2"); + vm.label(TENDERIZER_3, "Tenderizer3"); + vm.label(deployer, "Deployer"); + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + + address factoryImpl = address(new MultiValidatorFactory()); + factory = + MultiValidatorFactory(address(new ERC1967Proxy{ salt: bytes32("MultiValidatorLSTFactory") }(address(factoryImpl), ""))); + factory.initialize(); + + vm.startPrank(deployer); + lst = MultiValidatorLST(factory.deploy(address(GRT))); + lst.setFee(0.05e6); // 5% fee + + lst.addValidator(payable(TENDERIZER_1), 3_000_000 ether); // 3M Stake + lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake + lst.addValidator(payable(TENDERIZER_3), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_4), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_5), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_6), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_7), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_8), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_9), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_10), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_11), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_12), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_13), 1_000_000 ether); // 1M Stake + + vm.stopPrank(); + + // Add MINTER_ROLE + vm.prank(GOVERNOR); + // solhint-disable-next-line avoid-low-level-calls + (bool success,) = address(GRT).call(abi.encodeWithSignature("addMinter(address)", (address(MINTER_ROLE)))); + } + + function test_genesis_state() public { + assertEq(lst.name(), "Steaked GRT", "Name should be 'Steaked GRT'"); + assertEq(lst.symbol(), "stGRT", "Symbol should be 'sGRT'"); + assertEq(lst.fee(), 0.05e6, "Fee should be set to 5%"); + assertEq(lst.totalAssets(), 0, "Initial total assets should be 0"); + assertEq(lst.totalSupply(), 0, "Initial total supply should be 0"); + + // Check validators are properly set up + (address tToken1, uint256 target1,) = lst.stakingPools(0); + (address tToken2, uint256 target2,) = lst.stakingPools(1); + (address tToken3, uint256 target3,) = lst.stakingPools(2); + + assertEq(tToken1, TENDERIZER_1, "First validator should be TENDERIZER_1"); + assertEq(tToken2, TENDERIZER_2, "Second validator should be TENDERIZER_2"); + assertEq(tToken3, TENDERIZER_3, "Third validator should be TENDERIZER_3"); + + assertEq(target1, 3_000_000 ether, "First validator target weight should be 3M"); + assertEq(target2, 2_000_000 ether, "Second validator target weight should be 2M"); + assertEq(target3, 1_000_000 ether, "Third validator target weight should be 1M"); + + assertEq(lst.validatorCount(), 13, "Validator count should be 13"); + } + + function test_deposit() public { + uint256 depositAmount = 10 ether; + + mintTokens(alice, depositAmount); + + (address tToken1,, uint256 balance1) = lst.stakingPools(0); + (address tToken2,, uint256 balance2) = lst.stakingPools(1); + (address tToken3,, uint256 balance3) = lst.stakingPools(2); + + vm.startPrank(alice); + + // Approve GRT transfer to LST + GRT.approve(address(lst), depositAmount); + + // Record initial balances + uint256 initialGRTBalance = GRT.balanceOf(alice); + + // Deposit GRT to LST + uint256 shares = lst.deposit(alice, depositAmount); + + vm.stopPrank(); + + // Assert Alice received shares + assertGt(shares, 0, "Alice should receive shares"); + assertEq(lst.balanceOf(alice), shares, "Alice should have shares in LST"); + + // Assert Alice's GRT balance decreased + assertEq(GRT.balanceOf(alice), initialGRTBalance - depositAmount, "Alice's GRT balance should decrease"); + assertEq(lst.balanceOf(alice), shares, "Alice's LST balance should increase"); + // Assert total assets increased + + // Assert stake was distributed across validators according to targets + (,, balance1) = lst.stakingPools(0); + (,, balance2) = lst.stakingPools(1); + (,, balance3) = lst.stakingPools(2); + uint256 tTokenBalance1 = Tenderizer(payable(tToken1)).balanceOf(address(lst)); + uint256 tTokenBalance2 = Tenderizer(payable(tToken2)).balanceOf(address(lst)); + uint256 tTokenBalance3 = Tenderizer(payable(tToken3)).balanceOf(address(lst)); + + assertEq(balance1, tTokenBalance1, "Validator 1 balance should increase"); + assertEq(balance2, tTokenBalance2, "Validator 2 balance should increase"); + assertEq(balance3, tTokenBalance3, "Validator 3 balance should increase"); + // The sum of all tToken balances should equal total assets + assertEq( + tTokenBalance1 + tTokenBalance2 + tTokenBalance3, + lst.totalAssets(), + "Sum of validator balances should equal total tTokens" + ); + + // Since all validators start with 0 balance and the divergence is negative for all, + // distribution should prioritize the validators with highest target weight to balance them + // We'd expect more to go to validator 1, then 2, then 3 + assertGt(balance1, balance2, "Validator 1 should receive more than Validator 2"); + assertGt(balance2, balance3, "Validator 2 should receive more than Validator 3"); + } + + // function test_unwrap() public { + // uint256 depositAmount = 1_000_000 ether; + + // // Mint tokens for Alice + // mintTokens(alice, depositAmount); + + // // Get initial validator data + // (address tToken1,,) = lst.stakingPools(0); + // (address tToken2,,) = lst.stakingPools(1); + // (address tToken3,,) = lst.stakingPools(2); + + // // Setup: Alice deposits first + // vm.startPrank(alice); + // GRT.approve(address(lst), depositAmount); + // uint256 shares = lst.deposit(alice, depositAmount); + + // // Record balances after deposit + // (,, uint256 balance1AfterDeposit) = lst.stakingPools(0); + // (,, uint256 balance2AfterDeposit) = lst.stakingPools(1); + // (,, uint256 balance3AfterDeposit) = lst.stakingPools(2); + + // // Unwrap half of Alice's shares + // uint256 sharesToUnwrap = shares / 2; + // uint256 expectedAmount = lst.totalAssets() / 2; // Since 1:1 ratio + + // // Perform unwrap with minimum amount check (allow 1% slippage) + // (, uint256[] memory amounts) = lst.unwrap(sharesToUnwrap, expectedAmount); + // vm.stopPrank(); + + // // Assert Alice's shares were burned + // assertEq(lst.balanceOf(alice), shares - sharesToUnwrap, "Alice's shares should decrease"); + // assertEq( + // FixedPointMathLib.mulWad(lst.balanceOf(alice), lst.exchangeRate()), + // expectedAmount, + // "Alice's GRT balance should increase" + // ); + // // Assert total assets decreased + // assertEq(lst.totalAssets(), expectedAmount, "Total assets should decrease by half"); + + // console2.log("total assets", lst.totalAssets()); + // console2.log("alice expected underlying", FixedPointMathLib.mulWad(lst.balanceOf(alice), lst.exchangeRate())); + // // // calculate draw from each tToken + // // uint256 draw1; + // // uint256 draw2; + // // uint256 draw3; + // // { + // // uint256 avgStake = FixedPointMathLib.divWad(10 ether, 3); + // // uint256 maxDraw = FixedPointMathLib.divWad(avgStake, 2); + + // // draw1 = maxDraw > balance1AfterDeposit ? balance1AfterDeposit : balance1AfterDeposit - maxDraw; + // // draw2 = maxDraw > balance2AfterDeposit ? balance2AfterDeposit : balance2AfterDeposit - maxDraw; + // // draw3 = maxDraw > balance3AfterDeposit ? balance3AfterDeposit : balance3AfterDeposit - maxDraw; + // // } + // // /* + // // uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + // // uint256 draw = max < remaining ? max : remaining; + // // */ + // // // Check validator balances after unwrap + // // { + // // (,, uint256 balance1AfterUnwrap) = lst.stakingPools(0); + // // (,, uint256 balance2AfterUnwrap) = lst.stakingPools(1); + // // (,, uint256 balance3AfterUnwrap) = lst.stakingPools(2); + + // // // Verify that validator balances decreased + // // assertEq(balance1AfterUnwrap, balance1AfterDeposit - draw1, "Validator 1 balance should decrease"); + // // assertEq(balance2AfterUnwrap, balance2AfterDeposit - draw1, "Validator 2 balance should decrease"); + // // assertEq(balance3AfterUnwrap, balance3AfterDeposit - draw1, "Validator 3 balance should decrease"); + // // } + + // // assertEq(amounts[0], draw1, "Returned tToken1 amount should match drawn amount"); + // // assertEq(amounts[1], draw2, "Returned tToken2 amount should match drawn amount"); + // // assertEq(amounts[2], draw3, "Returned tToken3 amount should match drawn amount"); + // // assertEq(Tenderizer(payable(tToken1)).balanceOf(alice), draw1, "Alice should receive correct tToken1 amount"); + // // assertEq(Tenderizer(payable(tToken2)).balanceOf(alice), draw2, "Alice should receive correct tToken2 amount"); + // // assertEq(Tenderizer(payable(tToken3)).balanceOf(alice), draw3, "Alice should receive correct tToken3 amount"); + + // // // The tTokens Alice receives should match what the validators lost + // // assertEq(aliceTToken1Received, balance1AfterDeposit - balance1AfterUnwrap, "Alice should receive correct tToken1 + // // amount"); + // // assertEq(aliceTToken2Received, balance2AfterDeposit - balance2AfterUnwrap, "Alice should receive correct tToken2 + // // amount"); + // // assertEq(aliceTToken3Received, balance3AfterDeposit - balance3AfterUnwrap, "Alice should receive correct tToken3 + // // amount"); + + // // // The sum of received tToken amounts should equal the unwrapped value (half the deposit) + // // uint256 totalReceived = aliceTToken1Received + aliceTToken2Received + aliceTToken3Received; + // // assertEq(totalReceived, expectedAmount, "Total received should match expected unwrap amount"); + + // // // Verify the returned tTokens and amounts match what Alice received + // // bool foundToken1 = false; + // // bool foundToken2 = false; + // // bool foundToken3 = false; + + // // for (uint256 i = 0; i < tTokens.length; i++) { + // // if (tTokens[i] == tToken1) { + // // assertEq(amounts[i], aliceTToken1Received, "Returned tToken1 amount should match received"); + // // foundToken1 = true; + // // } else if (tTokens[i] == tToken2) { + // // assertEq(amounts[i], aliceTToken2Received, "Returned tToken2 amount should match received"); + // // foundToken2 = true; + // // } else if (tTokens[i] == tToken3) { + // // assertEq(amounts[i], aliceTToken3Received, "Returned tToken3 amount should match received"); + // // foundToken3 = true; + // // } + // // } + + // // // Ensure all tokens were accounted for in the return values + // // assertTrue(foundToken1 || aliceTToken1Received == 0, "tToken1 should be in return array if amount > 0"); + // // assertTrue(foundToken2 || aliceTToken2Received == 0, "tToken2 should be in return array if amount > 0"); + // // assertTrue(foundToken3 || aliceTToken3Received == 0, "tToken3 should be in return array if amount > 0"); + // // } + // } + + // function test_unstake_withdraw() public { + // uint256 depositAmount = 1_000_000 ether; + + // // Mint tokens for Alice + // mintTokens(alice, depositAmount); + + // // Setup: Alice deposits first + // vm.startPrank(alice); + // GRT.approve(address(lst), depositAmount); + // uint256 shares = lst.deposit(alice, depositAmount); + + // //_processRounds(1); + + // uint256 sharesToUnstake = shares / 2; + // uint256 expectedAmount = FixedPointMathLib.mulWad(sharesToUnstake, lst.exchangeRate()); + // uint256 id = lst.unstake(sharesToUnstake, expectedAmount); + + // assertEq(id, 1, "Unstake ID should be 1"); + + // MultiValidatorLST.UnstakeRequest memory req = lst.getUnstakeRequest(id); + + // for (uint256 i = 0; i < req.tTokens.length; i++) { + // console2.log("tToken %s", req.tTokens[i]); + // } + + // assertEq(req.amount, expectedAmount, "Unstake request amount should match expected amount"); + + // // _processRounds(7); + + // uint256 balBefore = GRT.balanceOf(alice); + // uint256 amount = lst.withdraw(id); + // uint256 balAfter = GRT.balanceOf(alice); + + // assertEq(amount, balAfter - balBefore, "Withdraw amount should match expected amount"); + // } +} diff --git a/test/multi-validator/MultiValidatorLST_LPT.t.sol b/test/multi-validator/MultiValidatorLST_LPT.t.sol new file mode 100644 index 0000000..a465ac6 --- /dev/null +++ b/test/multi-validator/MultiValidatorLST_LPT.t.sol @@ -0,0 +1,349 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { Test, console2 } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol"; + +import { MultiValidatorFactory } from "core/multi-validator/Factory.sol"; +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; +import { UnstakeNFT } from "core/multi-validator/MultiValidatorLST.sol"; + +import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; + +import { ERC721Receiver } from "core/utils/ERC721Receiver.sol"; + +import { LPT, LIVEPEER_ROUNDS, ILivepeerRoundsManager } from "core/adapters/LivepeerAdapter.sol"; + +import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; + +// Existing LPT Tenderizer addresses on Arbitrum +address constant TENDERIZER_1 = 0x4b7339E599a599DBd7829a8ECA0d233ED4F7eA09; +address constant TENDERIZER_2 = 0xFB32bF22B4F004a088c1E7d69e29492f5D7CD7E1; +address constant TENDERIZER_3 = 0x6DFd5Cee0Ed2ec24Fdc814Ad857902DE01c065d6; +address constant TENDERIZER_4 = 0xbEb81a62E9A8463C22a3f999846F3E3FB2e2002A; +address constant TENDERIZER_5 = 0x3a3D463fb8241DA6051eb4DAB2200C8b99691315; +address constant TENDERIZER_6 = 0x109eA4859a99B3347db5025A920f63Ab0EF3de42; +address constant TENDERIZER_7 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; +address constant TENDERIZER_8 = 0xFBc4435A3CebC1F4bd9c56aC95cfA37dfC142f5F; +address constant TENDERIZER_9 = 0x43ef285F5e27D8CA978A7e577f4dDF52147EB77b; +address constant TENDERIZER_10 = 0x47cd6B7e7308Fb062586e5185B4F3Ee7E224eefe; +address constant TENDERIZER_11 = 0x9b6DB9Cc6E479dd28471B9C899890C20377DA200; +address constant TENDERIZER_12 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; +address constant TENDERIZER_13 = 0x03572207d14bed3dd50E0d48CfaD44bDDB8BF4B7; + +address constant alice = address(0x5678); +address constant bob = address(0x9ABC); +address constant registry = 0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE; + +// Livepeer specific +address constant minter = 0xc20DE37170B45774e6CD3d2304017fc962f27252; // LPT Minter address + +ILivepeerRounds constant ROUNDS = ILivepeerRounds(address(LIVEPEER_ROUNDS)); +uint256 constant ROUND_LENGTH = 6377; // round length in blocks + +interface ILivepeerRounds is ILivepeerRoundsManager { + function initializeRound() external; +} + +contract MultiValidatorLSTTest is Test, ERC721Receiver { + function _processRounds(uint256 rounds) internal { + for (uint256 i = 0; i < rounds; i++) { + uint256 currentRoundStartBlock = ROUNDS.currentRoundStartBlock(); + vm.roll(currentRoundStartBlock + ROUND_LENGTH); + ROUNDS.initializeRound(); + } + } + + address immutable deployer; + + MultiValidatorFactory factory; + + MultiValidatorLST lst; + + constructor() { + deployer = address(this); + } + + function mintTokens(address to, uint256 amount) public { + vm.prank(minter); + MockERC20(address(LPT)).mint(to, amount); + } + + function setUp() public { + vm.createSelectFork(vm.envString("ARBITRUM_RPC"), 326_906_474); + // Use labeled addresses for better test output + vm.label(TENDERIZER_1, "Tenderizer1"); + vm.label(TENDERIZER_2, "Tenderizer2"); + vm.label(TENDERIZER_3, "Tenderizer3"); + vm.label(deployer, "Deployer"); + vm.label(alice, "Alice"); + vm.label(bob, "Bob"); + + address factoryImpl = address(new MultiValidatorFactory()); + factory = + MultiValidatorFactory(address(new ERC1967Proxy{ salt: bytes32("MultiValidatorLSTFactory") }(address(factoryImpl), ""))); + factory.initialize(); + + vm.startPrank(deployer); + lst = MultiValidatorLST(factory.deploy(address(LPT))); + lst.setFee(0.05e6); // 5% fee + + lst.addValidator(payable(TENDERIZER_1), 3_000_000 ether); // 3M Stake + lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake + lst.addValidator(payable(TENDERIZER_3), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_4), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_5), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_6), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_7), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_8), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_9), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_10), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_11), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_12), 1_000_000 ether); // 1M Stake + lst.addValidator(payable(TENDERIZER_13), 1_000_000 ether); // 1M Stake + + vm.stopPrank(); + } + + function test_genesis_state() public { + assertEq(lst.name(), "Steaked LPT", "Name should be 'Steaked LPT'"); + assertEq(lst.symbol(), "stLPT", "Symbol should be 'sLPT'"); + assertEq(lst.fee(), 0.05e6, "Fee should be set to 5%"); + assertEq(lst.totalAssets(), 0, "Initial total assets should be 0"); + assertEq(lst.totalSupply(), 0, "Initial total supply should be 0"); + + // Check validators are properly set up + (address tToken1, uint256 target1,) = lst.stakingPools(0); + (address tToken2, uint256 target2,) = lst.stakingPools(1); + (address tToken3, uint256 target3,) = lst.stakingPools(2); + + assertEq(tToken1, TENDERIZER_1, "First validator should be TENDERIZER_1"); + assertEq(tToken2, TENDERIZER_2, "Second validator should be TENDERIZER_2"); + assertEq(tToken3, TENDERIZER_3, "Third validator should be TENDERIZER_3"); + + assertEq(target1, 3_000_000 ether, "First validator target weight should be 3M"); + assertEq(target2, 2_000_000 ether, "Second validator target weight should be 2M"); + assertEq(target3, 1_000_000 ether, "Third validator target weight should be 1M"); + + assertEq(lst.validatorCount(), 13, "Validator count should be 13"); + } + + function test_deposit() public { + uint256 depositAmount = 10 ether; + + mintTokens(alice, depositAmount); + + (address tToken1,, uint256 balance1) = lst.stakingPools(0); + (address tToken2,, uint256 balance2) = lst.stakingPools(1); + (address tToken3,, uint256 balance3) = lst.stakingPools(2); + + vm.startPrank(alice); + + // Approve LPT transfer to LST + LPT.approve(address(lst), depositAmount); + + // Record initial balances + uint256 initialLPTBalance = LPT.balanceOf(alice); + + // Deposit LPT to LST + uint256 shares = lst.deposit(alice, depositAmount); + + vm.stopPrank(); + + // Assert Alice received shares + assertGt(shares, 0, "Alice should receive shares"); + assertEq(lst.balanceOf(alice), shares, "Alice should have shares in LST"); + + // Assert Alice's LPT balance decreased + assertEq(LPT.balanceOf(alice), initialLPTBalance - depositAmount, "Alice's LPT balance should decrease"); + assertEq(lst.balanceOf(alice), shares, "Alice's LST balance should increase"); + // Assert total assets increased + + // Assert stake was distributed across validators according to targets + (,, balance1) = lst.stakingPools(0); + (,, balance2) = lst.stakingPools(1); + (,, balance3) = lst.stakingPools(2); + uint256 tTokenBalance1 = Tenderizer(payable(tToken1)).balanceOf(address(lst)); + uint256 tTokenBalance2 = Tenderizer(payable(tToken2)).balanceOf(address(lst)); + uint256 tTokenBalance3 = Tenderizer(payable(tToken3)).balanceOf(address(lst)); + + assertEq(balance1, tTokenBalance1, "Validator 1 balance should increase"); + assertEq(balance2, tTokenBalance2, "Validator 2 balance should increase"); + assertEq(balance3, tTokenBalance3, "Validator 3 balance should increase"); + // The sum of all tToken balances should equal total assets + assertEq( + tTokenBalance1 + tTokenBalance2 + tTokenBalance3, + lst.totalAssets(), + "Sum of validator balances should equal total tTokens" + ); + + // Since all validators start with 0 balance and the divergence is negative for all, + // distribution should prioritize the validators with highest target weight to balance them + // We'd expect more to go to validator 1, then 2, then 3 + assertGt(balance1, balance2, "Validator 1 should receive more than Validator 2"); + assertGt(balance2, balance3, "Validator 2 should receive more than Validator 3"); + } + + function test_unwrap() public { + uint256 depositAmount = 1_000_000 ether; + + // Mint tokens for Alice + mintTokens(alice, depositAmount); + + // Get initial validator data + (address tToken1,,) = lst.stakingPools(0); + (address tToken2,,) = lst.stakingPools(1); + (address tToken3,,) = lst.stakingPools(2); + + // Setup: Alice deposits first + vm.startPrank(alice); + LPT.approve(address(lst), depositAmount); + uint256 shares = lst.deposit(alice, depositAmount); + + // Record balances after deposit + (,, uint256 balance1AfterDeposit) = lst.stakingPools(0); + (,, uint256 balance2AfterDeposit) = lst.stakingPools(1); + (,, uint256 balance3AfterDeposit) = lst.stakingPools(2); + + // Unwrap half of Alice's shares + uint256 sharesToUnwrap = shares / 2; + uint256 expectedAmount = lst.totalAssets() / 2; // Since 1:1 ratio + + // Perform unwrap with minimum amount check (allow 1% slippage) + (, uint256[] memory amounts) = lst.unwrap(sharesToUnwrap, expectedAmount); + vm.stopPrank(); + + // Assert Alice's shares were burned + assertEq(lst.balanceOf(alice), shares - sharesToUnwrap, "Alice's shares should decrease"); + assertEq( + FixedPointMathLib.mulWad(lst.balanceOf(alice), lst.exchangeRate()), + expectedAmount, + "Alice's LPT balance should increase" + ); + // Assert total assets decreased + assertEq(lst.totalAssets(), expectedAmount, "Total assets should decrease by half"); + + console2.log("total assets", lst.totalAssets()); + console2.log("alice expected underlying", FixedPointMathLib.mulWad(lst.balanceOf(alice), lst.exchangeRate())); + // // calculate draw from each tToken + // uint256 draw1; + // uint256 draw2; + // uint256 draw3; + // { + // uint256 avgStake = FixedPointMathLib.divWad(10 ether, 3); + // uint256 maxDraw = FixedPointMathLib.divWad(avgStake, 2); + + // draw1 = maxDraw > balance1AfterDeposit ? balance1AfterDeposit : balance1AfterDeposit - maxDraw; + // draw2 = maxDraw > balance2AfterDeposit ? balance2AfterDeposit : balance2AfterDeposit - maxDraw; + // draw3 = maxDraw > balance3AfterDeposit ? balance3AfterDeposit : balance3AfterDeposit - maxDraw; + // } + // /* + // uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + // uint256 draw = max < remaining ? max : remaining; + // */ + // // Check validator balances after unwrap + // { + // (,, uint256 balance1AfterUnwrap) = lst.stakingPools(0); + // (,, uint256 balance2AfterUnwrap) = lst.stakingPools(1); + // (,, uint256 balance3AfterUnwrap) = lst.stakingPools(2); + + // // Verify that validator balances decreased + // assertEq(balance1AfterUnwrap, balance1AfterDeposit - draw1, "Validator 1 balance should decrease"); + // assertEq(balance2AfterUnwrap, balance2AfterDeposit - draw1, "Validator 2 balance should decrease"); + // assertEq(balance3AfterUnwrap, balance3AfterDeposit - draw1, "Validator 3 balance should decrease"); + // } + + // assertEq(amounts[0], draw1, "Returned tToken1 amount should match drawn amount"); + // assertEq(amounts[1], draw2, "Returned tToken2 amount should match drawn amount"); + // assertEq(amounts[2], draw3, "Returned tToken3 amount should match drawn amount"); + // assertEq(Tenderizer(payable(tToken1)).balanceOf(alice), draw1, "Alice should receive correct tToken1 amount"); + // assertEq(Tenderizer(payable(tToken2)).balanceOf(alice), draw2, "Alice should receive correct tToken2 amount"); + // assertEq(Tenderizer(payable(tToken3)).balanceOf(alice), draw3, "Alice should receive correct tToken3 amount"); + + // // The tTokens Alice receives should match what the validators lost + // assertEq(aliceTToken1Received, balance1AfterDeposit - balance1AfterUnwrap, "Alice should receive correct tToken1 + // amount"); + // assertEq(aliceTToken2Received, balance2AfterDeposit - balance2AfterUnwrap, "Alice should receive correct tToken2 + // amount"); + // assertEq(aliceTToken3Received, balance3AfterDeposit - balance3AfterUnwrap, "Alice should receive correct tToken3 + // amount"); + + // // The sum of received tToken amounts should equal the unwrapped value (half the deposit) + // uint256 totalReceived = aliceTToken1Received + aliceTToken2Received + aliceTToken3Received; + // assertEq(totalReceived, expectedAmount, "Total received should match expected unwrap amount"); + + // // Verify the returned tTokens and amounts match what Alice received + // bool foundToken1 = false; + // bool foundToken2 = false; + // bool foundToken3 = false; + + // for (uint256 i = 0; i < tTokens.length; i++) { + // if (tTokens[i] == tToken1) { + // assertEq(amounts[i], aliceTToken1Received, "Returned tToken1 amount should match received"); + // foundToken1 = true; + // } else if (tTokens[i] == tToken2) { + // assertEq(amounts[i], aliceTToken2Received, "Returned tToken2 amount should match received"); + // foundToken2 = true; + // } else if (tTokens[i] == tToken3) { + // assertEq(amounts[i], aliceTToken3Received, "Returned tToken3 amount should match received"); + // foundToken3 = true; + // } + // } + + // // Ensure all tokens were accounted for in the return values + // assertTrue(foundToken1 || aliceTToken1Received == 0, "tToken1 should be in return array if amount > 0"); + // assertTrue(foundToken2 || aliceTToken2Received == 0, "tToken2 should be in return array if amount > 0"); + // assertTrue(foundToken3 || aliceTToken3Received == 0, "tToken3 should be in return array if amount > 0"); + // } + } + + function test_unstake_withdraw() public { + uint256 depositAmount = 1_000_000 ether; + + // Mint tokens for Alice + mintTokens(alice, depositAmount); + + // Setup: Alice deposits first + vm.startPrank(alice); + LPT.approve(address(lst), depositAmount); + uint256 shares = lst.deposit(alice, depositAmount); + + _processRounds(1); + + uint256 sharesToUnstake = shares / 2; + uint256 expectedAmount = FixedPointMathLib.mulWad(sharesToUnstake, lst.exchangeRate()); + uint256 id = lst.unstake(sharesToUnstake, expectedAmount); + + assertEq(id, 1, "Unstake ID should be 1"); + + MultiValidatorLST.UnstakeRequest memory req = lst.getUnstakeRequest(id); + + for (uint256 i = 0; i < req.tTokens.length; i++) { + console2.log("tToken %s", req.tTokens[i]); + } + + assertEq(req.amount, expectedAmount, "Unstake request amount should match expected amount"); + + _processRounds(7); + + uint256 balBefore = LPT.balanceOf(alice); + uint256 amount = lst.withdraw(id); + uint256 balAfter = LPT.balanceOf(alice); + + assertEq(amount, balAfter - balBefore, "Withdraw amount should match expected amount"); + } +} diff --git a/test/tendertoken/WtToken.t.sol b/test/tendertoken/WtToken.t.sol new file mode 100644 index 0000000..c0b6005 --- /dev/null +++ b/test/tendertoken/WtToken.t.sol @@ -0,0 +1,60 @@ +pragma solidity >=0.8.19; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { Wrapper } from "core/tendertoken/Wrapper.sol"; +import { WtToken } from "core/tendertoken/wTToken.sol"; +import { Registry } from "core/registry/Registry.sol"; +import { TToken } from "core/tendertoken/TToken.sol"; +import { ERC20 } from "solmate/tokens/ERC20.sol"; +import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; + +contract WrapperTest is Test { + address tenderizer = 0x4b0e5E54Df6d5eCcC7B2F838982411DC93253dAf; + address user = 0xF9CcA0b41063B611Dd210250ec9754007e87de6f; + + Wrapper wrapper; + + function setUp() public { + vm.createSelectFork(vm.envString("ARBITRUM_RPC")); + wrapper = new Wrapper(); + + // make sure we rebase before we do anything + Tenderizer(payable(tenderizer)).rebase(); + } + + function test_wrap_unwrap() public { + uint256 amount = 100 ether; + // no current wrapper + assertEq(wrapper.wrappedToken(tenderizer), address(0)); + // wrap + vm.startPrank(user); + + uint256 expectedAmount = TToken(tenderizer).convertToShares(amount); + TToken(tenderizer).approve(address(wrapper), amount); + (address wTToken, uint256 wrappedAmount) = wrapper.wrap(tenderizer, amount); + vm.stopPrank(); + + assertEq(ERC20(wTToken).balanceOf(user), expectedAmount); + assertEq(wrappedAmount, expectedAmount); + + // unwrap + vm.startPrank(user); + ERC20(wTToken).approve(address(wrapper), wrappedAmount); + (address tToken, uint256 unwrappedAmount) = wrapper.unwrap(wTToken, wrappedAmount); + vm.stopPrank(); + assertEq(tToken, tenderizer); + assertEq(unwrappedAmount, amount - 1); // rounding error + } + + function test_wrap_notTToken() public { + vm.expectRevert(abi.encodeWithSelector(Wrapper.NotTToken.selector)); + wrapper.wrap(user, 100 ether); + } + + function test_unwrap_notwTToken() public { + vm.expectRevert(); + wrapper.unwrap(user, 100 ether); + } +}