From 64d036c0713c260acfdb6538f4b3f4b1fd917eab Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 4 Jun 2024 13:47:30 +0200 Subject: [PATCH 01/14] wip --- src/tendertoken/Wrapper.sol | 43 ++++++++++++++++++++++++ src/tendertoken/WtToken.sol | 48 +++++++++++++++++++++++++++ test/tendertoken/WtToken.t.sol | 60 ++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 src/tendertoken/Wrapper.sol create mode 100644 src/tendertoken/WtToken.sol create mode 100644 test/tendertoken/WtToken.t.sol diff --git a/src/tendertoken/Wrapper.sol b/src/tendertoken/Wrapper.sol new file mode 100644 index 0000000..81ed1b3 --- /dev/null +++ b/src/tendertoken/Wrapper.sol @@ -0,0 +1,43 @@ +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(); + + 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(); + wrappers[tToken] = new WtToken(tToken); + } + + 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..1ee54c1 --- /dev/null +++ b/src/tendertoken/WtToken.sol @@ -0,0 +1,48 @@ +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 { + 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); + return shares; + } + + function unwrap(uint256 amount) external returns (uint256) { + uint256 amountFromShares = tToken.convertToAssets(amount); + _burn(msg.sender, amount); + tToken.transfer(msg.sender, 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/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); + } +} From ea5bf029dad1926ac9e47cc4a02c846ef51a9846 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 10 Dec 2024 12:18:42 +0100 Subject: [PATCH 02/14] wrapped tToken --- src/tendertoken/Wrapper.sol | 6 +++++- src/tendertoken/WtToken.sol | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tendertoken/Wrapper.sol b/src/tendertoken/Wrapper.sol index 81ed1b3..ab4616c 100644 --- a/src/tendertoken/Wrapper.sol +++ b/src/tendertoken/Wrapper.sol @@ -9,12 +9,16 @@ 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(); - wrappers[tToken] = new WtToken(tToken); + WtToken wtToken = new WtToken(tToken); + wrappers[tToken] = wtToken; + emit NewWrappedToken(tToken, address(wtToken)); } function wrap(address tToken, uint256 amount) external returns (address, uint256) { diff --git a/src/tendertoken/WtToken.sol b/src/tendertoken/WtToken.sol index 1ee54c1..5225dfa 100644 --- a/src/tendertoken/WtToken.sol +++ b/src/tendertoken/WtToken.sol @@ -10,6 +10,9 @@ interface ITToken { } 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) { @@ -20,6 +23,7 @@ contract WtToken is ERC20 { uint256 shares = tToken.convertToShares(amount); _mint(msg.sender, shares); tToken.transferFrom(msg.sender, address(this), amount); + emit Wrap(address(tToken), amount, shares); return shares; } @@ -27,6 +31,7 @@ contract WtToken is ERC20 { uint256 amountFromShares = tToken.convertToAssets(amount); _burn(msg.sender, amount); tToken.transfer(msg.sender, amountFromShares); + emit Unwrap(address(tToken), amount, amountFromShares); return amountFromShares; } From baad693a9338769bd3f4433935db86ccfed7c3aa Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Tue, 25 Feb 2025 11:05:04 +0100 Subject: [PATCH 03/14] composites WIP --- .gitmodules | 3 + .vscode/settings.json | 4 +- lib/solady | 1 + remappings.txt | 1 + script/XYZ_Faucet.s.sol | 2 +- src/composites/AVLTree.Sol | 569 ++++++++++++++++++++++++++++++++++ src/composites/UnstakeNFT.sol | 150 +++++++++ src/composites/ctToken.sol | 272 ++++++++++++++++ src/factory/Factory.sol | 1 - src/tenderizer/Tenderizer.sol | 1 - test/fork-tests/Fixture.sol | 14 +- 11 files changed, 1006 insertions(+), 12 deletions(-) create mode 160000 lib/solady create mode 100644 src/composites/AVLTree.Sol create mode 100644 src/composites/UnstakeNFT.sol create mode 100644 src/composites/ctToken.sol 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/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/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/src/composites/AVLTree.Sol b/src/composites/AVLTree.Sol new file mode 100644 index 0000000..ff6a8bf --- /dev/null +++ b/src/composites/AVLTree.Sol @@ -0,0 +1,569 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +pragma solidity >=0.8.19; + +/// @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++; + 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); + if (tree.first == nodeId && newNode.divergence < current.divergence) { + tree.first = newId; + } + } else { + current.right = _insertRecursive(tree, current.right, newId, newNode); + if (tree.last == nodeId && newNode.divergence >= current.divergence) { + 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; + 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 (current != 0 && 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) internal 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) internal 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); + } + + /// @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/composites/UnstakeNFT.sol b/src/composites/UnstakeNFT.sol new file mode 100644 index 0000000..8d0b9b6 --- /dev/null +++ b/src/composites/UnstakeNFT.sol @@ -0,0 +1,150 @@ +// 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 { UnstakeRequest } from "core/composites/ctToken.sol"; + +pragma solidity >=0.8.19; + +interface GetUnstakeRequest { + function getUnstakeRequest(uint256 id) external view returns (UnstakeRequest memory); +} + +abstract contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC721 { + using Strings for uint256; + using Strings for address; + + error NotOwner(uint256 id, address caller, address owner); + error InvalidID(uint256 id); + + uint256 lastID; + address token; + address minter; // ctToken + + constructor() ERC721() { + _disableInitializers(); + } + + function initialize() external initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + } + + function getRequest(uint256 id) public view returns (UnstakeRequest memory) { + return GetUnstakeRequest(minter).getUnstakeRequest(id); + } + + function mintNFT(address to) external 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(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(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(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 { } +} + +// Example usage e.g. LPT +contract UnstLPT is UnstakeNFT { + function name() public pure override returns (string memory) { + return "Unstake LPT"; + } + + function symbol() public pure override returns (string memory) { + return "UnstLPT"; + } +} diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol new file mode 100644 index 0000000..9e2be4a --- /dev/null +++ b/src/composites/ctToken.sol @@ -0,0 +1,272 @@ +// 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 { ERC20 } from "solady/tokens/ERC20.sol"; + +import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; + +import { AVLTree } from "core/composites/AVLTree.sol"; + +import { UnstakeNFT } from "core/composites/UnstakeNFT.sol"; + +// Deposits: do we stake directly or delay it to a crank bot ? +// Add fees + +bytes32 constant MINTER_ROLE = keccak256("MINTER"); +bytes32 constant UPGRADE_ROLE = keccak256("UPGRADE"); +bytes32 constant GOVERNANCE_ROLE = keccak256("GOVERNANCE"); + +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 +} + +contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradeable, Multicallable, SelfPermit { + using SafeTransferLib for address; + using FixedPointMathLib for uint256; + using AVLTree for AVLTree.Tree; + + error DepositTooSmall(); + error BalanceNotZero(); + error UnstakeSlippage(); + + // Events + event ValidatorAdded(uint256 indexed id, address tToken, uint256 weight); + 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 + } + + // State variables + address public token; // Underlying asset (e.g. ETH) + UnstakeNFT public unstakeNFT; + uint256 public totalAssets; + uint256 private lastUnstakeID; + // Exchange rate state + uint256 public exchangeRate = FixedPointMathLib.WAD; // Stored as fixed point (1e18) + + mapping(uint24 id => StakingPool) public stakingPools; + AVLTree.Tree private stakingPoolTree; + mapping(uint256 unstakeID => UnstakeRequest) public unstakeRequests; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function name() public pure override returns (string memory) { + return "Composite Token"; + } + + function symbol() public pure override returns (string memory) { + return "ctToken"; + } + + function initialize(address _token, UnstakeNFT _unstakeNFT) external initializer { + __AccessControl_init(); + _grantRole(UPGRADE_ROLE, msg.sender); + _grantRole(GOVERNANCE_ROLE, msg.sender); + + _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); + + // Set initial state + token = _token; + unstakeNFT = _unstakeNFT; + } + + // 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 = 1; + (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, count); + + // Distribute deposit + // TODO: should we distribute the deposit here directly + // Or have a public function that does this callable by anyone + // We would the e.g. call this once per day/epoch/whatever + // This could significantly reduce the gas cost of deposit. + uint256 received; + for (uint24 i = 0; i < count; i++) { + StakingPool storage pool = stakingPools[validatorIDs[i]]; + Tenderizer tenderizer = Tenderizer(pool.tToken); + uint256 tTokens = tenderizer.deposit(address(this), assets); + 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 += assets; + // Calculate shares based on current exchange rate + shares = received.divWad(exchangeRate); + if (shares == 0) revert DepositTooSmall(); + // Mint shares to receiver + _mint(receiver, shares); + } + + // TODO: make non-reentrant + // TODO: Improve strategy of how much to draw from each validator + function unstake(uint256 shares, uint256 minAmount) external returns (uint256 unstakeID) { + // Calculate amount of tokens that need to be unstaked + uint256 amount = shares.mulWad(exchangeRate); + if (amount < minAmount) revert UnstakeSlippage(); + + // Create unstake NFT (needed to claim withdrawal) + unstakeID = unstakeNFT.mintNFT(msg.sender); + + // Find least divergent validator(s) + (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(true, 1); + + // Update state + totalAssets -= amount; + // Burn shares + _burn(msg.sender, shares); + + UnstakeRequest memory request; + request.amount = amount; + request.createdAt = uint64(block.timestamp); + // Unstake from validator(s) + for (uint24 i = 0; i < validatorIDs.length; i++) { + StakingPool storage pool = stakingPools[validatorIDs[i]]; + Tenderizer tenderizer = Tenderizer(pool.tToken); + // TODO: check if amount is sufficient + uint256 id = tenderizer.unlock(amount); + request.tTokens[i] = pool.tToken; + request.unlockIDs[i] = id; + pool.balance -= amount; + } + + // unstakeRequests[unstakeID] = UnstakeRequest(amount, block.timestamp, tTokens, unlockIDs); + } + + // 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; + for (uint256 i = 0; i < l; i++) { + amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); + } + delete unstakeRequests[unstakeID]; + } + + // TODO: needed for flash unstake + // Unwrap this cToken (burn) into tToken counterparts and transfer them + // to the caller + // We can upgrade tenderswap to support this too with backwards compatibility + // Where if the asset is a cToken it will call unwrap on it + // then the tenderswap pool will multicall itself to transfer the tTokens + // or get the quote for the multiple tTokens in series. + function unwrap() external { } + + // TODO: Allow governance to execute a series of transaction to rebalance + // The contract. This could be e.g. staking, unstaking or withdraw operations, or even a tenderswap call. + function rebalance() external onlyRole(GOVERNANCE_ROLE) { } + + 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) { + totalAssets += newBalance - currentBalance; + } 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 + // 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)); + } + + // 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]; + } + + 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; + } + + // Override required by UUPSUpgradeable + function _authorizeUpgrade(address) internal override onlyRole(UPGRADE_ROLE) { } +} diff --git a/src/factory/Factory.sol b/src/factory/Factory.sol index bf6885d..f2fd085 100644 --- a/src/factory/Factory.sol +++ b/src/factory/Factory.sol @@ -22,7 +22,6 @@ import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; * @author Tenderize Labs Ltd * @notice Factory for Tenderizer contracts */ - contract Factory { using ClonesWithImmutableArgs for address; 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/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)); From 21ba2e41bb50caab91f384003b63184b2c48edfc Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Wed, 26 Feb 2025 21:04:56 +0100 Subject: [PATCH 04/14] wip deposit --- src/composites/ctToken.sol | 112 +++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 30 deletions(-) diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol index 9e2be4a..221d245 100644 --- a/src/composites/ctToken.sol +++ b/src/composites/ctToken.sol @@ -65,9 +65,10 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea uint256 balance; // Current balance of tTokens } + UnstakeNFT immutable unstakeNFT; + address immutable token; // Underlying asset (e.g. ETH) // State variables - address public token; // Underlying asset (e.g. ETH) - UnstakeNFT public unstakeNFT; + uint256 public totalAssets; uint256 private lastUnstakeID; // Exchange rate state @@ -78,8 +79,11 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea mapping(uint256 unstakeID => UnstakeRequest) public unstakeRequests; /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { + constructor(address _token, UnstakeNFT _unstakeNFT) { _disableInitializers(); + // Set initial state + token = _token; + unstakeNFT = _unstakeNFT; } function name() public pure override returns (string memory) { @@ -90,7 +94,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea return "ctToken"; } - function initialize(address _token, UnstakeNFT _unstakeNFT) external initializer { + function initialize() external initializer { __AccessControl_init(); _grantRole(UPGRADE_ROLE, msg.sender); _grantRole(GOVERNANCE_ROLE, msg.sender); @@ -100,10 +104,6 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // 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); - - // Set initial state - token = _token; - unstakeNFT = _unstakeNFT; } // Core functions for deposits @@ -112,30 +112,82 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea token.safeTransferFrom(msg.sender, address(this), assets); // Stake assets - uint24 count = 1; - (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, count); - - // Distribute deposit - // TODO: should we distribute the deposit here directly - // Or have a public function that does this callable by anyone - // We would the e.g. call this once per day/epoch/whatever - // This could significantly reduce the gas cost of deposit. + uint24 count = 3; + + (, uint24 positiveNodes, uint24 negativeNodes,, int200 negDivergence) = stakingPoolTree.getTreeStats(); + + uint256 negDiv_ = uint256(int256(-(negDivergence))); + uint256 received; - for (uint24 i = 0; i < count; i++) { - StakingPool storage pool = stakingPools[validatorIDs[i]]; - Tenderizer tenderizer = Tenderizer(pool.tToken); - uint256 tTokens = tenderizer.deposit(address(this), assets); - 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)); + 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 = assets * uint256(int256(int200(int256(items[i].target - items[i].balance)) / totalDivergence)); + 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[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++) { + uint256 amount = depositAmounts[i] + + assets * uint256(int256(int200(int256(items[i].target - items[i].balance)) / totalDivergence)); + 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); } - stakingPoolTree.updateDivergence(validatorIDs[i], d); } totalAssets += assets; From 2e19a989b1884ee138f42eefc2d65702ae4a523f Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 28 Feb 2025 12:35:58 +0100 Subject: [PATCH 05/14] wip withdrawal functions --- src/composites/AVLTree.Sol | 8 +++ src/composites/ctToken.sol | 110 +++++++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 23 deletions(-) diff --git a/src/composites/AVLTree.Sol b/src/composites/AVLTree.Sol index ff6a8bf..1eabae7 100644 --- a/src/composites/AVLTree.Sol +++ b/src/composites/AVLTree.Sol @@ -558,6 +558,14 @@ library AVLTree { 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. diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol index 221d245..36a3441 100644 --- a/src/composites/ctToken.sol +++ b/src/composites/ctToken.sol @@ -198,39 +198,60 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea _mint(receiver, shares); } - // TODO: make non-reentrant + // TODO: make non-reentrant (should be done by burning shares first) // TODO: Improve strategy of how much to draw from each validator 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(); - // Create unstake NFT (needed to claim withdrawal) - unstakeID = unstakeNFT.mintNFT(msg.sender); - - // Find least divergent validator(s) - (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(true, 1); - - // Update state - totalAssets -= amount; - // Burn shares + // Burn shares to prevent re-entrancy (after calculating amount !!) _burn(msg.sender, shares); - UnstakeRequest memory request; - request.amount = amount; - request.createdAt = uint64(block.timestamp); - // Unstake from validator(s) - for (uint24 i = 0; i < validatorIDs.length; i++) { - StakingPool storage pool = stakingPools[validatorIDs[i]]; - Tenderizer tenderizer = Tenderizer(pool.tToken); - // TODO: check if amount is sufficient - uint256 id = tenderizer.unlock(amount); + // Get the withdrawal ratio (wr * WAD) + uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up + // Get average stake of the validators + uint256 k = stakingPoolTree.getSize(); + uint256 avgStake = totalAssets.divWad(k); + uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down + // before + + // Start looping the tree from top to bottom + address[] memory tTokens; + uint256[] memory unlockIDs; + UnstakeRequest memory request = + UnstakeRequest({ amount: amount, createdAt: uint64(block.timestamp), tTokens: tTokens, unlockIDs: unlockIDs }); + uint256 remaining = amount; + for (uint256 i = 0; i < k; i++) { + uint24 id = stakingPoolTree.getFirst(); + StakingPool storage pool = stakingPools[id]; + uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + uint256 draw = max < remaining ? max : remaining; request.tTokens[i] = pool.tToken; - request.unlockIDs[i] = id; - pool.balance -= amount; + pool.balance -= draw; + 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); + request.unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); + if (draw == remaining) { + break; + } + remaining -= draw; } - // unstakeRequests[unstakeID] = UnstakeRequest(amount, block.timestamp, tTokens, unlockIDs); + // Create unstake NFT (needed to claim withdrawal) + unstakeID = unstakeNFT.mintNFT(msg.sender); + unstakeRequests[unstakeID] = request; + + // Update state + totalAssets -= amount; } // TODO: make non-reentrant @@ -240,6 +261,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea 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++) { amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); } @@ -253,7 +275,49 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // Where if the asset is a cToken it will call unwrap on it // then the tenderswap pool will multicall itself to transfer the tTokens // or get the quote for the multiple tTokens in series. - function unwrap() external { } + 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); + + // Get the withdrawal ratio (wr * WAD) + uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up + // Get average stake of the validators + uint256 k = stakingPoolTree.getSize(); + uint256 avgStake = totalAssets.divWad(k); + uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down + // before + + // Start looping the tree from top to bottom + uint256 remaining = amount; + for (uint256 i = 0; i < k; i++) { + uint24 id = stakingPoolTree.getFirst(); + StakingPool storage pool = stakingPools[id]; + uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + uint256 draw = max < remaining ? max : remaining; + tTokens[i] = pool.tToken; + amounts[i] = draw; + pool.balance -= draw; + 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; + } + + // Update state + totalAssets -= amount; + } // TODO: Allow governance to execute a series of transaction to rebalance // The contract. This could be e.g. staking, unstaking or withdraw operations, or even a tenderswap call. From ea5887f0344db2e275299ff385de6c84e585cb28 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 28 Feb 2025 12:38:43 +0100 Subject: [PATCH 06/14] small math fix --- src/composites/ctToken.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol index 36a3441..5dbbad0 100644 --- a/src/composites/ctToken.sol +++ b/src/composites/ctToken.sol @@ -215,7 +215,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up // Get average stake of the validators uint256 k = stakingPoolTree.getSize(); - uint256 avgStake = totalAssets.divWad(k); + uint256 avgStake = totalAssets / k; uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down // before @@ -287,7 +287,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up // Get average stake of the validators uint256 k = stakingPoolTree.getSize(); - uint256 avgStake = totalAssets.divWad(k); + uint256 avgStake = totalAssets / k; uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down // before From dd680ba98efe97b6ced518bd5e148b2684860e51 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 28 Feb 2025 16:26:51 +0100 Subject: [PATCH 07/14] fix remaining todos --- src/composites/ctToken.sol | 73 ++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol index 5dbbad0..db7fd97 100644 --- a/src/composites/ctToken.sol +++ b/src/composites/ctToken.sol @@ -28,6 +28,7 @@ import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; import { AVLTree } from "core/composites/AVLTree.sol"; import { UnstakeNFT } from "core/composites/UnstakeNFT.sol"; +import { Registry } from "core/registry/Registry.sol"; // Deposits: do we stake directly or delay it to a crank bot ? // Add fees @@ -36,6 +37,9 @@ 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 @@ -51,9 +55,15 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea error DepositTooSmall(); error BalanceNotZero(); error UnstakeSlippage(); + error RebalanceFailed(address target, bytes data, uint256 value); + error InvalidTenderizer(address tToken); // Events - event ValidatorAdded(uint256 indexed id, address tToken, uint256 weight); + 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); @@ -65,33 +75,36 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea uint256 balance; // Current balance of tTokens } + // === IMMUTABLES === UnstakeNFT immutable unstakeNFT; address immutable token; // Underlying asset (e.g. ETH) - // State variables + Registry immutable registry; + // === GLOBAL STATE === + uint256 public fee; // Stored as fixed point (1e18) uint256 public totalAssets; uint256 private lastUnstakeID; - // Exchange rate state uint256 public exchangeRate = FixedPointMathLib.WAD; // Stored as fixed point (1e18) mapping(uint24 id => StakingPool) public stakingPools; - AVLTree.Tree private stakingPoolTree; mapping(uint256 unstakeID => UnstakeRequest) public unstakeRequests; + AVLTree.Tree private stakingPoolTree; /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _token, UnstakeNFT _unstakeNFT) { + constructor(address _token, UnstakeNFT _unstakeNFT, Registry _registry) { _disableInitializers(); // Set initial state token = _token; unstakeNFT = _unstakeNFT; + registry = _registry; } - function name() public pure override returns (string memory) { - return "Composite Token"; + function name() public view override returns (string memory) { + return string.concat("Steaked ", ERC20(token).name()); } - function symbol() public pure override returns (string memory) { - return "ctToken"; + function symbol() public view override returns (string memory) { + return string.concat("st", ERC20(token).symbol()); } function initialize() external initializer { @@ -153,7 +166,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); uint256[] memory depositAmounts = new uint256[](maxCount); for (uint24 i = 0; i < maxCount; i++) { - StakingPool storage pool = stakingPools[i]; + StakingPool storage pool = stakingPools[validatorIDs[i]]; depositAmounts[i] = pool.target - pool.balance; } @@ -196,10 +209,11 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea if (shares == 0) revert DepositTooSmall(); // Mint shares to receiver _mint(receiver, shares); + + emit Deposit(msg.sender, assets, shares); } - // TODO: make non-reentrant (should be done by burning shares first) - // TODO: Improve strategy of how much to draw from each validator + // 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; @@ -252,6 +266,8 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // Update state totalAssets -= amount; + + emit Unstake(msg.sender, unstakeID, shares, amount); } // TODO: make non-reentrant @@ -266,6 +282,8 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); } delete unstakeRequests[unstakeID]; + + emit Withdraw(msg.sender, unstakeID, amountReceived); } // TODO: needed for flash unstake @@ -317,11 +335,26 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // Update state totalAssets -= amount; + + emit Unwrap(msg.sender, shares, amount); } // TODO: Allow governance to execute a series of transaction to rebalance // The contract. This could be e.g. staking, unstaking or withdraw operations, or even a tenderswap call. - function rebalance() external onlyRole(GOVERNANCE_ROLE) { } + function rebalance( + address[] calldata targets, + bytes[] calldata datas, + uint256[] calldata values + ) + external + onlyRole(GOVERNANCE_ROLE) + { + uint256 l = targets.length; + for (uint256 i = 0; i < l; i++) { + (bool success,) = targets[i].call{ value: values[i] }(datas[i]); + if (!success) revert RebalanceFailed(targets[i], datas[i], values[i]); + } + } function claimValidatorRewards(uint24 id) external { // Update the balance of the validator @@ -330,7 +363,9 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea uint256 newBalance = tenderizer.balanceOf(address(this)); uint256 currentBalance = pool.balance; if (newBalance > currentBalance) { - totalAssets += newBalance - currentBalance; + uint256 fees = newBalance - currentBalance * fee / FEE_WAD; + totalAssets += newBalance - currentBalance - fees; + _mint(registry.treasury(), fees); } else { totalAssets -= currentBalance - newBalance; } @@ -352,12 +387,15 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // 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); } // This only updates the divergence of the current validator. Depending on the weighting used the divergences @@ -368,6 +406,8 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea 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) { @@ -383,6 +423,11 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea 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) { } } From 0dd4e733c1989c73e122b1fafe928791204a9d9a Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 28 Feb 2025 16:27:24 +0100 Subject: [PATCH 08/14] fees 0 check --- src/composites/ctToken.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composites/ctToken.sol b/src/composites/ctToken.sol index db7fd97..d2cc81e 100644 --- a/src/composites/ctToken.sol +++ b/src/composites/ctToken.sol @@ -365,7 +365,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea if (newBalance > currentBalance) { uint256 fees = newBalance - currentBalance * fee / FEE_WAD; totalAssets += newBalance - currentBalance - fees; - _mint(registry.treasury(), fees); + if (fees > 0) _mint(registry.treasury(), fees); } else { totalAssets -= currentBalance - newBalance; } From b51180df2d198e7bf41b370b831ff3377b63064f Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Mon, 10 Mar 2025 16:20:55 +0100 Subject: [PATCH 09/14] initial tests --- .../AVLTree.Sol | 76 +++-- src/multi-validator/Factory.sol | 50 +++ .../MultiValidatorLST.sol} | 164 ++++++---- .../UnstakeNFT.sol | 51 +-- test/multi-validator/MultiValidatorLST.t.sol | 299 ++++++++++++++++++ 5 files changed, 519 insertions(+), 121 deletions(-) rename src/{composites => multi-validator}/AVLTree.Sol (93%) create mode 100644 src/multi-validator/Factory.sol rename src/{composites/ctToken.sol => multi-validator/MultiValidatorLST.sol} (79%) rename src/{composites => multi-validator}/UnstakeNFT.sol (75%) create mode 100644 test/multi-validator/MultiValidatorLST.t.sol diff --git a/src/composites/AVLTree.Sol b/src/multi-validator/AVLTree.Sol similarity index 93% rename from src/composites/AVLTree.Sol rename to src/multi-validator/AVLTree.Sol index 1eabae7..8621ccd 100644 --- a/src/composites/AVLTree.Sol +++ b/src/multi-validator/AVLTree.Sol @@ -7,8 +7,9 @@ // | | __/ | | | (_| | __/ | | |/ / __/ // \_/\___|_| |_|\__,_|\___|_| |_/___\___| // -pragma solidity >=0.8.19; +pragma solidity >=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`. @@ -70,7 +71,7 @@ library AVLTree { if (hasNode(tree, id)) revert NodeAlreadyExists(); // Update tree stats - tree.size++; + tree.size = tree.size + 1; if (divergence > 0) { tree.positiveNodes++; tree.posDivergence += divergence; @@ -82,14 +83,14 @@ library AVLTree { // 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; - } +// 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; @@ -101,30 +102,35 @@ library AVLTree { /// @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; - } +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); - if (tree.first == nodeId && newNode.divergence < current.divergence) { - tree.first = newId; - } - } else { - current.right = _insertRecursive(tree, current.right, newId, newNode); - if (tree.last == nodeId && newNode.divergence >= current.divergence) { - tree.last = 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); } + 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. @@ -201,6 +207,8 @@ library AVLTree { 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 @@ -314,7 +322,7 @@ library AVLTree { uint8 found = 0; uint24 current = positive ? tree.last : tree.first; - while (current != 0 && found < count) { + while (found < count) { Node memory node = tree.nodes[current]; if ((positive && node.divergence <= 0) || (!positive && node.divergence >= 0)) break; @@ -322,7 +330,7 @@ library AVLTree { divergences[found] = node.divergence; found++; - current = positive ? _findPredecessor(tree, current) : _findSuccessor(tree, current); + current = positive ? findPredecessor(tree, current) : findSuccessor(tree, current); } return (ids, divergences); @@ -332,7 +340,7 @@ library AVLTree { /// @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) internal view returns (uint24) { + 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. @@ -364,7 +372,7 @@ library AVLTree { /// @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) internal view returns (uint24) { + 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. diff --git a/src/multi-validator/Factory.sol b/src/multi-validator/Factory.sol new file mode 100644 index 0000000..e8f15ff --- /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; + address private immutable treasury; + + constructor(address _treasury) { + _disableInitializers(); + initialImpl = address(new MultiValidatorLST{ salt: bytes32("MultiValidatorLST") }(registry)); + initialUnstakeNFTImpl = address(new UnstakeNFT{ salt: bytes32("UnstakeNFT") }()); + treasury = _treasury; + } + + function initialize() external initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + } + + function deploy(address token) external onlyOwner returns (address) { + string memory symbol = ERC20(token).symbol(); + address stProxy = address(new ERC1967Proxy{ salt: bytes(string.concat("MultiValidator", symbol))[0] }(initialImpl, "")); + + address unstProxy = address( + new ERC1967Proxy{ salt: bytes(string.concat("UnstakeNFT", symbol))[0] }( + initialUnstakeNFTImpl, abi.encodeCall(UnstakeNFT.initialize, (token, stProxy)) + ) + ); + + MultiValidatorLST(stProxy).initialize(token, UnstakeNFT(unstProxy), treasury); + + UnstakeNFT(unstProxy).transferOwnership(owner()); + + 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/composites/ctToken.sol b/src/multi-validator/MultiValidatorLST.sol similarity index 79% rename from src/composites/ctToken.sol rename to src/multi-validator/MultiValidatorLST.sol index d2cc81e..71a1247 100644 --- a/src/composites/ctToken.sol +++ b/src/multi-validator/MultiValidatorLST.sol @@ -20,38 +20,46 @@ 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/composites/AVLTree.sol"; +import { AVLTree } from "core/multi-validator/AVLTree.sol"; -import { UnstakeNFT } from "core/composites/UnstakeNFT.sol"; +import { UnstakeNFT } from "core/multi-validator/UnstakeNFT.sol"; import { Registry } from "core/registry/Registry.sol"; -// Deposits: do we stake directly or delay it to a crank bot ? -// Add fees - -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 -} - -contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradeable, Multicallable, SelfPermit { +import { console2 } from "forge-std/Test.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(); @@ -76,47 +84,53 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea } // === IMMUTABLES === - UnstakeNFT immutable unstakeNFT; - address immutable token; // Underlying asset (e.g. ETH) Registry immutable registry; // === GLOBAL STATE === + address 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) public unstakeRequests; - AVLTree.Tree private stakingPoolTree; + mapping(uint256 unstakeID => UnstakeRequest) private unstakeRequests; + AVLTree.Tree public stakingPoolTree; /// @custom:oz-upgrades-unsafe-allow constructor - constructor(address _token, UnstakeNFT _unstakeNFT, Registry _registry) { + constructor(Registry _registry) { _disableInitializers(); // Set initial state - token = _token; - unstakeNFT = _unstakeNFT; registry = _registry; } function name() public view override returns (string memory) { - return string.concat("Steaked ", ERC20(token).name()); + return string.concat("Steaked ", ERC20(token).symbol()); } function symbol() public view override returns (string memory) { return string.concat("st", ERC20(token).symbol()); } - function initialize() external initializer { + 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, msg.sender); - _grantRole(GOVERNANCE_ROLE, msg.sender); + _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 @@ -138,7 +152,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea StakingPool[] memory items = new StakingPool[](maxCount); (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); - + console2.log("validatorIDs %s %s %s", validatorIDs[0], validatorIDs[1], validatorIDs[2]); for (uint24 i = 0; i < maxCount; i++) { StakingPool storage pool = stakingPools[validatorIDs[i]]; items[i] = StakingPool(pool.tToken, pool.target, pool.balance); @@ -146,8 +160,10 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea } for (uint24 i = 0; i < maxCount; i++) { - uint256 amount = assets * uint256(int256(int200(int256(items[i].target - items[i].balance)) / totalDivergence)); + 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); + console2.log("tTokens received %s", tTokens); StakingPool storage pool = stakingPools[validatorIDs[i]]; pool.balance += tTokens; received += tTokens; @@ -185,9 +201,11 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea } for (uint24 i = 0; i < maxCount; i++) { - uint256 amount = depositAmounts[i] - + assets * uint256(int256(int200(int256(items[i].target - items[i].balance)) / totalDivergence)); - uint256 tTokens = Tenderizer(items[i].tToken).deposit(address(this), amount); + 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; @@ -203,13 +221,17 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea } } - totalAssets += assets; + totalAssets += received; // Calculate shares based on current exchange rate + console2.log("received %s", received); + console2.log("exchangeRate %s", exchangeRate); shares = received.divWad(exchangeRate); if (shares == 0) revert DepositTooSmall(); // Mint shares to receiver _mint(receiver, shares); + console2.log("deposit first", stakingPoolTree.getFirst()); + console2.log("deposit last", stakingPoolTree.getLast()); emit Deposit(msg.sender, assets, shares); } @@ -225,27 +247,32 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // Burn shares to prevent re-entrancy (after calculating amount !!) _burn(msg.sender, shares); - // Get the withdrawal ratio (wr * WAD) - uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up - // Get average stake of the validators uint256 k = stakingPoolTree.getSize(); - uint256 avgStake = totalAssets / k; - uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down - // before - + 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 - address[] memory tTokens; - uint256[] memory unlockIDs; + uint256 remaining = amount; + uint24 id = stakingPoolTree.getLast(); + UnstakeRequest memory request = UnstakeRequest({ amount: amount, createdAt: uint64(block.timestamp), tTokens: tTokens, unlockIDs: unlockIDs }); - uint256 remaining = amount; + for (uint256 i = 0; i < k; i++) { - uint24 id = stakingPoolTree.getFirst(); StakingPool storage pool = stakingPools[id]; - uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + if (maxDrawdown >= pool.balance) { + id = stakingPoolTree.findPredecessor(id); + continue; + } + uint256 max = pool.balance - maxDrawdown; // Edge case with rounding uint256 draw = max < remaining ? max : remaining; - request.tTokens[i] = pool.tToken; + tTokens[i] = pool.tToken; pool.balance -= draw; + request.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)); @@ -253,11 +280,11 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea d = int200(uint200(pool.balance - pool.target)); } stakingPoolTree.updateDivergence(id, d); - request.unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); if (draw == remaining) { break; } remaining -= draw; + id = nextId; } // Create unstake NFT (needed to claim withdrawal) @@ -286,13 +313,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea emit Withdraw(msg.sender, unstakeID, amountReceived); } - // TODO: needed for flash unstake - // Unwrap this cToken (burn) into tToken counterparts and transfer them - // to the caller - // We can upgrade tenderswap to support this too with backwards compatibility - // Where if the asset is a cToken it will call unwrap on it - // then the tenderswap pool will multicall itself to transfer the tTokens - // or get the quote for the multiple tTokens in series. + // 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); @@ -301,24 +322,28 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea // Burn shares to prevent re-entrancy (after calculating amount !!) _burn(msg.sender, shares); - // Get the withdrawal ratio (wr * WAD) - uint256 wr = amount.divWad(totalAssets); // round down so max_drawdown is rounded up - // Get average stake of the validators uint256 k = stakingPoolTree.getSize(); - uint256 avgStake = totalAssets / k; - uint256 maxDrawdown = avgStake.mulWad(FixedPointMathLib.WAD - wr); // We need this to be rounded up by rounding 'wr' down - // before - + 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(); for (uint256 i = 0; i < k; i++) { - uint24 id = stakingPoolTree.getFirst(); StakingPool storage pool = stakingPools[id]; - uint256 max = maxDrawdown > pool.balance ? pool.balance : pool.balance - maxDrawdown; // Edge case with rounding + 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; amounts[i] = draw; 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)); @@ -331,6 +356,7 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea break; } remaining -= draw; + id = nextId; } // Update state @@ -398,6 +424,10 @@ contract ctToken is ERC20, Initializable, AccessControlUpgradeable, UUPSUpgradea 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. diff --git a/src/composites/UnstakeNFT.sol b/src/multi-validator/UnstakeNFT.sol similarity index 75% rename from src/composites/UnstakeNFT.sol rename to src/multi-validator/UnstakeNFT.sol index 8d0b9b6..c367874 100644 --- a/src/composites/UnstakeNFT.sol +++ b/src/multi-validator/UnstakeNFT.sol @@ -21,19 +21,20 @@ import { Strings } from "openzeppelin-contracts/utils/Strings.sol"; import { Base64 } from "core/unlocks/Base64.sol"; -import { UnstakeRequest } from "core/composites/ctToken.sol"; +import { MultiValidatorLST } from "core/multi-validator/MultiValidatorLST.sol"; pragma solidity >=0.8.19; interface GetUnstakeRequest { - function getUnstakeRequest(uint256 id) external view returns (UnstakeRequest memory); + function getUnstakeRequest(uint256 id) external view returns (MultiValidatorLST.UnstakeRequest memory); } -abstract contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC721 { +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; @@ -44,16 +45,33 @@ abstract contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeab _disableInitializers(); } - function initialize() external initializer { + 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 (UnstakeRequest memory) { + function getRequest(uint256 id) public view returns (MultiValidatorLST.UnstakeRequest memory) { return GetUnstakeRequest(minter).getUnstakeRequest(id); } - function mintNFT(address to) external returns (uint256 unstakeID) { + function mintNFT(address to) external onlyMinter returns (uint256 unstakeID) { unstakeID = ++lastID; _safeMint(to, unstakeID); } @@ -80,7 +98,7 @@ abstract contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeab * @notice Returns the JSON metadata for a given unlock * @param data metadata for the token */ - function json(UnstakeRequest memory data) public view returns (string memory) { + function json(MultiValidatorLST.UnstakeRequest memory data) public view returns (string memory) { return string( abi.encodePacked( "data:application/json;base64,", @@ -93,7 +111,7 @@ abstract contract UnstakeNFT is Initializable, UUPSUpgradeable, OwnableUpgradeab ); } - function svg(UnstakeRequest memory data) external pure returns (string memory) { + function svg(MultiValidatorLST.UnstakeRequest memory data) external pure returns (string memory) { return string( abi.encodePacked( '=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 } 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 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; // Example address, replace with real one +address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; // Example address, replace with real one +address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; // Example address, replace with real one + +address constant alice = address(0x5678); +address constant bob = address(0x9ABC); +address constant registry = 0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE; + +// Livepeer specific +address constant minter = 0xc20DE37170B45774e6CD3d2304017fc962f27252; // LPT Minter address + +contract MultiValidatorLSTTest is Test, ERC721Receiver { + 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"), 313_514_289); + // 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(address(this))); + 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 + 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(), 3, "Validator count should be 3"); + } + + 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); + + 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); + + assertEq(req.amount, expectedAmount, "Unstake request amount should match expected amount"); + } +} From 4b8e01006fc78cc558181cb007962e9b37644a5d Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 28 Mar 2025 10:23:21 +0100 Subject: [PATCH 10/14] testnet script --- script/MultiValidatorLST.s.sol | 66 ++++++++++++++++++++ script/multi-validator-testnet.sh | 23 +++++++ src/multi-validator/MultiValidatorLST.sol | 11 ++-- test/multi-validator/MultiValidatorLST.t.sol | 37 +++++++++-- 4 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 script/MultiValidatorLST.s.sol create mode 100755 script/multi-validator-testnet.sh diff --git a/script/MultiValidatorLST.s.sol b/script/MultiValidatorLST.s.sol new file mode 100644 index 0000000..6b2408b --- /dev/null +++ b/script/MultiValidatorLST.s.sol @@ -0,0 +1,66 @@ +// 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 { 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 TENDERIZER_1 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; +address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; +address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; + +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 me = vm.addr(privKey); + + console2.log("Deploying MultiValidatorFactory..."); + address factoryImpl = address(new MultiValidatorFactory(me)); + 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)); + + lst.setFee(0.05e6); // 5% fee + + lst.addValidator(payable(TENDERIZER_1), 2_000_000 ether); // 3M Stake + lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake + lst.addValidator(payable(TENDERIZER_3), 2_000_000 ether); // 1M Stake + vm.stopBroadcast(); + } +} diff --git a/script/multi-validator-testnet.sh b/script/multi-validator-testnet.sh new file mode 100755 index 0000000..211dde7 --- /dev/null +++ b/script/multi-validator-testnet.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -x +pkill -9 anvil +nohup bash -c "anvil --fork-url https://arb-mainnet.alchemyapi.io/v2/ISHp9nyZwKlfoSfS3-Hv-05CRiklcRBt --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 + +read -r -d '' _ = pool.balance) { @@ -266,9 +263,10 @@ contract MultiValidatorLST is } uint256 max = pool.balance - maxDrawdown; // Edge case with rounding uint256 draw = max < remaining ? max : remaining; + console2.log("Drawing from validator %s pool %s", id, pool.tToken); tTokens[i] = pool.tToken; pool.balance -= draw; - request.unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); + unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); // Get next id before updating uint24 nextId = stakingPoolTree.findPredecessor(id); @@ -289,7 +287,8 @@ contract MultiValidatorLST is // Create unstake NFT (needed to claim withdrawal) unstakeID = unstakeNFT.mintNFT(msg.sender); - unstakeRequests[unstakeID] = request; + unstakeRequests[unstakeID] = + UnstakeRequest({ amount: amount, createdAt: uint64(block.timestamp), tTokens: tTokens, unlockIDs: unlockIDs }); // Update state totalAssets -= amount; @@ -306,6 +305,8 @@ contract MultiValidatorLST is 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; + console2.log("withdrawing tToken %s", request.tTokens[i]); amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); } delete unstakeRequests[unstakeID]; diff --git a/test/multi-validator/MultiValidatorLST.t.sol b/test/multi-validator/MultiValidatorLST.t.sol index 4fd99dc..8d6fd9d 100644 --- a/test/multi-validator/MultiValidatorLST.t.sol +++ b/test/multi-validator/MultiValidatorLST.t.sol @@ -24,16 +24,16 @@ import { Tenderizer } from "core/tenderizer/Tenderizer.sol"; import { ERC721Receiver } from "core/utils/ERC721Receiver.sol"; -import { LPT } from "core/adapters/LivepeerAdapter.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 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; // Example address, replace with real one -address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; // Example address, replace with real one -address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; // Example address, replace with real one +address constant TENDERIZER_1 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; +address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; +address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; address constant alice = address(0x5678); address constant bob = address(0x9ABC); @@ -42,7 +42,22 @@ 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; @@ -286,6 +301,8 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { 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); @@ -294,6 +311,18 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { 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"); } } From ba9059fc47bec3c0cf4ba91a708995613d8d21cb Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Wed, 9 Apr 2025 12:47:35 +0200 Subject: [PATCH 11/14] flash unstake --- script/MultiValidatorLST.s.sol | 5 ++ src/multi-validator/FlashUnstake.sol | 97 +++++++++++++++++++++++ src/multi-validator/MultiValidatorLST.sol | 30 ++++++- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/multi-validator/FlashUnstake.sol diff --git a/script/MultiValidatorLST.s.sol b/script/MultiValidatorLST.s.sol index 6b2408b..1830429 100644 --- a/script/MultiValidatorLST.s.sol +++ b/script/MultiValidatorLST.s.sol @@ -17,6 +17,7 @@ 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"; @@ -56,6 +57,10 @@ contract MultiValidatorLST_Deploy is Script { 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 lst.addValidator(payable(TENDERIZER_1), 2_000_000 ether); // 3M Stake diff --git a/src/multi-validator/FlashUnstake.sol b/src/multi-validator/FlashUnstake.sol new file mode 100644 index 0000000..2b16d4a --- /dev/null +++ b/src/multi-validator/FlashUnstake.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +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 * amount.mulWad(lst.exchangeRate())); + + uint256 l = tTokens.length; + + if (l == 0) revert(); + + if (l == 1) { + tTokens[0].safeApprove(tenderSwap, amounts[0]); + (out, fees) = TenderSwap(tenderSwap).swap(tTokens[0], amounts[0], minOut); + } else { + for (uint256 i = 0; i < l; ++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 index 6dfdcbb..b477c2f 100644 --- a/src/multi-validator/MultiValidatorLST.sol +++ b/src/multi-validator/MultiValidatorLST.sol @@ -87,7 +87,7 @@ contract MultiValidatorLST is Registry immutable registry; // === GLOBAL STATE === - address token; // Underlying asset (e.g. ETH) + address public token; // Underlying asset (e.g. ETH) UnstakeNFT unstakeNFT; uint256 public fee; // Stored as fixed point (1e18) uint256 public totalAssets; @@ -366,6 +366,34 @@ contract MultiValidatorLST is emit Unwrap(msg.sender, shares, amount); } + function previewUnwrap(uint256 shares) external view returns (address[] memory tTokens, uint256[] memory amounts) { + 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; + + 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 < amount ? max : amount; + tTokens[i] = pool.tToken; + amounts[i] = draw; + if (draw == remaining) { + break; + } + remaining -= draw; + } + } + // TODO: Allow governance to execute a series of transaction to rebalance // The contract. This could be e.g. staking, unstaking or withdraw operations, or even a tenderswap call. function rebalance( From 155183ef10cdd45161a76bb42d3660bba5e95d75 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 11 Apr 2025 11:13:58 +0200 Subject: [PATCH 12/14] flash unstake --- foundry.toml | 4 +- script/MultiValidatorLST.s.sol | 10 ++-- script/Run.s.sol | 62 +++++++++++++++++++++++ script/multi-validator-testnet.sh | 5 +- src/multi-validator/FlashUnstake.sol | 13 +++-- src/multi-validator/MultiValidatorLST.sol | 15 ++---- 6 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 script/Run.s.sol 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/script/MultiValidatorLST.s.sol b/script/MultiValidatorLST.s.sol index 1830429..f5c33e2 100644 --- a/script/MultiValidatorLST.s.sol +++ b/script/MultiValidatorLST.s.sol @@ -24,9 +24,9 @@ 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 TENDERIZER_1 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; -address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; -address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; +address constant TENDERIZER_1 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; +address constant TENDERIZER_2 = 0x4b0e5E54Df6d5eCcC7B2F838982411DC93253dAf; +address constant TENDERIZER_3 = 0x218337076c79A6D94EB3B557f2c89dDd82E883A0; address constant LIVEPEER_MINTER = 0xc20DE37170B45774e6CD3d2304017fc962f27252; @@ -64,8 +64,8 @@ contract MultiValidatorLST_Deploy is Script { lst.setFee(0.05e6); // 5% fee lst.addValidator(payable(TENDERIZER_1), 2_000_000 ether); // 3M Stake - lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake - lst.addValidator(payable(TENDERIZER_3), 2_000_000 ether); // 1M Stake + // lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake + // lst.addValidator(payable(TENDERIZER_3), 2_000_000 ether); // 1M Stake vm.stopBroadcast(); } } diff --git a/script/Run.s.sol b/script/Run.s.sol new file mode 100644 index 0000000..ca93e1d --- /dev/null +++ b/script/Run.s.sol @@ -0,0 +1,62 @@ +// 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 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; +address constant TENDERIZER_2 = 0x4b0e5E54Df6d5eCcC7B2F838982411DC93253dAf; +address constant TENDERIZER_3 = 0x218337076c79A6D94EB3B557f2c89dDd82E883A0; + +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 = 0xfdc1E7Ec8dBab6D05f2655E0409a79550eCb01aE; + LPT.approve(lst, type(uint256).max); + MultiValidatorLST(lst).deposit(msg.sender, 10 ether); + + uint256 bal = MultiValidatorLST(lst).balanceOf(msg.sender); + + MultiValidatorLST(lst).approve(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95, type(uint256).max); + (uint256 out, uint256 fee) = FlashUnstake(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95).flashUnstakeQuote( + lst, 0x686962481543d543934903C3FE8bDe8c5dB9Bd97, 1 ether + ); + console2.log("Quote out: %s", out); + console2.log("fee: %s", fee); + + (out, fee) = FlashUnstake(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95).flashUnstake( + lst, 0x686962481543d543934903C3FE8bDe8c5dB9Bd97, 1 ether, out - 1 + ); + console2.log("Successfully flash unstaked"); + vm.stopBroadcast(); + } +} diff --git a/script/multi-validator-testnet.sh b/script/multi-validator-testnet.sh index 211dde7..7e90dbb 100755 --- a/script/multi-validator-testnet.sh +++ b/script/multi-validator-testnet.sh @@ -1,7 +1,7 @@ #!/bin/bash set -x pkill -9 anvil -nohup bash -c "anvil --fork-url https://arb-mainnet.alchemyapi.io/v2/ISHp9nyZwKlfoSfS3-Hv-05CRiklcRBt --chain-id 5000 --block-base-fee-per-gas 0 &" >/dev/null 2>&1 && sleep 5 +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 @@ -18,6 +18,9 @@ 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; +pragma solidity >=0.8.20; import { Multicallable } from "solady/utils/Multicallable.sol"; import { SelfPermit } from "core/utils/SelfPermit.sol"; @@ -57,17 +57,21 @@ contract FlashUnstake is ERC721Receiver, Multicallable, SelfPermit { { token.safeTransferFrom(msg.sender, address(this), amount); MultiValidatorLST lst = MultiValidatorLST(token); - (address[] memory tTokens, uint256[] memory amounts) = lst.unwrap(amount, amount * amount.mulWad(lst.exchangeRate())); + (address[] memory tTokens, uint256[] memory amounts) = lst.unwrap(amount, amount.mulWad(lst.exchangeRate())); uint256 l = tTokens.length; if (l == 0) revert(); if (l == 1) { - tTokens[0].safeApprove(tenderSwap, amounts[0]); - (out, fees) = TenderSwap(tenderSwap).swap(tTokens[0], amounts[0], minOut); + 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); @@ -87,6 +91,7 @@ contract FlashUnstake is ERC721Receiver, Multicallable, SelfPermit { { (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]); diff --git a/src/multi-validator/MultiValidatorLST.sol b/src/multi-validator/MultiValidatorLST.sol index b477c2f..8d1dc70 100644 --- a/src/multi-validator/MultiValidatorLST.sol +++ b/src/multi-validator/MultiValidatorLST.sol @@ -31,8 +31,6 @@ import { AVLTree } from "core/multi-validator/AVLTree.sol"; import { UnstakeNFT } from "core/multi-validator/UnstakeNFT.sol"; import { Registry } from "core/registry/Registry.sol"; -import { console2 } from "forge-std/Test.sol"; - contract MultiValidatorLST is ERC20, ERC721Receiver, @@ -152,7 +150,6 @@ contract MultiValidatorLST is StakingPool[] memory items = new StakingPool[](maxCount); (uint24[] memory validatorIDs,) = stakingPoolTree.findMostDivergent(false, maxCount); - console2.log("validatorIDs %s %s %s", validatorIDs[0], validatorIDs[1], validatorIDs[2]); for (uint24 i = 0; i < maxCount; i++) { StakingPool storage pool = stakingPools[validatorIDs[i]]; items[i] = StakingPool(pool.tToken, pool.target, pool.balance); @@ -163,7 +160,6 @@ contract MultiValidatorLST is 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); - console2.log("tTokens received %s", tTokens); StakingPool storage pool = stakingPools[validatorIDs[i]]; pool.balance += tTokens; received += tTokens; @@ -223,15 +219,11 @@ contract MultiValidatorLST is totalAssets += received; // Calculate shares based on current exchange rate - console2.log("received %s", received); - console2.log("exchangeRate %s", exchangeRate); shares = received.divWad(exchangeRate); if (shares == 0) revert DepositTooSmall(); // Mint shares to receiver _mint(receiver, shares); - console2.log("deposit first", stakingPoolTree.getFirst()); - console2.log("deposit last", stakingPoolTree.getLast()); emit Deposit(msg.sender, assets, shares); } @@ -263,7 +255,6 @@ contract MultiValidatorLST is } uint256 max = pool.balance - maxDrawdown; // Edge case with rounding uint256 draw = max < remaining ? max : remaining; - console2.log("Drawing from validator %s pool %s", id, pool.tToken); tTokens[i] = pool.tToken; pool.balance -= draw; unlockIDs[i] = Tenderizer(pool.tToken).unlock(draw); @@ -306,7 +297,6 @@ contract MultiValidatorLST is // 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; - console2.log("withdrawing tToken %s", request.tTokens[i]); amountReceived += Tenderizer(payable(request.tTokens[i])).withdraw(msg.sender, request.unlockIDs[i]); } delete unstakeRequests[unstakeID]; @@ -367,6 +357,7 @@ contract MultiValidatorLST is } 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(); @@ -382,15 +373,15 @@ contract MultiValidatorLST is id = stakingPoolTree.findPredecessor(id); continue; } - uint256 max = pool.balance - maxDrawdown; // Edge case with rounding - uint256 draw = max < amount ? max : amount; + uint256 draw = max < remaining ? max : remaining; tTokens[i] = pool.tToken; amounts[i] = draw; if (draw == remaining) { break; } remaining -= draw; + id = stakingPoolTree.findPredecessor(id); } } From 31bc0b5fab3cfd6f9f440c203780e78dec15d8ec Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Wed, 16 Apr 2025 11:24:12 +0200 Subject: [PATCH 13/14] deploy time --- script/MultiValidatorLST.s.sol | 27 +++++++--- script/Run.s.sol | 15 +++--- src/multi-validator/MultiValidatorLST.sol | 52 +++++++++++++++++--- test/multi-validator/MultiValidatorLST.t.sol | 31 ++++++++++-- 4 files changed, 99 insertions(+), 26 deletions(-) diff --git a/script/MultiValidatorLST.s.sol b/script/MultiValidatorLST.s.sol index f5c33e2..a7d32f5 100644 --- a/script/MultiValidatorLST.s.sol +++ b/script/MultiValidatorLST.s.sol @@ -24,15 +24,27 @@ 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 TENDERIZER_1 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; -address constant TENDERIZER_2 = 0x4b0e5E54Df6d5eCcC7B2F838982411DC93253dAf; -address constant TENDERIZER_3 = 0x218337076c79A6D94EB3B557f2c89dDd82E883A0; - 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; @@ -63,9 +75,10 @@ contract MultiValidatorLST_Deploy is Script { lst.setFee(0.05e6); // 5% fee - lst.addValidator(payable(TENDERIZER_1), 2_000_000 ether); // 3M Stake - // lst.addValidator(payable(TENDERIZER_2), 2_000_000 ether); // 2M Stake - // lst.addValidator(payable(TENDERIZER_3), 2_000_000 ether); // 1M Stake + 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/Run.s.sol b/script/Run.s.sol index ca93e1d..bf2df3f 100644 --- a/script/Run.s.sol +++ b/script/Run.s.sol @@ -25,10 +25,9 @@ import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy. import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol"; import { FlashUnstake } from "core/multi-validator/FlashUnstake.sol"; -address constant TENDERIZER_1 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; -address constant TENDERIZER_2 = 0x4b0e5E54Df6d5eCcC7B2F838982411DC93253dAf; -address constant TENDERIZER_3 = 0x218337076c79A6D94EB3B557f2c89dDd82E883A0; - +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 { @@ -40,20 +39,20 @@ contract MultiValidatorLST_Deploy is Script { function run() public { uint256 privKey = vm.envUint("PRIVATE_KEY"); vm.startBroadcast(privKey); - address lst = 0xfdc1E7Ec8dBab6D05f2655E0409a79550eCb01aE; + 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(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95, type(uint256).max); - (uint256 out, uint256 fee) = FlashUnstake(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95).flashUnstakeQuote( + 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(0x0Dbce9D1E875772cf370f14f10Cd22f71B6B6F95).flashUnstake( + (out, fee) = FlashUnstake(0x59b86cf4d8B566602a687Bd9A2979792e73316d9).flashUnstake( lst, 0x686962481543d543934903C3FE8bDe8c5dB9Bd97, 1 ether, out - 1 ); console2.log("Successfully flash unstaked"); diff --git a/src/multi-validator/MultiValidatorLST.sol b/src/multi-validator/MultiValidatorLST.sol index 8d1dc70..a52bc8a 100644 --- a/src/multi-validator/MultiValidatorLST.sol +++ b/src/multi-validator/MultiValidatorLST.sol @@ -320,16 +320,27 @@ contract MultiValidatorLST is // 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[i] = pool.tToken; - amounts[i] = draw; + tTokens[index] = pool.tToken; + amounts[index] = draw; + index++; pool.balance -= draw; // Get next id before updating @@ -347,9 +358,20 @@ contract MultiValidatorLST is 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; @@ -366,23 +388,41 @@ contract MultiValidatorLST is 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[i] = pool.tToken; - amounts[i] = draw; - if (draw == remaining) { + tTokens[index] = pool.tToken; + amounts[index] = draw; + index++; + remaining -= draw; + if (remaining == 0) { break; } - remaining -= draw; id = stakingPoolTree.findPredecessor(id); } + + // End truncate unused elements + if (tTokens.length > 0) { + assembly { + mstore(tTokens, index) + mstore(amounts, index) + } + } } // TODO: Allow governance to execute a series of transaction to rebalance diff --git a/test/multi-validator/MultiValidatorLST.t.sol b/test/multi-validator/MultiValidatorLST.t.sol index 8d6fd9d..8d10ce7 100644 --- a/test/multi-validator/MultiValidatorLST.t.sol +++ b/test/multi-validator/MultiValidatorLST.t.sol @@ -31,9 +31,19 @@ import { ERC1967Proxy } from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy. import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol"; // Existing LPT Tenderizer addresses on Arbitrum -address constant TENDERIZER_1 = 0x6CBC6967A941CCa12c1316E4D567c6892C3F0Ed6; -address constant TENDERIZER_2 = 0xFCfeD578958D42Cd1c2ea09db09bfC1A668E0efd; -address constant TENDERIZER_3 = 0x3A760477cA7CB37Dec4DF9B9e19ce15CB265bfF8; +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); @@ -74,7 +84,7 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { } function setUp() public { - vm.createSelectFork(vm.envString("ARBITRUM_RPC"), 313_514_289); + 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"); @@ -95,6 +105,17 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { 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(); } @@ -118,7 +139,7 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { 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(), 3, "Validator count should be 3"); + assertEq(lst.validatorCount(), 13, "Validator count should be 13"); } function test_deposit() public { From 848761722a8fbf65aae130042fc3fb8d95281bb9 Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Thu, 22 May 2025 13:01:27 +0200 Subject: [PATCH 14/14] multivalidator lst tests and launch --- .../MultiValidatorFactory.deploy.s.sol | 44 +++ .../MultiValidatorLST.deploy.s.sol | 31 ++ .../MultiValidatorLST.s.sol | 2 +- .../multi-validator-testnet.sh | 0 src/multi-validator/Factory.sol | 18 +- src/multi-validator/MultiValidatorLST.sol | 17 - .../MultiValidatorLST_GRT.t.sol | 352 ++++++++++++++++++ ...rLST.t.sol => MultiValidatorLST_LPT.t.sol} | 2 +- 8 files changed, 438 insertions(+), 28 deletions(-) create mode 100644 script/multi-validator/MultiValidatorFactory.deploy.s.sol create mode 100644 script/multi-validator/MultiValidatorLST.deploy.s.sol rename script/{ => multi-validator}/MultiValidatorLST.s.sol (99%) rename script/{ => multi-validator}/multi-validator-testnet.sh (100%) create mode 100644 test/multi-validator/MultiValidatorLST_GRT.t.sol rename test/multi-validator/{MultiValidatorLST.t.sol => MultiValidatorLST_LPT.t.sol} (99%) 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/MultiValidatorLST.s.sol b/script/multi-validator/MultiValidatorLST.s.sol similarity index 99% rename from script/MultiValidatorLST.s.sol rename to script/multi-validator/MultiValidatorLST.s.sol index a7d32f5..847655e 100644 --- a/script/MultiValidatorLST.s.sol +++ b/script/multi-validator/MultiValidatorLST.s.sol @@ -55,7 +55,7 @@ contract MultiValidatorLST_Deploy is Script { address me = vm.addr(privKey); console2.log("Deploying MultiValidatorFactory..."); - address factoryImpl = address(new MultiValidatorFactory(me)); + address factoryImpl = address(new MultiValidatorFactory()); factory = MultiValidatorFactory(address(new ERC1967Proxy{ salt: bytes32("MultiValidatorLSTFactory") }(address(factoryImpl), ""))); factory.initialize(); diff --git a/script/multi-validator-testnet.sh b/script/multi-validator/multi-validator-testnet.sh similarity index 100% rename from script/multi-validator-testnet.sh rename to script/multi-validator/multi-validator-testnet.sh diff --git a/src/multi-validator/Factory.sol b/src/multi-validator/Factory.sol index e8f15ff..b271a7a 100644 --- a/src/multi-validator/Factory.sol +++ b/src/multi-validator/Factory.sol @@ -13,33 +13,33 @@ contract MultiValidatorFactory is Initializable, UUPSUpgradeable, OwnableUpgrade Registry constant registry = Registry(0xa7cA8732Be369CaEaE8C230537Fc8EF82a3387EE); address private immutable initialImpl; address private immutable initialUnstakeNFTImpl; - address private immutable treasury; - constructor(address _treasury) { + constructor() { _disableInitializers(); - initialImpl = address(new MultiValidatorLST{ salt: bytes32("MultiValidatorLST") }(registry)); - initialUnstakeNFTImpl = address(new UnstakeNFT{ salt: bytes32("UnstakeNFT") }()); - treasury = _treasury; + 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: bytes(string.concat("MultiValidator", symbol))[0] }(initialImpl, "")); + address stProxy = + address(new ERC1967Proxy{ salt: keccak256(bytes(string.concat("MultiValidator", symbol))) }(initialImpl, "")); address unstProxy = address( - new ERC1967Proxy{ salt: bytes(string.concat("UnstakeNFT", symbol))[0] }( + new ERC1967Proxy{ salt: keccak256(bytes(string.concat("UnstakeNFT", symbol))) }( initialUnstakeNFTImpl, abi.encodeCall(UnstakeNFT.initialize, (token, stProxy)) ) ); - MultiValidatorLST(stProxy).initialize(token, UnstakeNFT(unstProxy), treasury); + MultiValidatorLST(stProxy).initialize(token, UnstakeNFT(unstProxy), registry.treasury()); - UnstakeNFT(unstProxy).transferOwnership(owner()); + UnstakeNFT(unstProxy).transferOwnership(registry.treasury()); return stProxy; } diff --git a/src/multi-validator/MultiValidatorLST.sol b/src/multi-validator/MultiValidatorLST.sol index a52bc8a..4224c9d 100644 --- a/src/multi-validator/MultiValidatorLST.sol +++ b/src/multi-validator/MultiValidatorLST.sol @@ -425,23 +425,6 @@ contract MultiValidatorLST is } } - // TODO: Allow governance to execute a series of transaction to rebalance - // The contract. This could be e.g. staking, unstaking or withdraw operations, or even a tenderswap call. - function rebalance( - address[] calldata targets, - bytes[] calldata datas, - uint256[] calldata values - ) - external - onlyRole(GOVERNANCE_ROLE) - { - uint256 l = targets.length; - for (uint256 i = 0; i < l; i++) { - (bool success,) = targets[i].call{ value: values[i] }(datas[i]); - if (!success) revert RebalanceFailed(targets[i], datas[i], values[i]); - } - } - function claimValidatorRewards(uint24 id) external { // Update the balance of the validator StakingPool storage pool = stakingPools[id]; 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.t.sol b/test/multi-validator/MultiValidatorLST_LPT.t.sol similarity index 99% rename from test/multi-validator/MultiValidatorLST.t.sol rename to test/multi-validator/MultiValidatorLST_LPT.t.sol index 8d10ce7..a465ac6 100644 --- a/test/multi-validator/MultiValidatorLST.t.sol +++ b/test/multi-validator/MultiValidatorLST_LPT.t.sol @@ -93,7 +93,7 @@ contract MultiValidatorLSTTest is Test, ERC721Receiver { vm.label(alice, "Alice"); vm.label(bob, "Bob"); - address factoryImpl = address(new MultiValidatorFactory(address(this))); + address factoryImpl = address(new MultiValidatorFactory()); factory = MultiValidatorFactory(address(new ERC1967Proxy{ salt: bytes32("MultiValidatorLSTFactory") }(address(factoryImpl), ""))); factory.initialize();