From 7282a82929da7d34b2dd4040062c5774f918130f Mon Sep 17 00:00:00 2001 From: kyriediculous Date: Fri, 31 May 2024 00:58:50 +0200 Subject: [PATCH] feat: prototype --- .gitmodules | 15 + foundry.toml | 70 +-- lib/openzeppelin-contracts | 1 + lib/openzeppelin-contracts-upgradeable | 1 + lib/prb-math | 1 + lib/solady | 1 + lib/tenderswap | 1 + remappings.txt | 11 +- script/Base.s.sol | 41 -- script/Deploy.s.sol | 13 - script/add_liq.s.sol | 22 + script/check.s.sol | 39 ++ script/deploy.local.s.sol | 83 +++ script/deploy.local.sh | 17 + script/send.local.sh | 26 + src/Foo.sol | 8 - src/LPETH.sol | 697 +++++++++++++++++++++++ src/LPToken.sol | 45 ++ src/Registry.sol | 26 + src/UnsETHQueue.sol | 127 +++++ src/WithdrawQueue.sol | 96 ++++ src/adapters/Adapter.sol | 32 ++ src/adapters/ETHx/ETHxAdapter.sol | 43 ++ src/adapters/ETHx/IStader.sol | 21 + src/adapters/eETH/EETHAdapter.sol | 46 ++ src/adapters/eETH/IEtherfi.sol | 12 + src/adapters/lsETH/ILiquidCollective.sol | 34 ++ src/adapters/lsETH/LsETHAdapter.sol | 47 ++ src/adapters/mETH/IMantle.sol | 13 + src/adapters/mETH/METHAdapter.sol | 48 ++ src/adapters/stETH/ILido.sol | 36 ++ src/adapters/stETH/StETHAdapter.sol | 52 ++ src/adapters/swETH/ISwell.sol | 35 ++ src/adapters/swETH/SwETHAdapter.sol | 43 ++ src/unsETH/Base64.sol | 143 +++++ src/unsETH/Renderer.sol | 98 ++++ src/unsETH/UnsETH.sol | 145 +++++ src/utils/ERC721Receiver.sol | 11 + src/utils/SelfPermit.sol | 85 +++ test/Foo.t.sol | 56 -- test/LPToken.t.sol | 52 ++ test/Registry.t.sol | 50 ++ test/UnsETH.t.sol | 127 +++++ test/adapters/EETHAdapter.t.sol | 37 ++ test/adapters/ETHxAdapter.t.sol | 37 ++ test/adapters/METHAdapter.t.sol | 36 ++ test/adapters/StETHAdapter.t.sol | 37 ++ test/adapters/SwETHAdapter.t.sol | 37 ++ test/helpers/MockERC20.sol | 15 + test/lpETH/FeeGauge.t.sol | 43 ++ 50 files changed, 2650 insertions(+), 162 deletions(-) create mode 100644 .gitmodules create mode 160000 lib/openzeppelin-contracts create mode 160000 lib/openzeppelin-contracts-upgradeable create mode 160000 lib/prb-math create mode 160000 lib/solady create mode 160000 lib/tenderswap delete mode 100644 script/Base.s.sol delete mode 100644 script/Deploy.s.sol create mode 100644 script/add_liq.s.sol create mode 100644 script/check.s.sol create mode 100644 script/deploy.local.s.sol create mode 100644 script/deploy.local.sh create mode 100644 script/send.local.sh delete mode 100644 src/Foo.sol create mode 100644 src/LPETH.sol create mode 100644 src/LPToken.sol create mode 100644 src/Registry.sol create mode 100644 src/UnsETHQueue.sol create mode 100644 src/WithdrawQueue.sol create mode 100644 src/adapters/Adapter.sol create mode 100644 src/adapters/ETHx/ETHxAdapter.sol create mode 100644 src/adapters/ETHx/IStader.sol create mode 100644 src/adapters/eETH/EETHAdapter.sol create mode 100644 src/adapters/eETH/IEtherfi.sol create mode 100644 src/adapters/lsETH/ILiquidCollective.sol create mode 100644 src/adapters/lsETH/LsETHAdapter.sol create mode 100644 src/adapters/mETH/IMantle.sol create mode 100644 src/adapters/mETH/METHAdapter.sol create mode 100644 src/adapters/stETH/ILido.sol create mode 100644 src/adapters/stETH/StETHAdapter.sol create mode 100644 src/adapters/swETH/ISwell.sol create mode 100644 src/adapters/swETH/SwETHAdapter.sol create mode 100644 src/unsETH/Base64.sol create mode 100644 src/unsETH/Renderer.sol create mode 100644 src/unsETH/UnsETH.sol create mode 100644 src/utils/ERC721Receiver.sol create mode 100644 src/utils/SelfPermit.sol delete mode 100644 test/Foo.t.sol create mode 100644 test/LPToken.t.sol create mode 100644 test/Registry.t.sol create mode 100644 test/UnsETH.t.sol create mode 100644 test/adapters/EETHAdapter.t.sol create mode 100644 test/adapters/ETHxAdapter.t.sol create mode 100644 test/adapters/METHAdapter.t.sol create mode 100644 test/adapters/StETHAdapter.t.sol create mode 100644 test/adapters/SwETHAdapter.t.sol create mode 100644 test/helpers/MockERC20.sol create mode 100644 test/lpETH/FeeGauge.t.sol diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3d4212f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/Vectorized/solady +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/prb-math"] + path = lib/prb-math + url = https://github.com/PaulRBerg/prb-math +[submodule "lib/tenderswap"] + path = lib/tenderswap + url = https://github.com/Tenderize/tenderswap +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/openzeppelin/openzeppelin-contracts diff --git a/foundry.toml b/foundry.toml index 65bd3b2..136026f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,53 +1,39 @@ # Full reference https://github.com/foundry-rs/foundry/tree/master/crates/config [profile.default] - auto_detect_solc = false - block_timestamp = 1_680_220_800 # March 31, 2023 at 00:00 GMT - bytecode_hash = "none" - evm_version = "paris" # See https://www.evmdiff.com/features?name=PUSH0&kind=opcode - fuzz = { runs = 1_000 } - gas_reports = ["*"] - optimizer = true - optimizer_runs = 10_000 - out = "out" - script = "script" - solc = "0.8.25" - src = "src" - test = "test" +auto_detect_solc = false +block_timestamp = 1_680_220_800 # March 31, 2023 at 00:00 GMT +bytecode_hash = "none" +evm_version = "paris" # See https://www.evmdiff.com/features?name=PUSH0&kind=opcode +fuzz = { runs = 1_000 } +gas_reports = ["*"] +optimizer = true +optimizer_runs = 10_000 +out = "out" +script = "script" +solc = "0.8.25" +src = "src" +test = "test" [profile.ci] - fuzz = { runs = 10_000 } - verbosity = 4 +fuzz = { runs = 10_000 } +verbosity = 4 [etherscan] - arbitrum = { key = "${API_KEY_ARBISCAN}" } - avalanche = { key = "${API_KEY_SNOWTRACE}" } - bnb_smart_chain = { key = "${API_KEY_BSCSCAN}" } - gnosis_chain = { key = "${API_KEY_GNOSISSCAN}" } - goerli = { key = "${API_KEY_ETHERSCAN}" } - mainnet = { key = "${API_KEY_ETHERSCAN}" } - optimism = { key = "${API_KEY_OPTIMISTIC_ETHERSCAN}" } - polygon = { key = "${API_KEY_POLYGONSCAN}" } - sepolia = { key = "${API_KEY_ETHERSCAN}" } +mainnet = { key = "${API_KEY_ETHERSCAN}" } +arbitrum = { key = "${API_KEY_ARBISCAN}" } [fmt] - bracket_spacing = true - int_types = "long" - line_length = 120 - multiline_func_header = "all" - number_underscore = "thousands" - quote_style = "double" - tab_width = 4 - wrap_comments = true +bracket_spacing = true +int_types = "long" +line_length = 120 +multiline_func_header = "all" +number_underscore = "thousands" +quote_style = "double" +tab_width = 4 +wrap_comments = true [rpc_endpoints] - arbitrum = "https://arbitrum-mainnet.infura.io/v3/${API_KEY_INFURA}" - avalanche = "https://avalanche-mainnet.infura.io/v3/${API_KEY_INFURA}" - bnb_smart_chain = "https://bsc-dataseed.binance.org" - gnosis_chain = "https://rpc.gnosischain.com" - goerli = "https://goerli.infura.io/v3/${API_KEY_INFURA}" - localhost = "http://localhost:8545" - mainnet = "https://eth-mainnet.g.alchemy.com/v2/${API_KEY_ALCHEMY}" - optimism = "https://optimism-mainnet.infura.io/v3/${API_KEY_INFURA}" - polygon = "https://polygon-mainnet.infura.io/v3/${API_KEY_INFURA}" - sepolia = "https://sepolia.infura.io/v3/${API_KEY_INFURA}" +mainnet = "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" +arbitrum = "https://arb-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" +sepolia = "https://sepolia-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..dbb6104 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..723f8ca --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 diff --git a/lib/prb-math b/lib/prb-math new file mode 160000 index 0000000..9dc0651 --- /dev/null +++ b/lib/prb-math @@ -0,0 +1 @@ +Subproject commit 9dc06519f3b9f1659fec7d396da634fe690f660c diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..3f239d3 --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit 3f239d3596f9123f5b667c90e396469e2d605115 diff --git a/lib/tenderswap b/lib/tenderswap new file mode 160000 index 0000000..8b1e34b --- /dev/null +++ b/lib/tenderswap @@ -0,0 +1 @@ +Subproject commit 8b1e34b9dcf85f6473f6073ae8a1efba0d8a100b diff --git a/remappings.txt b/remappings.txt index 550f908..b1bf218 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,9 @@ -@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/ -forge-std/=node_modules/forge-std/ +forge-std/=node_modules/forge-std/src +@=src/ +@test=test/ +@openzeppelin/upgradeable=lib/openzeppelin-contracts-upgradeable/contracts +@openzeppelin/contracts=lib/openzeppelin-contracts/contracts +@solady/=lib/solady/src/ +@prb/math/=lib/prb-math/src/ +@tenderswap=lib/tenderswap/src +@solmate=lib/solmate/src diff --git a/script/Base.s.sol b/script/Base.s.sol deleted file mode 100644 index 07135a2..0000000 --- a/script/Base.s.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity >=0.8.25 <0.9.0; - -import { Script } from "forge-std/src/Script.sol"; - -abstract contract BaseScript is Script { - /// @dev Included to enable compilation of the script without a $MNEMONIC environment variable. - string internal constant TEST_MNEMONIC = "test test test test test test test test test test test junk"; - - /// @dev Needed for the deterministic deployments. - bytes32 internal constant ZERO_SALT = bytes32(0); - - /// @dev The address of the transaction broadcaster. - address internal broadcaster; - - /// @dev Used to derive the broadcaster's address if $ETH_FROM is not defined. - string internal mnemonic; - - /// @dev Initializes the transaction broadcaster like this: - /// - /// - If $ETH_FROM is defined, use it. - /// - Otherwise, derive the broadcaster address from $MNEMONIC. - /// - If $MNEMONIC is not defined, default to a test mnemonic. - /// - /// The use case for $ETH_FROM is to specify the broadcaster key and its address via the command line. - constructor() { - address from = vm.envOr({ name: "ETH_FROM", defaultValue: address(0) }); - if (from != address(0)) { - broadcaster = from; - } else { - mnemonic = vm.envOr({ name: "MNEMONIC", defaultValue: TEST_MNEMONIC }); - (broadcaster,) = deriveRememberKey({ mnemonic: mnemonic, index: 0 }); - } - } - - modifier broadcast() { - vm.startBroadcast(broadcaster); - _; - vm.stopBroadcast(); - } -} diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol deleted file mode 100644 index 498db52..0000000 --- a/script/Deploy.s.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25 <0.9.0; - -import { Foo } from "../src/Foo.sol"; - -import { BaseScript } from "./Base.s.sol"; - -/// @dev See the Solidity Scripting tutorial: https://book.getfoundry.sh/tutorials/solidity-scripting -contract Deploy is BaseScript { - function run() public broadcast returns (Foo foo) { - foo = new Foo(); - } -} diff --git a/script/add_liq.s.sol b/script/add_liq.s.sol new file mode 100644 index 0000000..1e9e019 --- /dev/null +++ b/script/add_liq.s.sol @@ -0,0 +1,22 @@ +pragma solidity >=0.8.20; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Registry } from "@/Registry.sol"; +import { UnsETH } from "@/unsETH/UnsETH.sol"; +import { Renderer } from "@/unsETH/Renderer.sol"; +import { LPETH } from "@/LPETH.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; + +contract DeployLocal is Script { + bytes32 salt = bytes32(uint256(1)); + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + address swap = 0xB5A53938316E4a02c0d91F1b454E43583429e347; + + LPETH(payable(swap)).deposit{ value: 5000 ether }(0); + vm.stopBroadcast(); + } +} diff --git a/script/check.s.sol b/script/check.s.sol new file mode 100644 index 0000000..3a558a0 --- /dev/null +++ b/script/check.s.sol @@ -0,0 +1,39 @@ +pragma solidity >=0.8.20; + +import { Script, console2 } from "forge-std/Script.sol"; +import { UnsETH } from "@/unsETH/UnsETH.sol"; +import { Renderer } from "@/unsETH/Renderer.sol"; +import { LPETH } from "@/LPETH.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; + +// Adapters +import { Adapter } from "@/adapters/Adapter.sol"; +import { EETHAdapter, EETH_TOKEN } from "@/adapters/eETH/EETHAdapter.sol"; +import { ETHxAdapter, ETHx_TOKEN } from "@/adapters/ETHx/ETHxAdapter.sol"; +import { METHAdapter, METH_TOKEN } from "@/adapters/mETH/METHAdapter.sol"; +import { StETHAdapter, STETH_TOKEN } from "@/adapters/stETH/StETHAdapter.sol"; +import { SwETHAdapter, SWETH_TOKEN } from "@/adapters/swETH/SwETHAdapter.sol"; + +// Token holders, to get some funds +import { EETH_HOLDER } from "@test/adapters/EETHAdapter.t.sol"; +import { ETHx_HOLDER } from "@test/adapters/ETHxAdapter.t.sol"; +import { METH_HOLDER } from "@test/adapters/METHAdapter.t.sol"; +import { STETH_HOLDER } from "@test/adapters/StETHAdapter.t.sol"; +import { SWETH_HOLDER } from "@test/adapters/SwETHAdapter.t.sol"; + +contract DeployLocal is Script { + bytes32 salt = bytes32(uint256(1)); + + function run() public { + address me = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + + console2.log("balance", ERC20(EETH_TOKEN).balanceOf(me)); + + console2.log("balance", ERC20(EETH_TOKEN).balanceOf(me)); + + console2.log("balance", ERC20(EETH_TOKEN).balanceOf(me)); + + console2.log("balance", ERC20(EETH_TOKEN).balanceOf(me)); + } +} diff --git a/script/deploy.local.s.sol b/script/deploy.local.s.sol new file mode 100644 index 0000000..dea6141 --- /dev/null +++ b/script/deploy.local.s.sol @@ -0,0 +1,83 @@ +// pragma solidity >=0.8.20; + +// import { Script, console2 } from "forge-std/Script.sol"; +// import { Registry } from "@/Registry.sol"; +// import { UnsETH } from "@/unsETH/UnsETH.sol"; +// import { Renderer } from "@/unsETH/Renderer.sol"; +// import { LPETH, ConstructorConfig } from "@/LPETH.sol"; +// import { LPToken } from "@/LPToken.sol"; +// import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +// import { ERC20 } from "solady/tokens/ERC20.sol"; + +// // Adapters +// import { Adapter } from "@/adapters/Adapter.sol"; +// import { EETHAdapter, EETH_TOKEN } from "@/adapters/eETH/EETHAdapter.sol"; +// import { ETHxAdapter, ETHx_TOKEN } from "@/adapters/ETHx/ETHxAdapter.sol"; +// import { METHAdapter, METH_TOKEN } from "@/adapters/mETH/METHAdapter.sol"; +// import { StETHAdapter, STETH_TOKEN } from "@/adapters/stETH/StETHAdapter.sol"; +// import { SwETHAdapter, SWETH_TOKEN } from "@/adapters/swETH/SwETHAdapter.sol"; + +// // Token holders, to get some funds +// import { EETH_HOLDER } from "@test/adapters/EETHAdapter.t.sol"; +// import { ETHx_HOLDER } from "@test/adapters/ETHxAdapter.t.sol"; +// import { METH_HOLDER } from "@test/adapters/METHAdapter.t.sol"; +// import { STETH_HOLDER } from "@test/adapters/StETHAdapter.t.sol"; +// import { SWETH_HOLDER } from "@test/adapters/SwETHAdapter.t.sol"; + +// contract DeployLocal is Script { +// bytes32 salt = bytes32(uint256(1)); + +// function run() public { +// uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); +// vm.startBroadcast(deployerPrivateKey); + +// LPToken lpToken = new LPToken(); + +// address registry_impl = address(new Registry{ salt: salt }()); +// Registry registryProxy = Registry(address(new ERC1967Proxy{ salt: salt }(address(registry_impl), ""))); +// registryProxy.initialize(); +// console2.log("Registry Implementation: %s", registry_impl); +// console2.log("Registry Proxy: %s", address(registryProxy)); + +// address renderer = address(new Renderer()); +// address unsETH_impl = address(new UnsETH{ salt: salt }(address(registryProxy), renderer)); +// UnsETH unsETHProxy = UnsETH(payable(address(new ERC1967Proxy{ salt: salt }(unsETH_impl, "")))); +// unsETHProxy.initialize(); +// console2.log("UnsETH Implementation: %s", unsETH_impl); +// console2.log("UnsETH Proxy: %s", address(unsETHProxy)); + +// ConstructorConfig memory config = ConstructorConfig({ +// registry: registryProxy, +// lpToken: lpToken, +// treasury: address(0), +// unsETH: address(unsETHProxy), +// withdrawQueue: address(0) +// }); + +// address lpETH_impl = address(new LPETH{ salt: salt }()); +// LPETH lpETHProxy = LPETH(payable(address(new ERC1967Proxy{ salt: salt }(lpETH_impl, "")))); +// lpETHProxy.initialize(); +// console2.log("LPETH Implementation: %s", lpETH_impl); +// console2.log("LPETH Proxy: %s", address(lpETHProxy)); +// console2.log("LP Token: %s", address(lpETHProxy.lpToken())); + +// // Register and deploy adapters, send some funds +// Adapter eETHAdapter = new EETHAdapter(); +// registryProxy.setAdapter(EETH_TOKEN, eETHAdapter); +// console2.log("EETH Adapter: %s", address(eETHAdapter)); + +// Adapter ethxAdapter = new ETHxAdapter(); +// registryProxy.setAdapter(ETHx_TOKEN, ethxAdapter); +// console2.log("ETHx Adapter: %s", address(ethxAdapter)); + +// Adapter methAdapter = new METHAdapter(); +// registryProxy.setAdapter(METH_TOKEN, methAdapter); +// console2.log("METH Adapter: %s", address(methAdapter)); + +// Adapter stETHAdapter = new StETHAdapter(); +// registryProxy.setAdapter(STETH_TOKEN, stETHAdapter); +// console2.log("StETH Adapter: %s", address(stETHAdapter)); + +// vm.stopBroadcast(); +// } +// } diff --git a/script/deploy.local.sh b/script/deploy.local.sh new file mode 100644 index 0000000..adc304d --- /dev/null +++ b/script/deploy.local.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -x +source .env + +nohup bash -c "anvil --fork-url ${MAINNET_RPC} --fork-block-number 19847895 --chain-id 1337 &" >/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/deploy.local.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --private-key $PRIVATE_KEY -vvvv +forge script script/add_liq.s.sol --rpc-url http://127.0.0.1:8545 --broadcast --private-key $PRIVATE_KEY +read -r -d '' _ =0.8.25; - -contract Foo { - function id(uint256 value) external pure returns (uint256) { - return value; - } -} diff --git a/src/LPETH.sol b/src/LPETH.sol new file mode 100644 index 0000000..1e3858b --- /dev/null +++ b/src/LPETH.sol @@ -0,0 +1,697 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.20; + +import { Registry } from "@/Registry.sol"; +import { LPToken } from "@/LPToken.sol"; +import { UnsETH, Metadata } from "@/unsETH/UnsETH.sol"; +import { UnsETHQueue } from "@/UnsETHQueue.sol"; +import { Adapter } from "@/adapters/Adapter.sol"; +import { WithdrawQueue } from "@/WithdrawQueue.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.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 "@/utils/SelfPermit.sol"; + +import { UD60x18, ud, UNIT as UNIT_60x18, ZERO as ZERO_60x18 } from "@prb/math/ud60x18.sol"; + +import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +// Time for which unsETH can be bought from the pool +// Since at the moment it won't be possible to determine +// The maturity of certain unsETH tokens, we will set this to 3.5 days. +// This value should be lower than the common advertised unstaking time of supported protocols. +// ALTERNATIVELY: in the future we could use an oracle that determines the current withdrawal queue length +// which should account for both partial and full withdrawals, but not for any potential instant liquid funds +// some protocols might keep on hand. This is why "buyUnlock" should also always check if an unlock has been finalized. +uint256 constant UNSETH_EXPIRATION_TIME = 3 days + 12 hours; +UD60x18 constant BASE_FEE = UD60x18.wrap(0.0005e18); +UD60x18 constant K = UD60x18.wrap(4.5e18); +UD60x18 constant RELAYER_CUT = UD60x18.wrap(0.025e18); +UD60x18 constant TREASURY_CUT = UD60x18.wrap(0.2e18); +UD60x18 constant MIN_LP_CUT = UD60x18.wrap(0.2e18); + +struct ConstructorConfig { + Registry registry; + LPToken lpToken; + address treasury; + address unsETH; + address withdrawQueue; +} + +struct SwapParams { + UD60x18 u; + UD60x18 U; + UD60x18 s; + UD60x18 S; + uint256 min; + uint256 max; +} + +abstract contract LpETHEvents { + error ErrorNotFinalized(uint256 tokenId); + error ErrorIsFinalized(uint256 tokenId); + error ErrorInvalidAsset(address asset); + error ErrorSlippage(uint256 out, uint256 minOut); + error ErrorDepositSharesZero(); + error ErrorRecoveryMode(); + error GaugeZero(); + + event Deposit(address indexed from, uint256 amount, uint256 lpSharesMinted); + event Withdraw(address indexed to, uint256 amount, uint256 lpSharesBurnt); + event Swap(address indexed caller, address indexed asset, uint256 amountIn, uint256 fee, uint256 unlockId); + event UnlockBought(address indexed caller, uint256 tokenId, uint256 amount, uint256 reward, uint256 lpFees); + event UnlockRedeemed(address indexed relayer, uint256 tokenId, uint256 amount, uint256 reward, uint256 lpFees); + event BatchUnlockRedeemed( + address indexed relayer, uint256 amount, uint256 reward, uint256 lpFees, uint256[] tokenIds + ); + event BatchUnlockBought(address indexed caller, uint256 amount, uint256 reward, uint256 lpFees, uint256[] tokenIds); + event RelayerRewardsClaimed(address indexed relayer, uint256 rewards); +} + +abstract contract LpETHStorage { + uint256 private constant SSLOT = uint256(keccak256("lpeth.xyz.storage.location")) - 1; + + struct Data { + LPToken lpToken; + // total amount unlocking + uint256 unlocking; + // total amount of liabilities owed to LPs + uint256 liabilities; + // sum of token supplies that have outstanding unlocks + UD60x18 S; + // Recovery amount, if `recovery` > 0 enable recovery mode + uint256 recovery; + // treasury share of rewards pending withdrawal + uint256 treasuryRewards; + // Unlock queue to hold unlocks + UnsETHQueue.Data unsETHQueue; + // amount unlocking per asset + mapping(address asset => uint256 unlocking) unlockingForAsset; + // last supply of a tenderizer when seen, tracked because they are rebasing tokens + mapping(address asset => UD60x18 lastSupply) lastSupplyForAsset; + // relayer fees + mapping(address relayer => uint256 reward) relayerRewards; + // fee gauges + mapping(address => UD60x18) gauges; + } + + function _loadStorageSlot() internal pure returns (Data storage $) { + uint256 slot = SSLOT; + + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := slot + } + } +} + +contract LPETH is + LpETHStorage, + LpETHEvents, + Initializable, + OwnableUpgradeable, + UUPSUpgradeable, + Multicallable, + SelfPermit, + ERC721Receiver +{ + using UnsETHQueue for UnsETHQueue.Data; + + LPToken private immutable LPTOKEN = LPToken(address(0)); + Registry private immutable REGISTRY = Registry(address(0)); + address payable private immutable TREASURY = payable(0x5542b58080FEE48dBE6f38ec0135cE9011519d96); + address payable private immutable UNSETH = payable(0xA2FE2b9298c03AF9C5d885e62Bc04F77a7Ff91BF); + address payable private immutable WITHDRAW_QUEUE = payable(address(0)); + + function initialize() public initializer { + __Ownable_init(msg.sender); + __UUPSUpgradeable_init(); + } + + receive() external payable { } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(ConstructorConfig memory config) { + REGISTRY = config.registry; + LPTOKEN = config.lpToken; + TREASURY = payable(config.treasury); + UNSETH = payable(config.unsETH); + WITHDRAW_QUEUE = payable(config.withdrawQueue); + _disableInitializers(); + } + + function setFeeGauge(address asset, UD60x18 gauge) external onlyOwner { + Data storage $ = _loadStorageSlot(); + if (gauge.eq(ZERO_60x18)) revert GaugeZero(); + $.gauges[asset] = gauge; + } + + function getFeeGauge(address asset) public view returns (UD60x18) { + Data storage $ = _loadStorageSlot(); + UD60x18 gauge = $.gauges[asset]; + return gauge.eq(ZERO_60x18) ? UNIT_60x18 : gauge; + } + + function deposit(uint256 minLpShares) external payable returns (uint256 lpShares) { + Data storage $ = _loadStorageSlot(); + + lpShares = $.liabilities > 0 + ? FixedPointMathLib.fullMulDiv(msg.value, LPTOKEN.totalSupply(), $.liabilities) + : msg.value; + + if (lpShares < minLpShares) revert ErrorSlippage(lpShares, minLpShares); + if (lpShares == 0) revert ErrorDepositSharesZero(); + + LPTOKEN.mint(msg.sender, lpShares); + $.liabilities += msg.value; + + emit Deposit(msg.sender, msg.value, lpShares); + } + + function withdraw(uint256 amount, uint256 maxLpSharesBurnt) external returns (uint256 requestId) { + Data storage $ = _loadStorageSlot(); + + uint256 available = ud(amount).mul(ud($.unlocking).div(ud($.liabilities))).unwrap(); + + requestId = WithdrawQueue(WITHDRAW_QUEUE).createRequest(uint128(amount - available), payable(msg.sender)); + + // Calculate LP tokens to burn + uint256 lpShares = + $.liabilities > 0 ? FixedPointMathLib.fullMulDivUp(amount, LPTOKEN.totalSupply(), $.liabilities) : amount; + + if (lpShares > maxLpSharesBurnt) revert ErrorSlippage(lpShares, maxLpSharesBurnt); + + // Update liabilities + $.liabilities -= amount; + + // Burn LP tokens from the caller + LPTOKEN.burn(msg.sender, lpShares); + + // Transfer available tokens to caller + payable(msg.sender).transfer(available); + + emit Withdraw(msg.sender, amount, lpShares); + } + + function quote(address asset, uint256 amount) external view returns (uint256 out) { + SwapParams memory p = _getSwapParams(asset); + (out,) = _quote(asset, amount, p); + } + + function swap(address asset, uint256 amount, uint256 minOut) external returns (uint256 out, uint256 fee) { + Data storage $ = _loadStorageSlot(); + + SwapParams memory p = _getSwapParams(asset); + + /** + * First unstake the LST so we can get the expected amount of ETH + * that will be available for withdrawal later. Since some LST/LRT protocols + * might charge small fees for unstaking, we need to account for that. + * TODO: if amount > max then we have to split this + * TODO: if amount < min we have to batch and not request the unstake at this time and not create the queue item + */ + // (uint256 min, uint256 max) = adapter.minMaxAmount(); + + SafeTransferLib.safeTransferFrom(asset, msg.sender, address(this), amount); + SafeTransferLib.safeApprove(asset, UNSETH, amount); + (uint256 tokenId, uint256 amountExpected) = UnsETH(UNSETH).requestWithdraw(asset, amount); + + (out, fee) = _quote(asset, amount, p); + + // Revert if slippage threshold is exceeded, i.e. if `out` is less than `minOut` + if (out < minOut) revert ErrorSlippage(out, minOut); + + // update pool state + $.unsETHQueue.push(UnsETHQueue.Item({ tokenId: tokenId, fee: fee })); + $.unlocking += amount; + $.unlockingForAsset[asset] += amount; + { + UD60x18 x = ud(amountExpected); + + $.lastSupplyForAsset[asset] = p.s.sub(x); + $.S = p.S.sub(x); + } + + // Transfer `out` of `to` to msg.sender + SafeTransferLib.safeTransferETH(msg.sender, out); + + emit Swap(msg.sender, asset, amount, fee, tokenId); + } + + function redeemUnlock() external { + Data storage $ = _loadStorageSlot(); + + // get oldest item from unlock queue + UnsETHQueue.Item memory unlock = $.unsETHQueue.popHead().data; + + if (!UnsETH(UNSETH).isFinalized(unlock.tokenId)) revert ErrorNotFinalized(unlock.tokenId); + + (, uint256 amountExpected,, address derivative) = UnsETH(UNSETH).metadata(unlock.tokenId); + uint256 amountReceived = UnsETH(UNSETH).claimWithdraw(unlock.tokenId); + + uint256 fee = _doRecovery(amountReceived, amountExpected, unlock.fee); + + // update pool state with liabilities + { + // - Update unlocking + uint256 unlocked = _min(amountExpected, amountReceived); + $.unlocking -= unlocked; + uint256 ufa = $.unlockingForAsset[derivative] - unlocked; + // - Update S if unlockingForAsset is now zero + if (ufa == 0) { + $.S = $.S.sub($.lastSupplyForAsset[derivative]); + $.lastSupplyForAsset[derivative] = ZERO_60x18; + } + // - Update unlockingForAsset + $.unlockingForAsset[derivative] = ufa; + } + + // account for rewards and fees + //calculate the relayer reward + uint256 relayerReward; + uint256 lpReward; + { + relayerReward = ud(fee).mul(RELAYER_CUT).unwrap(); + // update relayer rewards + $.relayerRewards[msg.sender] += relayerReward; + + // - Update liabilities to distribute LP rewards + uint256 treasuryCut = ud(fee).mul(TREASURY_CUT).unwrap(); + $.treasuryRewards += treasuryCut; + lpReward = fee - treasuryCut - relayerReward; + $.liabilities += lpReward; + } + + // Finalize requests + { + uint256 amountToFinalize = amountReceived - unlock.fee; + WithdrawQueue(WITHDRAW_QUEUE).finalizeRequests{ value: amountToFinalize }(); + } + + emit UnlockRedeemed(msg.sender, unlock.tokenId, amountReceived, relayerReward, lpReward); + } + + function batchRedeemUnlocks(uint256 n) external { + Data storage $ = _loadStorageSlot(); + uint256 totalReceived; + uint256 totalExpected; + uint256 totalFee; + uint256[] memory tokenIds = new uint256[](n); + for (uint256 i = 0; i < n; i++) { + // get oldest item from unlock queue + UnsETHQueue.Item memory unlock = $.unsETHQueue.popHead().data; + if (!UnsETH(UNSETH).isFinalized(unlock.tokenId)) break; + + (, uint256 amountExpected,, address derivative) = UnsETH(UNSETH).metadata(unlock.tokenId); + uint256 amountReceived = UnsETH(UNSETH).claimWithdraw(unlock.tokenId); + totalFee += unlock.fee; + totalExpected += amountExpected; + totalReceived += amountReceived; + + uint256 ufa = $.unlockingForAsset[derivative] - _min(amountReceived, amountExpected); + // - Update S if unlockingForAsset is now zero + if (ufa == 0) { + $.S = $.S.sub($.lastSupplyForAsset[derivative]); + $.lastSupplyForAsset[derivative] = ZERO_60x18; + } + // - Update unlockingForAsset + $.unlockingForAsset[derivative] = ufa; + tokenIds[i] = unlock.tokenId; + } + + uint256 totalFeeAfterRecovery = _doRecovery(totalReceived, totalExpected, totalFee); + // update pool state + // - Update unlocking + $.unlocking -= _min(totalExpected, totalReceived); + + //calculate the relayer reward + uint256 relayerReward; + uint256 lpReward; + { + relayerReward = ud(totalFeeAfterRecovery).mul(RELAYER_CUT).unwrap(); + // update relayer rewards + $.relayerRewards[msg.sender] += relayerReward; + + // - Update liabilities to distribute LP rewards + uint256 treasuryCut = ud(totalFeeAfterRecovery).mul(TREASURY_CUT).unwrap(); + $.treasuryRewards += treasuryCut; + lpReward = totalFeeAfterRecovery - treasuryCut - relayerReward; + $.liabilities += lpReward; + } + + // Finalize requests + { + uint256 amountToFinalize = totalReceived - totalFee; + WithdrawQueue(WITHDRAW_QUEUE).finalizeRequests{ value: amountToFinalize }(); + } + + emit BatchUnlockRedeemed(msg.sender, totalReceived, relayerReward, lpReward, tokenIds); + } + + function buyUnlock() external returns (uint256 tokenId) { + Data storage $ = _loadStorageSlot(); + + // Can not purchase unlocks in recovery mode + // The fees need to flow back to paying off debt and relayers are cheaper + if ($.recovery > 0) revert ErrorRecoveryMode(); + + // get newest item from unlock queue + UnsETHQueue.Item memory unlock = $.unsETHQueue.popTail().data; + tokenId = unlock.tokenId; + if (UnsETH(UNSETH).isFinalized(tokenId)) revert ErrorIsFinalized(tokenId); + + (, uint256 amountExpected, uint256 createdAt, address derivative) = UnsETH(UNSETH).metadata(tokenId); + + // Calculate the reward for purchasing the unlock + // The base reward is the fee minus the MIN_LP_CUT going to liquidity providers and minus the TREASURY_CUT going + // to the + // treasury + // The base reward then further decays as time to maturity decreases + uint256 reward; + uint256 lpCut; + uint256 treasuryCut; + { + UD60x18 fee60x18 = ud(unlock.fee); + lpCut = fee60x18.mul(MIN_LP_CUT).unwrap(); + treasuryCut = fee60x18.mul(TREASURY_CUT).unwrap(); + uint256 baseReward = unlock.fee - lpCut - treasuryCut; + UD60x18 progress = ud(createdAt - block.number).div(ud(UNSETH_EXPIRATION_TIME)); + reward = ud(baseReward).mul(UNIT_60x18.sub(progress)).unwrap(); + // Adjust lpCut by the remaining amount after subtracting the reward + // This step seems to adjust lpCut to balance out the distribution + // Assuming the final lpCut should encompass any unallocated fee portions + lpCut += baseReward - reward; + } + + // Update pool state + // - update unlocking + $.unlocking -= amountExpected; + // - Update liabilities to distribute LP rewards + $.liabilities += lpCut; + // - Update treasury rewards + $.treasuryRewards += treasuryCut; + + uint256 ufa = $.unlockingForAsset[derivative] - amountExpected; + // - Update S if unlockingForAsset is now zero + if (ufa == 0) { + $.S = $.S.sub($.lastSupplyForAsset[derivative]); + $.lastSupplyForAsset[derivative] = ZERO_60x18; + } + // - Update unlockingForAsset + $.unlockingForAsset[derivative] = ufa; + + // Finalize requests + { + uint256 amountToFinalize = amountExpected - unlock.fee; + WithdrawQueue(WITHDRAW_QUEUE).finalizeRequests{ value: amountToFinalize }(); + } + + // transfer unlock amount minus reward from caller to pool + // the reward is the discount paid. 'reward < unlock.fee' always. + SafeTransferLib.safeTransferFrom(derivative, msg.sender, address(this), amountExpected - reward); + + // transfer unlock to caller + UnsETH(UNSETH).safeTransferFrom(address(this), msg.sender, tokenId); + + emit UnlockBought(msg.sender, tokenId, amountExpected, reward, lpCut); + } + + function batchBuyUnlock(uint256 n) external { + Data storage $ = _loadStorageSlot(); + + // Can not purchase unlocks in recovery mode + // The fees need to flow back to paying off debt and relayers are cheaper + if ($.recovery > 0) revert ErrorRecoveryMode(); + + uint256 totalAmountExpected; + uint256 totalRewards; + uint256 totalLpCut; + uint256 totalTreasuryCut; + + uint256[] memory tokenIds = new uint256[](n); + + for (uint256 i = 0; i < n; i++) { + // get newest item from unlock queue + UnsETHQueue.Item memory unlock = $.unsETHQueue.popTail().data; + if (UnsETH(UNSETH).isFinalized(unlock.tokenId)) break; + (, uint256 amountExpected, uint256 createdAt, address derivative) = UnsETH(UNSETH).metadata(unlock.tokenId); + if (block.timestamp - createdAt > UNSETH_EXPIRATION_TIME) break; + totalAmountExpected += amountExpected; + tokenIds[i] = unlock.tokenId; + uint256 reward; + { + UD60x18 fee60x18 = ud(unlock.fee); + uint256 lpCut = fee60x18.mul(MIN_LP_CUT).unwrap(); + uint256 treasuryCut = fee60x18.mul(TREASURY_CUT).unwrap(); + uint256 baseReward = unlock.fee - lpCut - treasuryCut; + UD60x18 progress = ud(createdAt - block.number).div(ud(UNSETH_EXPIRATION_TIME)); + reward = ud(baseReward).mul(UNIT_60x18.sub(progress)).unwrap(); + // Adjust lpCut by the remaining amount after subtracting the reward + // This step seems to adjust lpCut to balance out the distribution + // Assuming the final lpCut should encompass any unallocated fee portions + lpCut += baseReward - reward; + totalRewards += reward; + totalLpCut += lpCut; + totalTreasuryCut += treasuryCut; + } + + uint256 ufa = $.unlockingForAsset[derivative] - amountExpected; + // - Update S if unlockingForAsset is now zero + if (ufa == 0) { + $.S = $.S.sub($.lastSupplyForAsset[derivative]); + $.lastSupplyForAsset[derivative] = ZERO_60x18; + } + // - Update unlockingForAsset + $.unlockingForAsset[derivative] = ufa; + + // transfer unlock amount minus reward from caller to pool + // the reward is the discount paid. 'reward < unlock.fee' always. + SafeTransferLib.safeTransferFrom(derivative, msg.sender, address(this), amountExpected - reward); + + // transfer unlock to caller + UnsETH(UNSETH).safeTransferFrom(address(this), msg.sender, unlock.tokenId); + } + + // Update pool state + // - update unlocking + $.unlocking -= totalAmountExpected; + // - Update liabilities to distribute LP rewards + $.liabilities += totalLpCut; + // - Update treasury rewards + $.treasuryRewards += totalTreasuryCut; + + // Finalize requests + { + uint256 amountToFinalize = totalAmountExpected - totalRewards - totalLpCut - totalTreasuryCut; + WithdrawQueue(WITHDRAW_QUEUE).finalizeRequests{ value: amountToFinalize }(); + } + + emit BatchUnlockBought(msg.sender, totalAmountExpected, totalRewards, totalLpCut, tokenIds); + } + + /** + * @notice Claim outstanding rewards for a relayer. + * @return relayerReward Amount of tokens claimed + */ + function claimRelayerRewards() external returns (uint256 relayerReward) { + Data storage $ = _loadStorageSlot(); + + relayerReward = $.relayerRewards[msg.sender]; + + delete $.relayerRewards[msg.sender]; + + payable(msg.sender).transfer(relayerReward); + + emit RelayerRewardsClaimed(msg.sender, relayerReward); + } + + function claimTreasuryRewards() external onlyOwner returns (uint256 treasuryReward) { + Data storage $ = _loadStorageSlot(); + + treasuryReward = $.treasuryRewards; + + $.treasuryRewards = 0; + + payable(TREASURY).transfer(treasuryReward); + } + + function lpToken() external view returns (address) { + return address(LPTOKEN); + } + + function liabilities() external view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.liabilities; + } + + /** + * @notice Amount of available liquidity (cash on hand). + */ + function liquidity() public view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.liabilities - $.unlocking; + } + + /** + * @notice Check outstanding rewards for a relayer. + * @param relayer Address of the relayer + * @return relayerReward Amount of tokens that can be claimed + */ + function pendingRelayerRewards(address relayer) external view returns (uint256) { + Data storage $ = _loadStorageSlot(); + return $.relayerRewards[relayer]; + } + + ///@dev required by the OZ UUPS module + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner { } + + function _min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + function _getSwapParams(address asset) internal view returns (SwapParams memory p) { + Data storage $ = _loadStorageSlot(); + Adapter adapter = REGISTRY.adapters(asset); + (uint256 min, uint256 max) = adapter.minMaxAmount(); + if (address(adapter) == address(0)) revert ErrorInvalidAsset(asset); + UD60x18 U = ud($.unlocking); + UD60x18 u = ud($.unlockingForAsset[asset]); + (UD60x18 s, UD60x18 S) = _checkTotalETHStaked(asset, adapter); + p = SwapParams({ U: U, u: u, S: S, s: s, min: min, max: max }); + } + + /** + * @notice Since the LSTs to be exchanged are aTokens, and thus have a rebasing supply, + * we need to update the supplies upon a swap to correctly determine the spread of the asset. + */ + function _checkTotalETHStaked(address asset, Adapter adapter) internal view returns (UD60x18 s, UD60x18 S) { + Data storage $ = _loadStorageSlot(); + + S = $.S; + + s = ud(adapter.totalStaked()); + UD60x18 oldSupply = $.lastSupplyForAsset[asset]; + + if (oldSupply.lt(s)) { + S = S.add(s.sub(oldSupply)); + } else if (oldSupply.gt(s)) { + S = S.sub(oldSupply.sub(s)); + } + } + + function _quote( + address asset, + uint256 amount, + SwapParams memory p + ) + internal + view + returns (uint256 out, uint256 fee) + { + Data storage $ = _loadStorageSlot(); + UD60x18 x = ud(amount); + UD60x18 nom = _calculateNominator(x, p, $); + UD60x18 denom = _calculateDenominator(p); + + UD60x18 gauge = getFeeGauge(asset); + // total fee = gauge x (baseFee * amount + nom/denom) + fee = BASE_FEE.mul(x).add(nom.div(denom)).mul(gauge).unwrap(); + fee = fee >= amount ? amount : fee; + unchecked { + out = amount - fee; + } + } + + function _calculateNominator(UD60x18 x, SwapParams memory p, Data storage $) internal view returns (UD60x18 nom) { + UD60x18 L = ud($.liabilities); + UD60x18 sumA = p.u.add(x).mul(K).add(p.u); + UD60x18 negatorB = K.add(UNIT_60x18).mul(p.u); + UD60x18 util = p.U.div(L).pow(K); + UD60x18 util_change = p.U.add(x).div(L).pow(K); + + if (sumA < p.U) { + sumA = p.U.sub(sumA).mul(util_change); + // we must subtract sumA from sumB + // we know sumB must always be positive so we + // can proceed with the regular calculation + UD60x18 sumB = p.U.sub(negatorB).mul(util); + nom = sumB.sub(sumA).mul(p.S.add(p.U)); + } else { + // sumA is positive, sumB can be positive or negative + sumA = sumA.sub(p.U).mul(util_change); + if (p.U < negatorB) { + UD60x18 sumB = negatorB.sub(p.U).mul(util); + nom = sumA.sub(sumB).mul(p.S.add(p.U)); + } else { + UD60x18 sumB = p.U.sub(negatorB).mul(util); + nom = sumA.add(sumB).mul(p.S.add(p.U)); + } + } + } + + function _calculateDenominator(SwapParams memory p) internal pure returns (UD60x18) { + return K.mul(UNIT_60x18.add(K)).mul(p.s.add(p.u)); + } + + function _doRecovery( + uint256 amountReceived, + uint256 amountExpected, + uint256 fee + ) + internal + returns (uint256 remaining) + { + Data storage $ = _loadStorageSlot(); + uint256 recovery = $.recovery; + + // Handle deficit + if (amountReceived < amountExpected) { + recovery += amountExpected - amountReceived; + } + + // Handle surplus + if (amountReceived > amountExpected) { + uint256 excess = amountReceived - amountExpected; + amountReceived = amountExpected; + if (excess > recovery) { + excess -= recovery; + recovery = 0; + $.liabilities += excess; + } else { + recovery -= excess; + excess = 0; + } + } + + if (recovery > 0) { + if (fee >= recovery) { + unchecked { + fee -= recovery; + recovery = 0; + } + } else { + unchecked { + recovery -= fee; + fee = 0; + } + } + } + remaining = fee; + $.recovery = recovery; + } +} diff --git a/src/LPToken.sol b/src/LPToken.sol new file mode 100644 index 0000000..5c96e87 --- /dev/null +++ b/src/LPToken.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.20; + +import { ERC20 } from "@solady/tokens/ERC20.sol"; + +contract LPToken is ERC20 { + address public immutable owner; + + error Unauthorized(); + + modifier onlyOwner() { + if (msg.sender != owner) revert Unauthorized(); + _; + } + + constructor() ERC20() { + owner = msg.sender; + } + + function name() public pure override returns (string memory) { + return "lpETH"; + } + + function symbol() public pure override returns (string memory) { + return "lpETH"; + } + + function mint(address to, uint256 value) public onlyOwner { + _mint(to, value); + } + + function burn(address from, uint256 value) public onlyOwner { + _burn(from, value); + } +} diff --git a/src/Registry.sol b/src/Registry.sol new file mode 100644 index 0000000..717c200 --- /dev/null +++ b/src/Registry.sol @@ -0,0 +1,26 @@ +pragma solidity >=0.8.20; + +import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { Adapter } from "@/adapters/Adapter.sol"; + +contract Registry is Initializable, OwnableUpgradeable, UUPSUpgradeable { + mapping(address asset => Adapter) public adapters; + + function initialize() public initializer { + __Ownable_init(msg.sender); + __UUPSUpgradeable_init(); + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function setAdapter(address token, Adapter adapter) external onlyOwner { + adapters[token] = adapter; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyOwner { } +} diff --git a/src/UnsETHQueue.sol b/src/UnsETHQueue.sol new file mode 100644 index 0000000..0676f76 --- /dev/null +++ b/src/UnsETHQueue.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +/** + * @notice This file implements the necessary functionality for a double-ended queue or deque. + * Elements can be popped from the front or back, but the deque can only be appended to. + * It is used to store a list of unlocks for a given TenderSwap pool. + * @dev modified from https://github.com/Tenderize/tenderswap/blob/main/src/UnlockQueue.sol + */ +pragma solidity >=0.8.19; + +library UnsETHQueue { + error QueueEmpty(); + error IdExists(); + + struct Item { + uint256 tokenId; + uint256 fee; + } + + struct Node { + Item data; + uint256 next; + uint256 prev; + } + + struct Data { + uint256 _head; // oldest element + uint256 _tail; // newest element + mapping(uint256 index => Node) nodes; // elements as a map + } + + /** + * @notice Get the oldest element in the queue + * @param q The queue to query + * @return The oldest element in the queue + */ + function head(UnsETHQueue.Data storage q) internal view returns (Node memory) { + return q.nodes[q._head]; + } + + /** + * @notice Get the newest element in the queue + * @param q The queue to query + * @return The newest element in the queue + */ + function tail(UnsETHQueue.Data storage q) internal view returns (Node memory) { + return q.nodes[q._tail]; + } + + /** + * @notice Pop the oldest element from the queue + * @param q The queue to pop from + */ + function popHead(UnsETHQueue.Data storage q) internal returns (Node memory node) { + uint256 head = q._head; + if (head == 0) revert QueueEmpty(); + + node = q.nodes[head]; + + uint256 next = q.nodes[head].next; + if (next == 0) { + q._head = 0; + q._tail = 0; + } else { + q._head = next; + q.nodes[next].prev = 0; + } + + delete q.nodes[head]; + } + + /** + * @notice Pop the newest element from the queue + * @param q The queue to pop from + */ + function popTail(UnsETHQueue.Data storage q) internal returns (Node memory node) { + uint256 tail = q._tail; + if (tail == 0) revert QueueEmpty(); + + node = q.nodes[tail]; + + uint256 prev = q.nodes[tail].prev; + if (prev == 0) { + q._head = 0; + q._tail = 0; + } else { + q._tail = prev; + q.nodes[prev].next = 0; + } + + delete q.nodes[tail]; + } + + /** + * @notice Push a new element to the back of the queue + * @param q The queue to push to + * @param unlock The unlock data to push + */ + function push(UnsETHQueue.Data storage q, Item memory unlock) internal { + uint256 tail = q._tail; + uint256 newTail = unlock.tokenId; + + if (tail != 0) { + if (q.nodes[newTail].data.tokenId != 0) revert IdExists(); + } + + q.nodes[newTail].data = unlock; + q.nodes[newTail].prev = tail; + + if (tail == 0) { + q._head = newTail; + } else { + q.nodes[tail].next = newTail; + } + + q._tail = newTail; + } +} diff --git a/src/WithdrawQueue.sol b/src/WithdrawQueue.sol new file mode 100644 index 0000000..0e2edc9 --- /dev/null +++ b/src/WithdrawQueue.sol @@ -0,0 +1,96 @@ +pragma solidity >=0.8.20; + +address constant LPETH = address(0); + +error NotFinalized(uint256 id); +error InsufficientMsgvalue(); +error Unauthorized(); + +struct WithdrawRequest { + uint128 amount; // original request amount + uint128 claimed; // amount claimed + uint256 cumulative; // cumulative lifetime requested + address payable account; +} + +contract WithdrawQueue { + uint256 private head; + uint256 private tail; + uint256 private lifetimeFinalized; + uint128 private partiallyFinalizedAmount; + + mapping(uint256 id => WithdrawRequest) private queue; + + receive() external payable { } + + function createRequest(uint128 amount, address payable account) external returns (uint256 id) { + if (msg.sender != LPETH) revert Unauthorized(); + // start head at 1 + id = ++tail; + queue[id] = WithdrawRequest(amount, 0, queue[id - 1].cumulative + amount, account); + if (head == 0) head = 1; + } + + function claimRequest(uint256 id) external { + WithdrawRequest storage req = queue[id]; + if (msg.sender != req.account) revert Unauthorized(); + if (id < head) { + uint256 amount = req.amount - req.claimed; + delete queue[id]; + req.account.transfer(amount); + } else if (id == head) { + req.claimed = partiallyFinalizedAmount; + req.account.transfer(partiallyFinalizedAmount); + } else { + revert NotFinalized(id); + } + } + + function finalizeRequests() external payable { + uint256 amount = msg.value; + if (msg.sender != LPETH) revert Unauthorized(); + uint256 index = _findFinalizableIndex(head, tail, amount); + head = index + 1; + partiallyFinalizedAmount = uint128(amount - (queue[index].cumulative - lifetimeFinalized)); + lifetimeFinalized += amount; + } + + function getClaimableForRequest(uint256 id) external view returns (uint256) { + if (id < head) { + WithdrawRequest memory req = queue[id]; + return req.amount - req.claimed; + } else if (id == head) { + WithdrawRequest memory req = queue[id]; + return partiallyFinalizedAmount - req.claimed; + } else { + return 0; + } + } + + function length() external view returns (uint256) { + return tail - head; + } + + function amountUnfinalized() external view returns (uint256) { + return queue[tail].cumulative - lifetimeFinalized; + } + + function _findFinalizableIndex(uint256 start, uint256 end, uint256 amount) internal view returns (uint256) { + uint256 _ltf = lifetimeFinalized; + + while (start < end) { + uint256 mid = (start + end) / 2; + uint256 midCumulative = queue[mid].cumulative; + + if (midCumulative - _ltf == amount) { + return mid; + } else if (midCumulative - _ltf <= amount) { + start = mid + 1; + } else { + end = mid; + } + } + + return start; + } +} diff --git a/src/adapters/Adapter.sol b/src/adapters/Adapter.sol new file mode 100644 index 0000000..ae71798 --- /dev/null +++ b/src/adapters/Adapter.sol @@ -0,0 +1,32 @@ +pragma solidity >=0.8.20; + +library AdapterDelegateCall { + error AdapterDelegateCallFailed(string msg); + + function _delegatecall(Adapter adapter, bytes memory data) internal returns (bytes memory) { + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory returnData) = address(adapter).delegatecall(data); + + if (!success) { + // Next 5 lines from https://ethereum.stackexchange.com/a/83577 + if (returnData.length < 68) revert AdapterDelegateCallFailed(""); + assembly { + returnData := add(returnData, 0x04) + } + revert AdapterDelegateCallFailed(abi.decode(returnData, (string))); + } + + return returnData; + } +} + +interface Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256 amountExpected); + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected); + // TODO: for each adapter check if a cross-contract invocation to get this amount is more efficient + // than fetching account balance before and after + function claimWithdraw(uint256 tokenId) external returns (uint256 amount); + function isFinalized(uint256 tokenId) external view returns (bool); + function totalStaked() external view returns (uint256); + function minMaxAmount() external view returns (uint256 min, uint256 max); +} diff --git a/src/adapters/ETHx/ETHxAdapter.sol b/src/adapters/ETHx/ETHxAdapter.sol new file mode 100644 index 0000000..412011b --- /dev/null +++ b/src/adapters/ETHx/ETHxAdapter.sol @@ -0,0 +1,43 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/adapters/Adapter.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { IUserWithdrawalManager, IStaderStakePoolsManager, UserWithdrawInfo } from "@/adapters/ETHx/IStader.sol"; + +address constant STADER_USER_WITHDRAWAL_MANAGER = 0x9F0491B32DBce587c50c4C43AB303b06478193A7; +address constant STADER_STAKE_POOLS_MANAGER = 0xcf5EA1b38380f6aF39068375516Daf40Ed70D299; +address constant ETHx_TOKEN = 0xA35b1B31Ce002FBF2058D22F30f95D405200A15b; + +uint256 constant MIN_AMOUNT = 100_000_000_000_000; // 0,0001 ETH +uint256 constant MAX_AMOUNT = 10_000 ether; + +contract ETHxAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return IStaderStakePoolsManager(STADER_STAKE_POOLS_MANAGER).previewWithdraw(amount); + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + SafeTransferLib.safeApprove(ETHx_TOKEN, STADER_USER_WITHDRAWAL_MANAGER, amount); + tokenId = IUserWithdrawalManager(STADER_USER_WITHDRAWAL_MANAGER).requestWithdraw(amount, address(this)); + amountExpected = + IUserWithdrawalManager(STADER_USER_WITHDRAWAL_MANAGER).userWithdrawRequests(tokenId).ethExpected; + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + IUserWithdrawalManager(STADER_USER_WITHDRAWAL_MANAGER).claim(tokenId); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + return tokenId < IUserWithdrawalManager(STADER_USER_WITHDRAWAL_MANAGER).nextRequestIdToFinalize(); + } + + function totalStaked() external view returns (uint256) { + return IStaderStakePoolsManager(STADER_STAKE_POOLS_MANAGER).totalAssets(); + } + + function minMaxAmount() external pure returns (uint256 min, uint256 max) { + return (MIN_AMOUNT, MAX_AMOUNT); + } +} diff --git a/src/adapters/ETHx/IStader.sol b/src/adapters/ETHx/IStader.sol new file mode 100644 index 0000000..b23cb22 --- /dev/null +++ b/src/adapters/ETHx/IStader.sol @@ -0,0 +1,21 @@ +/// @notice structure representing a user request for withdrawal. +struct UserWithdrawInfo { + address payable owner; // address that can claim eth on behalf of this request + uint256 ethXAmount; //amount of ethX share locked for withdrawal + uint256 ethExpected; //eth requested according to given share and exchangeRate + uint256 ethFinalized; // final eth for claiming according to finalize exchange rate + uint256 requestBlock; // block number of withdraw request +} + +interface IUserWithdrawalManager { + // returns the request id + function requestWithdraw(uint256 _ethXAmount, address _owner) external returns (uint256); + function claim(uint256 _requestId) external; + function userWithdrawRequests(uint256 _requestId) external view returns (UserWithdrawInfo memory); + function nextRequestIdToFinalize() external view returns (uint256); +} + +interface IStaderStakePoolsManager { + function totalAssets() external view returns (uint256); + function previewWithdraw(uint256 _ethXAmount) external view returns (uint256); +} diff --git a/src/adapters/eETH/EETHAdapter.sol b/src/adapters/eETH/EETHAdapter.sol new file mode 100644 index 0000000..0e677c7 --- /dev/null +++ b/src/adapters/eETH/EETHAdapter.sol @@ -0,0 +1,46 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/adapters/Adapter.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { ILiquidityPool, IWithdrawRequestNFT } from "@/adapters/eETH/IEtherfi.sol"; + +address constant ETHERFI_WITHDRAW_REQUEST_NFT = 0x7d5706f6ef3F89B3951E23e557CDFBC3239D4E2c; +address constant ETHERFI_LIQUIDITY_POOL = 0x308861A430be4cce5502d0A12724771Fc6DaF216; +address constant EETH_TOKEN = 0x35fA164735182de50811E8e2E824cFb9B6118ac2; + +uint256 constant MIN_AMOUNT = 1e9; +uint256 constant MAX_AMOUNT = type(uint96).max; + +contract EETHAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return ILiquidityPool(ETHERFI_LIQUIDITY_POOL).amountForShare( + ILiquidityPool(ETHERFI_LIQUIDITY_POOL).sharesForAmount(amount) + ); + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + SafeTransferLib.safeApprove(EETH_TOKEN, ETHERFI_LIQUIDITY_POOL, amount); + amountExpected = ILiquidityPool(ETHERFI_LIQUIDITY_POOL).amountForShare( + ILiquidityPool(ETHERFI_LIQUIDITY_POOL).sharesForAmount(amount) + ); + tokenId = ILiquidityPool(ETHERFI_LIQUIDITY_POOL).requestWithdraw(address(this), amount); + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + IWithdrawRequestNFT(ETHERFI_WITHDRAW_REQUEST_NFT).claimWithdraw(tokenId); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + return IWithdrawRequestNFT(ETHERFI_WITHDRAW_REQUEST_NFT).isFinalized(tokenId); + } + + function totalStaked() external view returns (uint256) { + return ILiquidityPool(ETHERFI_LIQUIDITY_POOL).getTotalPooledEther(); + } + + function minMaxAmount() external pure returns (uint256 min, uint256 max) { + return (MIN_AMOUNT, MAX_AMOUNT); + } +} diff --git a/src/adapters/eETH/IEtherfi.sol b/src/adapters/eETH/IEtherfi.sol new file mode 100644 index 0000000..7163221 --- /dev/null +++ b/src/adapters/eETH/IEtherfi.sol @@ -0,0 +1,12 @@ +interface ILiquidityPool { + function requestWithdraw(address recipient, uint256 amount) external returns (uint256 requestId); + function amountForShare(uint256 share) external view returns (uint256); + function sharesForAmount(uint256 amount) external view returns (uint256); + function getTotalPooledEther() external view returns (uint256); +} + +interface IWithdrawRequestNFT { + function claimWithdraw(uint256 tokenId) external; + function isFinalized(uint256 requestId) external view returns (bool); + function getClaimableAmount(uint256 tokenId) external view returns (uint256); +} diff --git a/src/adapters/lsETH/ILiquidCollective.sol b/src/adapters/lsETH/ILiquidCollective.sol new file mode 100644 index 0000000..5aa244d --- /dev/null +++ b/src/adapters/lsETH/ILiquidCollective.sol @@ -0,0 +1,34 @@ +struct RedeemRequest { + /// @custom:attribute The amount of the redeem request in LsETH + uint256 amount; + /// @custom:attribute The maximum amount of ETH redeemable by this request + uint256 maxRedeemableEth; // equivalent to 'amountExpected' in + /// @custom:attribute The owner of the redeem request + address owner; + /// @custom:attribute The height is the cumulative sum of all the sizes of preceding redeem requests + uint256 height; +} + +interface IRiver { + function totalUnderlyingSupply() external view returns (uint256); + function underlyingBalanceFromShares(uint256 _shares) external view returns (uint256); + function requestRedeem(uint256 _lsETHAmount, address _recipient) external returns (uint32 redeemRequestId); +} + +interface IRedeemManager { + function getRedeemRequestDetails(uint32 _redeemRequestId) external view returns (RedeemRequest memory); + + function resolveRedeemRequests(uint32[] calldata _redeemRequestIds) + external + view + returns (int64[] memory withdrawalEventIds); + + function requestRedeem(uint256 _lsETHAmount) external returns (uint32 redeemRequestId); + + function claimRedeemRequests( + uint32[] calldata _redeemRequestIds, + uint32[] calldata _withdrawalEventIds + ) + external + returns (uint8[] memory claimStatuses); +} diff --git a/src/adapters/lsETH/LsETHAdapter.sol b/src/adapters/lsETH/LsETHAdapter.sol new file mode 100644 index 0000000..cbfcfd4 --- /dev/null +++ b/src/adapters/lsETH/LsETHAdapter.sol @@ -0,0 +1,47 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/adapters/Adapter.sol"; +import { IRiver, IRedeemManager } from "@/adapters/lsETH/ILiquidCollective.sol"; + +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; + +address constant REDEEM_MANAGER = 0x080b3a41390b357Ad7e8097644d1DEDf57AD3375; +address constant LSETH_TOKEN = 0x8c1BEd5b9a0928467c9B1341Da1D7BD5e10b6549; + +contract LsETHAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return IRiver(LSETH_TOKEN).underlyingBalanceFromShares(amount); + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + SafeTransferLib.safeApprove(LSETH_TOKEN, LSETH_TOKEN, amount); + amountExpected = IRiver(LSETH_TOKEN).underlyingBalanceFromShares(amount); + tokenId = IRiver(LSETH_TOKEN).requestRedeem(amount, address(this)); + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + uint32[] memory redeemRequestIds = new uint32[](1); + redeemRequestIds[0] = uint32(tokenId); + uint32[] memory withdrawalEventIds = new uint32[](1); + // TODO: Safecast ? + withdrawalEventIds[0] = uint32(int32(IRedeemManager(REDEEM_MANAGER).resolveRedeemRequests(redeemRequestIds)[0])); + IRedeemManager(REDEEM_MANAGER).claimRedeemRequests(redeemRequestIds, withdrawalEventIds); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + uint32[] memory redeemRequestIds = new uint32[](1); + redeemRequestIds[0] = uint32(tokenId); + int64[] memory withdrawalEventIds = IRedeemManager(REDEEM_MANAGER).resolveRedeemRequests(redeemRequestIds); + return withdrawalEventIds[0] >= 0; + } + + function totalStaked() external view returns (uint256) { + return IRiver(LSETH_TOKEN).totalUnderlyingSupply(); + } + + function minMaxAmount() external view returns (uint256 min, uint256 max) { + return (1e9, type(uint256).max); + } +} diff --git a/src/adapters/mETH/IMantle.sol b/src/adapters/mETH/IMantle.sol new file mode 100644 index 0000000..84b4ce5 --- /dev/null +++ b/src/adapters/mETH/IMantle.sol @@ -0,0 +1,13 @@ +// 0xe3cBd06D7dadB3F4e6557bAb7EdD924CD1489E8f + +interface IStaking { + // TODO: 0,01 ETH min + function unstakeRequest(uint128 methAmount, uint128 minETHAmount) external returns (uint256 id); + function claimUnstakeRequest(uint256 unstakeRequestID) external; + function unstakeRequestInfo(uint256 unstakeRequestID) + external + view + returns (bool finalized, uint256 claimableAmount); + function mETHToETH(uint256 mETHAmount) external view returns (uint256); + function totalControlled() external view returns (uint256); +} diff --git a/src/adapters/mETH/METHAdapter.sol b/src/adapters/mETH/METHAdapter.sol new file mode 100644 index 0000000..256751b --- /dev/null +++ b/src/adapters/mETH/METHAdapter.sol @@ -0,0 +1,48 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/adapters/Adapter.sol"; +import { IStaking } from "@/adapters/mETH/IMantle.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { SafeCastLib } from "solady/utils/SafeCastLib.sol"; + +address constant STAKING = 0xe3cBd06D7dadB3F4e6557bAb7EdD924CD1489E8f; +address constant METH_TOKEN = 0xd5F7838F5C461fefF7FE49ea5ebaF7728bB0ADfa; + +uint256 constant MIN_AMOUNT = 10_000_000_000_000_000; // 0.01 ETH +uint256 constant MAX_AMOUNT = type(uint128).max; + +contract METHAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return IStaking(STAKING).mETHToETH(amount); + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + SafeTransferLib.safeApprove(METH_TOKEN, STAKING, amount); + + // Safe cast amount to uint128 + // calculate minEthReceived + uint128 safeCastAmount = SafeCastLib.toUint128(amount); + amountExpected = IStaking(STAKING).mETHToETH(amount); + // no need to safeCast amount expected + tokenId = IStaking(STAKING).unstakeRequest(safeCastAmount, uint128(amountExpected)); + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + IStaking(STAKING).claimUnstakeRequest(tokenId); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + (bool finalized,) = IStaking(STAKING).unstakeRequestInfo(tokenId); + return finalized; + } + + function totalStaked() external view returns (uint256) { + return IStaking(STAKING).totalControlled(); + } + + function minMaxAmount() external pure returns (uint256 min, uint256 max) { + return (MIN_AMOUNT, MAX_AMOUNT); + } +} diff --git a/src/adapters/stETH/ILido.sol b/src/adapters/stETH/ILido.sol new file mode 100644 index 0000000..6539f87 --- /dev/null +++ b/src/adapters/stETH/ILido.sol @@ -0,0 +1,36 @@ +struct WithdrawalRequestStatus { + /// @notice stETH token amount that was locked on withdrawal queue for this request + uint256 amountOfStETH; + /// @notice amount of stETH shares locked on withdrawal queue for this request + uint256 amountOfShares; + /// @notice address that can claim or transfer this request + address owner; + /// @notice timestamp of when the request was created, in seconds + uint256 timestamp; + /// @notice true, if request is finalized + bool isFinalized; + /// @notice true, if request is claimed. Request is claimable if (isFinalized && !isClaimed) + bool isClaimed; +} + +interface IWithdrawalQueue { + // TODO: this has a minimum !! + function requestWithdrawals( + uint256[] calldata _amounts, + address _owner + ) + external + returns (uint256[] memory requestIds); + + // TODO: find a solution to optimize with hints + function claimWithdrawal(uint256 _requestId) external; + + function getWithdrawalStatus(uint256[] calldata _requestIds) + external + view + returns (WithdrawalRequestStatus[] memory statuses); +} + +interface IStETH { + function getTotalPooledEther() external view returns (uint256); +} diff --git a/src/adapters/stETH/StETHAdapter.sol b/src/adapters/stETH/StETHAdapter.sol new file mode 100644 index 0000000..211ce9d --- /dev/null +++ b/src/adapters/stETH/StETHAdapter.sol @@ -0,0 +1,52 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/Registry.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; +import { IStETH, IWithdrawalQueue, WithdrawalRequestStatus } from "@/adapters/stETH/ILido.sol"; + +address constant STETH_TOKEN = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; +address constant LIDO_WITHDRAWAL_QUEUE = 0x889edC2eDab5f40e902b864aD4d7AdE8E412F9B1; +uint256 constant MIN_AMOUNT = 1e9; +uint256 constant MAX_AMOUNT = 1000 ether; + +contract StETHAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return amount; + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + // This has a min and a max, so if below min (check) we have to batch + // if above max we have to split into multiple requests + // min is negligible, max is 1000 ETH this would result in multiple token Ids + + // We can solve this with a new abstraction in the adapter instead + // We can have a function that returns the min and max amount + // if the swap amount exceeds the max amount then we have to multicall it + SafeTransferLib.safeApprove(STETH_TOKEN, LIDO_WITHDRAWAL_QUEUE, amount); + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + tokenId = IWithdrawalQueue(LIDO_WITHDRAWAL_QUEUE).requestWithdrawals(amounts, address(this))[0]; + // TODO: find amount expected ? + amountExpected = amount; + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + IWithdrawalQueue(LIDO_WITHDRAWAL_QUEUE).claimWithdrawal(tokenId); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + return IWithdrawalQueue(LIDO_WITHDRAWAL_QUEUE).getWithdrawalStatus(tokenIds)[0].isFinalized; + } + + function totalStaked() external view returns (uint256) { + return IStETH(STETH_TOKEN).getTotalPooledEther(); + } + + function minMaxAmount() external pure returns (uint256 min, uint256 max) { + return (MIN_AMOUNT, MAX_AMOUNT); + } +} diff --git a/src/adapters/swETH/ISwell.sol b/src/adapters/swETH/ISwell.sol new file mode 100644 index 0000000..f175878 --- /dev/null +++ b/src/adapters/swETH/ISwell.sol @@ -0,0 +1,35 @@ +// TODO: there is a min and maximum amount for the withdrawal + +struct WithdrawRequest { + uint256 amount; + uint256 lastTokenIdProcessed; // last token id processed at the time the request was created + // when claiming (finalizing here) it will perform a binary search between lastTokenIdProcessed and our tokenid + uint256 rateWhenCreated; +} + +uint256 constant WITHDRAW_REQUEST_MAX = 0; +uint256 constant WITHDRAW_REQUEST_MIN = 0; + +interface IswEXIT { + // this doesn't return the id, so we need to get the id seperately + function createWithdrawRequest(uint256 amount) external; + function getLastTokenIdCreated() external view returns (uint256); + + // this doesn't return anything and getting the processed rate is very expensive + // so instead we can just get balance before and after from the adapter + function finalizeWithdrawal(uint256 tokenId) external; + + // isFinalized = lastTokenIdProcessd >= tokenId + function getLastTokenIdProcessed() external view returns (uint256); + // probably don't need this + function withdrawalRequests(uint256 tokenId) external view returns (WithdrawRequest memory); +} + +interface IswETH { + // uses ud60x18 under the hood + function getRate() external view returns (uint256); + function totalSupply() external view returns (uint256); + + // totalStaked will be total_supply * rate / 1e18 + // or if using ud60x18 wrap(total_supply).mul(wrap(rate)).unwrap() +} diff --git a/src/adapters/swETH/SwETHAdapter.sol b/src/adapters/swETH/SwETHAdapter.sol new file mode 100644 index 0000000..203f4a1 --- /dev/null +++ b/src/adapters/swETH/SwETHAdapter.sol @@ -0,0 +1,43 @@ +pragma solidity >=0.8.20; + +import { Adapter } from "@/adapters/Adapter.sol"; +import { IswETH, IswEXIT } from "@/adapters/swETH/ISwell.sol"; +import { wrap, unwrap } from "@prb/math/UD60x18.sol"; +import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; + +address constant SWETH_TOKEN = 0xf951E335afb289353dc249e82926178EaC7DEd78; +address constant SWEXIT = 0x48C11b86807627AF70a34662D4865cF854251663; + +uint256 constant MIN_AMOUNT = 5_000_000_000_000_000; // 0.005 ETH +uint256 constant MAX_AMOUNT = 500 ether; + +contract SwETHAdapter is Adapter { + function previewWithdraw(uint256 amount) external view returns (uint256) { + return wrap(amount).mul(wrap(IswETH(SWETH_TOKEN).getRate())).unwrap(); + } + + function requestWithdraw(uint256 amount) external returns (uint256 tokenId, uint256 amountExpected) { + SafeTransferLib.safeApprove(SWETH_TOKEN, SWEXIT, amount); + amountExpected = wrap(amount).mul(wrap(IswETH(SWETH_TOKEN).getRate())).unwrap(); + IswEXIT(SWEXIT).createWithdrawRequest(amount); + tokenId = IswEXIT(SWEXIT).getLastTokenIdCreated(); + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + uint256 balBefore = address(this).balance; + IswEXIT(SWEXIT).finalizeWithdrawal(tokenId); + amount = address(this).balance - balBefore; + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + return IswEXIT(SWEXIT).getLastTokenIdProcessed() >= tokenId; + } + + function totalStaked() external view returns (uint256) { + return wrap(IswETH(SWETH_TOKEN).totalSupply()).mul(wrap(IswETH(SWETH_TOKEN).getRate())).unwrap(); + } + + function minMaxAmount() external pure returns (uint256 min, uint256 max) { + return (MIN_AMOUNT, MAX_AMOUNT); + } +} diff --git a/src/unsETH/Base64.sol b/src/unsETH/Base64.sol new file mode 100644 index 0000000..daa4470 --- /dev/null +++ b/src/unsETH/Base64.sol @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +/** + * Based on Brecht Devos (Brechtpd) implementation - MIT licence + * https://github.com/Brechtpd/base64/blob/80238e2ebed645cf7dcfe831f7c4458e9cb574e9/base64.sol + */ + +/// @title Base64 +/// @notice Provides functions for encoding/decoding base64 +library Base64 { + string internal constant TABLE_ENCODE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + bytes internal constant TABLE_DECODE = hex"0000000000000000000000000000000000000000000000000000000000000000" + hex"00000000000000000000003e0000003f3435363738393a3b3c3d000000000000" + hex"00000102030405060708090a0b0c0d0e0f101112131415161718190000000000" + hex"001a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132330000000000"; + + function encode(bytes memory data) internal pure returns (string memory) { + if (data.length == 0) return ""; + + // load the table into memory + string memory table = TABLE_ENCODE; + + // multiply by 4/3 rounded up + uint256 encodedLen = 4 * ((data.length + 2) / 3); + + // add some extra buffer at the end required for the writing + string memory result = new string(encodedLen + 32); + + assembly { + // set the actual output length + mstore(result, encodedLen) + + // prepare the lookup table + let tablePtr := add(table, 1) + + // input ptr + let dataPtr := data + let endPtr := add(dataPtr, mload(data)) + + // result ptr, jump over length + let resultPtr := add(result, 32) + + // run over the input, 3 bytes at a time + // solhint-disable-next-line no-empty-blocks + for { } lt(dataPtr, endPtr) { } { + // read 3 bytes + dataPtr := add(dataPtr, 3) + let input := mload(dataPtr) + + // write 4 characters + mstore(resultPtr, shl(248, mload(add(tablePtr, and(shr(18, input), 0x3F))))) + resultPtr := add(resultPtr, 1) + mstore(resultPtr, shl(248, mload(add(tablePtr, and(shr(12, input), 0x3F))))) + resultPtr := add(resultPtr, 1) + mstore(resultPtr, shl(248, mload(add(tablePtr, and(shr(6, input), 0x3F))))) + resultPtr := add(resultPtr, 1) + mstore(resultPtr, shl(248, mload(add(tablePtr, and(input, 0x3F))))) + resultPtr := add(resultPtr, 1) + } + + // padding with '=' + switch mod(mload(data), 3) + case 1 { mstore(sub(resultPtr, 2), shl(240, 0x3d3d)) } + case 2 { mstore(sub(resultPtr, 1), shl(248, 0x3d)) } + } + + return result; + } + + function decode(string memory _data) internal pure returns (bytes memory) { + bytes memory data = bytes(_data); + + if (data.length == 0) return new bytes(0); + require(data.length % 4 == 0, "invalid base64 decoder input"); + + // load the table into memory + bytes memory table = TABLE_DECODE; + + // every 4 characters represent 3 bytes + uint256 decodedLen = (data.length / 4) * 3; + + // add some extra buffer at the end required for the writing + bytes memory result = new bytes(decodedLen + 32); + + assembly { + // padding with '=' + let lastBytes := mload(add(data, mload(data))) + if eq(and(lastBytes, 0xFF), 0x3d) { + decodedLen := sub(decodedLen, 1) + if eq(and(lastBytes, 0xFFFF), 0x3d3d) { decodedLen := sub(decodedLen, 1) } + } + + // set the actual output length + mstore(result, decodedLen) + + // prepare the lookup table + let tablePtr := add(table, 1) + + // input ptr + let dataPtr := data + let endPtr := add(dataPtr, mload(data)) + + // result ptr, jump over length + let resultPtr := add(result, 32) + + // run over the input, 4 characters at a time + // solhint-disable-next-line no-empty-blocks + for { } lt(dataPtr, endPtr) { } { + // read 4 characters + dataPtr := add(dataPtr, 4) + let input := mload(dataPtr) + + // write 3 bytes + let output := + add( + add( + shl(18, and(mload(add(tablePtr, and(shr(24, input), 0xFF))), 0xFF)), + shl(12, and(mload(add(tablePtr, and(shr(16, input), 0xFF))), 0xFF)) + ), + add( + shl(6, and(mload(add(tablePtr, and(shr(8, input), 0xFF))), 0xFF)), + and(mload(add(tablePtr, and(input, 0xFF))), 0xFF) + ) + ) + mstore(resultPtr, shl(232, output)) + resultPtr := add(resultPtr, 3) + } + } + + return result; + } +} diff --git a/src/unsETH/Renderer.sol b/src/unsETH/Renderer.sol new file mode 100644 index 0000000..4b26151 --- /dev/null +++ b/src/unsETH/Renderer.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +// +// _____ _ _ +// |_ _| | | (_) +// | | ___ _ __ __| | ___ _ __ _ _______ +// | |/ _ \ '_ \ / _` |/ _ \ '__| |_ / _ \ +// | | __/ | | | (_| | __/ | | |/ / __/ +// \_/\___|_| |_|\__,_|\___|_| |_/___\___| +// +// Copyright (c) Tenderize Labs Ltd + +pragma solidity >=0.8.19; + +import { Initializable } from "@openzeppelin/upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol"; + +import { Metadata } from "@/unsETH/UnsETH.sol"; +import { Base64 } from "@/unsETH/Base64.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +// import { Strings } from "openzeppelin-contracts/utils/Strings.sol"; + +// solhint-disable quotes + +/// @title Renderer +/// @notice ERC721 metadata renderer for unlock tokens +/// @dev Renders SVG and JSON metadata for unlock tokens +/// @dev UUPS upgradeable contract + +contract Renderer { + using Strings for uint256; + + /** + * @notice Returns the JSON metadata for a given unlock + * @param data metadata for the token + */ + function json(Metadata memory data) external pure returns (string memory) { + return string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + abi.encodePacked( + '{"name": "unsETH", "description": "unstaking ETH",', + '"attributes":[', + _serializeMetadata(data), + "]}" + ) + ) + ) + ); + } + + function svg(Metadata memory data) external pure returns (string memory) { + return string( + abi.encodePacked( + '", + Base64.encode( + abi.encodePacked( + "", + "", + data.derivative, + '', + data.amount.toString(), + '', + data.createdAt.toString(), + '', + data.requestId.toString(), + "", + "" + ) + ) + ) + ); + } + + function _serializeMetadata(Metadata memory data) internal pure returns (string memory metadataString) { + metadataString = string( + abi.encodePacked( + '{"trait_type": "createdAt", "value":', + data.createdAt.toString(), + "},", + '{"trait_type": "amount", "value":', + data.amount.toString(), + "},", + '{"trait_type": "derivative", "value":"', + data.derivative, + '"},', + '{"trait_type": "requestId", "value":"', + data.requestId, + '"},' + ) + ); + } +} diff --git a/src/unsETH/UnsETH.sol b/src/unsETH/UnsETH.sol new file mode 100644 index 0000000..1e1e5f7 --- /dev/null +++ b/src/unsETH/UnsETH.sol @@ -0,0 +1,145 @@ +// 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 { Initializable } from "@openzeppelin/upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol"; + +import { Renderer } from "@/unsETH/Renderer.sol"; +import { Registry } from "@/Registry.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { Adapter, AdapterDelegateCall } from "@/adapters/Adapter.sol"; + +// TODO: ERC165 for adapters + +pragma solidity >=0.8.19; + +// solhint-disable quotes + +/// @title Unlocks +/// @notice ERC721 contract for unlock tokens +/// @dev Creates an NFT for staked tokens pending unlock. Each Unlock has an amount and a maturity date. + +struct Metadata { + uint256 requestId; // request id + uint256 amount; // expected amount to receive + uint256 createdAt; // block number + address derivative; // address of the derivative LST/LRT +} + +contract UnsETH is Initializable, UUPSUpgradeable, OwnableUpgradeable, ERC721, ERC721Receiver { + address private immutable LPETH; + Registry private immutable REGISTRY; + Renderer private immutable RENDERER; + + mapping(uint256 => Metadata) public metadata; + + error NotOwnerOf(uint256 tokenId, address owner, address sender); + error InvalidID(); + + constructor(address registry, address renderer) ERC721() { + REGISTRY = Registry(registry); + RENDERER = Renderer(renderer); + _disableInitializers(); + } + + function initialize() external initializer { + __Ownable_init(msg.sender); + } + + fallback() external payable { } + receive() external payable { } + + function name() public pure override returns (string memory) { + return "Unstaking ETH"; + } + + function symbol() public pure override returns (string memory) { + return "unsETH"; + } + + function requestWithdraw( + address asset, + uint256 amount + ) + external + returns (uint256 tokenId, uint256 amountExpected) + { + SafeTransferLib.safeTransferFrom(asset, msg.sender, address(this), amount); + + uint256 requestId; + (requestId, amountExpected) = abi.decode( + AdapterDelegateCall._delegatecall( + REGISTRY.adapters(asset), abi.encodeWithSelector(Adapter.requestWithdraw.selector, amount) + ), + (uint256, uint256) + ); + + Metadata memory _metadata = + Metadata({ requestId: requestId, amount: amountExpected, createdAt: block.timestamp, derivative: asset }); + tokenId = uint256(keccak256(abi.encodePacked(asset, requestId))); + metadata[tokenId] = _metadata; + _safeMint(msg.sender, tokenId); + } + + function claimWithdraw(uint256 tokenId) external returns (uint256 amount) { + if (ownerOf(tokenId) != msg.sender) { + revert NotOwnerOf(tokenId, ownerOf(tokenId), msg.sender); + } + + Metadata memory _metadata = metadata[tokenId]; + + amount = abi.decode( + AdapterDelegateCall._delegatecall( + REGISTRY.adapters(_metadata.derivative), + abi.encodeWithSelector(Adapter.claimWithdraw.selector, _metadata.requestId) + ), + (uint256) + ); + + delete metadata[tokenId]; + _burn(tokenId); + SafeTransferLib.safeTransferETH(msg.sender, amount); + } + + function isFinalized(uint256 tokenId) external view returns (bool) { + Metadata memory _metadata = metadata[tokenId]; + return REGISTRY.adapters(_metadata.derivative).isFinalized(_metadata.requestId); + } + + function minMaxAmount(address asset) external view returns (uint256 min, uint256 max) { + return REGISTRY.adapters(asset).minMaxAmount(); + } + + /** + * @notice Returns the tokenURI of an unlock token + * @param tokenId ID of the unlock token + * @return tokenURI of the unlock token + */ + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + if (ownerOf(tokenId) == address(0)) { + revert InvalidID(); + } + + Metadata memory data = metadata[tokenId]; + // TODO: this gives an error atm + // return RENDERER.json(data); + } + + ///@dev required by the OZ UUPS module + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner { } +} diff --git a/src/utils/ERC721Receiver.sol b/src/utils/ERC721Receiver.sol new file mode 100644 index 0000000..5ea5646 --- /dev/null +++ b/src/utils/ERC721Receiver.sol @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2021 Tenderize + +// SPDX-License-Identifier: MIT + +pragma solidity >=0.8.20; + +abstract contract ERC721Receiver { + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { + return ERC721Receiver.onERC721Received.selector; + } +} diff --git a/src/utils/SelfPermit.sol b/src/utils/SelfPermit.sol new file mode 100644 index 0000000..e3b448e --- /dev/null +++ b/src/utils/SelfPermit.sol @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2021 Tenderize + +// SPDX-License-Identifier: MIT + +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +pragma solidity >=0.8.20; + +/// @title Self Permit +/// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route +interface ISelfPermit { + /// @notice Permits this contract to spend a given token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// @param _token The address of the token spent + /// @param _value The amount that can be spent of token + /// @param _deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param _v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param _r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param _s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermit( + address _token, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + external + payable; + + /// @notice Permits this contract to spend a given token from `msg.sender` + /// @dev The `owner` is always msg.sender and the `spender` is always address(this). + /// Can be used instead of #selfPermit to prevent calls from failing due to a frontrun of a call to #selfPermit + /// @param _token The address of the token spent + /// @param _value The amount that can be spent of token + /// @param _deadline A timestamp, the current blocktime must be less than or equal to this timestamp + /// @param _v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param _r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param _s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function selfPermitIfNecessary( + address _token, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + external + payable; +} + +abstract contract SelfPermit is ISelfPermit { + /// @inheritdoc ISelfPermit + function selfPermit( + address _token, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + public + payable + override + { + ERC20(_token).permit(msg.sender, address(this), _value, _deadline, _v, _r, _s); + } + + /// @inheritdoc ISelfPermit + function selfPermitIfNecessary( + address _token, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + external + payable + override + { + uint256 allowance = ERC20(_token).allowance(msg.sender, address(this)); + if (allowance < _value) selfPermit(_token, _value - allowance, _deadline, _v, _r, _s); + } +} diff --git a/test/Foo.t.sol b/test/Foo.t.sol deleted file mode 100644 index 727337a..0000000 --- a/test/Foo.t.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25 <0.9.0; - -import { Test } from "forge-std/src/Test.sol"; -import { console2 } from "forge-std/src/console2.sol"; - -import { Foo } from "../src/Foo.sol"; - -interface IERC20 { - function balanceOf(address account) external view returns (uint256); -} - -/// @dev If this is your first time with Forge, read this tutorial in the Foundry Book: -/// https://book.getfoundry.sh/forge/writing-tests -contract FooTest is Test { - Foo internal foo; - - /// @dev A function invoked before each test case is run. - function setUp() public virtual { - // Instantiate the contract-under-test. - foo = new Foo(); - } - - /// @dev Basic test. Run it with `forge test -vvv` to see the console log. - function test_Example() external view { - console2.log("Hello World"); - uint256 x = 42; - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fuzz test that provides random values for an unsigned integer, but which rejects zero as an input. - /// If you need more sophisticated input validation, you should use the `bound` utility instead. - /// See https://twitter.com/PaulRBerg/status/1622558791685242880 - function testFuzz_Example(uint256 x) external view { - vm.assume(x != 0); // or x = bound(x, 1, 100) - assertEq(foo.id(x), x, "value mismatch"); - } - - /// @dev Fork test that runs against an Ethereum Mainnet fork. For this to work, you need to set `API_KEY_ALCHEMY` - /// in your environment You can get an API key for free at https://alchemy.com. - function testFork_Example() external { - // Silently pass this test if there is no API key. - string memory alchemyApiKey = vm.envOr("API_KEY_ALCHEMY", string("")); - if (bytes(alchemyApiKey).length == 0) { - return; - } - - // Otherwise, run the test against the mainnet fork. - vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 16_428_000 }); - address usdc = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; - address holder = 0x7713974908Be4BEd47172370115e8b1219F4A5f0; - uint256 actualBalance = IERC20(usdc).balanceOf(holder); - uint256 expectedBalance = 196_307_713.810457e6; - assertEq(actualBalance, expectedBalance); - } -} diff --git a/test/LPToken.t.sol b/test/LPToken.t.sol new file mode 100644 index 0000000..b5f8239 --- /dev/null +++ b/test/LPToken.t.sol @@ -0,0 +1,52 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { LPToken } from "@/LPToken.sol"; + +contract LPTokenTest is Test { + LPToken lpToken; + + function setUp() public { + lpToken = new LPToken(); + } + + function test_name() public view { + assertEq(lpToken.name(), "lpETH"); + } + + function test_symbol() public view { + assertEq(lpToken.symbol(), "lpETH"); + } + + function test_owner() public view { + assertEq(lpToken.owner(), address(this)); + } + + function test_mint() public { + lpToken.mint(address(this), 1000); + assertEq(lpToken.balanceOf(address(this)), 1000); + } + + function test_mint_unauthorized() public { + vm.startPrank(vm.addr(333)); + vm.expectRevert(abi.encodeWithSelector(LPToken.Unauthorized.selector)); + lpToken.mint(address(this), 1000); + vm.stopPrank(); + } + + function test_burn() public { + lpToken.mint(address(this), 1000); + lpToken.burn(address(this), 500); + assertEq(lpToken.balanceOf(address(this)), 500); + } + + function test_burn_unauthorized() public { + lpToken.mint(address(this), 1000); + vm.startPrank(vm.addr(333)); + vm.expectRevert(abi.encodeWithSelector(LPToken.Unauthorized.selector)); + lpToken.burn(address(this), 500); + vm.stopPrank(); + } +} diff --git a/test/Registry.t.sol b/test/Registry.t.sol new file mode 100644 index 0000000..a4939f0 --- /dev/null +++ b/test/Registry.t.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@/adapters/Adapter.sol"; +import { Registry } from "@/Registry.sol"; + +import { UUPSUpgradeable } from "@openzeppelin/upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { ERC1967Proxy, ERC1967Utils } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { OwnableUpgradeable } from "@openzeppelin/upgradeable/access/OwnableUpgradeable.sol"; + +contract RegistryTest is Test { + Registry private registry; + address private owner; + address private other; + Adapter private adapter; + + function setUp() public { + other = address(0x1234); + vm.etch(other, bytes("code")); + adapter = Adapter(vm.addr(0x5678)); + + // Deploy the registry contract + address registry_impl = address(new Registry()); + registry = Registry(payable(address(new ERC1967Proxy(registry_impl, "")))); + + // Initialize the registry contract + registry.initialize(); + } + + function test_owner() public view { + // Check that the owner is set correctly + assertEq(registry.owner(), address(this), "Owner should be set to the deployer"); + } + + function test_setAdapter() public { + // Set an adapter + registry.setAdapter(other, adapter); + + // Verify the adapter was set correctly + assertEq(address(registry.adapters(other)), address(adapter), "Adapter should be set correctly"); + } + + function test_setAdapter_unauthorized() public { + // Try to set an adapter from a non-owner address + vm.prank(other); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, other)); + registry.setAdapter(other, adapter); + } +} diff --git a/test/UnsETH.t.sol b/test/UnsETH.t.sol new file mode 100644 index 0000000..057ba97 --- /dev/null +++ b/test/UnsETH.t.sol @@ -0,0 +1,127 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { UnsETH } from "@/unsETH/UnsETH.sol"; +import { Registry } from "@/Registry.sol"; +import { Renderer } from "@/unsETH/Renderer.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { Adapter } from "@/adapters/Adapter.sol"; + +import { ERC721 } from "solady/tokens/ERC721.sol"; +import { MockERC20 } from "./helpers/MockERC20.sol"; + +contract UnsETHTest is Test, ERC721Receiver { + address registry = vm.addr(123); + address adapter = vm.addr(456); + UnsETH unsETH; + MockERC20 myETH; // derivative token + + receive() external payable { } + + function setUp() public { + vm.etch(registry, bytes("code")); + vm.etch(adapter, bytes("code")); + address renderer = address(new Renderer()); + address unsETH_impl = address(new UnsETH(registry, renderer)); + unsETH = UnsETH(payable(address(new ERC1967Proxy(unsETH_impl, "")))); + unsETH.initialize(); + + myETH = new MockERC20("MyETH", "myETH", 18); + myETH.mint(address(this), 1000 ether); + vm.mockCall(registry, abi.encodeWithSignature("adapters(address)", (address(myETH))), abi.encode(adapter)); + } + + function test_name() public view { + assertEq(unsETH.name(), "Unstaking ETH"); + } + + function test_symbol() public view { + assertEq(unsETH.symbol(), "unsETH"); + } + + function test_owner() public view { + assertEq(unsETH.owner(), address(this)); + } + + function test_requestWithdraw() public { + uint256 amount = 10 ether; + myETH.approve(address(unsETH), amount); + uint256 expectedRequestId = 1337; + uint256 expectedAmount = 9 ether; + + vm.mockCall( + adapter, abi.encodeCall(Adapter.requestWithdraw, (amount)), abi.encode(expectedRequestId, expectedAmount) + ); + + (uint256 tokenId, uint256 outAmount) = unsETH.requestWithdraw(address(myETH), amount); + + uint256 expectedTokenId = uint256(keccak256(abi.encodePacked(address(myETH), expectedRequestId))); + assertEq(tokenId, expectedTokenId); + assertEq(outAmount, expectedAmount); + (uint256 id, uint256 _amount, uint256 createdAt, address derivative) = unsETH.metadata(tokenId); + assertEq(id, expectedRequestId); + assertEq(_amount, expectedAmount); + assertEq(createdAt, block.timestamp); + assertEq(derivative, address(myETH)); + + assertEq(unsETH.ownerOf(tokenId), address(this)); + } + + function test_claimWithdraw() public { + uint256 amount = 10 ether; + myETH.approve(address(unsETH), amount); + uint256 expectedRequestId = 1337; + uint256 expectedAmount = 9 ether; + vm.deal(address(unsETH), 9 ether); + + uint256 balanceBefore = address(this).balance; + vm.mockCall( + adapter, abi.encodeCall(Adapter.requestWithdraw, (amount)), abi.encode(expectedRequestId, expectedAmount) + ); + + (uint256 tokenId, uint256 outAmount) = unsETH.requestWithdraw(address(myETH), amount); + + vm.mockCall(adapter, abi.encodeCall(Adapter.claimWithdraw, (expectedRequestId)), abi.encode(outAmount)); + + assertEq(unsETH.claimWithdraw(tokenId), expectedAmount); + assertEq(unsETH.balanceOf(address(this)), 0); + assertEq(address(this).balance - balanceBefore, 9 ether); + vm.expectRevert(abi.encodeWithSelector(ERC721.TokenDoesNotExist.selector)); + assertEq(unsETH.ownerOf(tokenId), address(0)); + } + + function test_isFinalized() public { + uint256 amount = 10 ether; + myETH.approve(address(unsETH), amount); + uint256 expectedRequestId = 1337; + uint256 expectedAmount = 9 ether; + + vm.mockCall( + adapter, abi.encodeCall(Adapter.requestWithdraw, (amount)), abi.encode(expectedRequestId, expectedAmount) + ); + + (uint256 tokenId,) = unsETH.requestWithdraw(address(myETH), amount); + + vm.mockCall(adapter, abi.encodeCall(Adapter.isFinalized, (expectedRequestId)), abi.encode(false)); + + assertFalse(unsETH.isFinalized(tokenId)); + + vm.mockCall(adapter, abi.encodeCall(Adapter.isFinalized, (expectedRequestId)), abi.encode(true)); + + assertTrue(unsETH.isFinalized(tokenId)); + } + + function test_minMax() public { + uint256 min = 1 ether; + uint256 max = 10 ether; + + vm.mockCall(adapter, abi.encodeCall(Adapter.minMaxAmount, ()), abi.encode(min, max)); + + (uint256 _min, uint256 _max) = unsETH.minMaxAmount(address(myETH)); + assertEq(_min, min); + assertEq(_max, max); + } +} diff --git a/test/adapters/EETHAdapter.t.sol b/test/adapters/EETHAdapter.t.sol new file mode 100644 index 0000000..62a1acf --- /dev/null +++ b/test/adapters/EETHAdapter.t.sol @@ -0,0 +1,37 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { EETHAdapter, EETH_TOKEN } from "@/adapters/eETH/EETHAdapter.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { AdapterDelegateCall } from "@/adapters/Adapter.sol"; + +address constant EETH_HOLDER = 0x22162DbBa43fE0477cdC5234E248264eC7C6EA7c; + +// tokenId 18143 +// amountExpected 99999999999999999999 +// totalStaked 1283949800110909723568459 + +contract EETHAdapterTest is Test, ERC721Receiver { + EETHAdapter adapter; + + using AdapterDelegateCall for EETHAdapter; + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895); + adapter = new EETHAdapter(); + vm.startPrank(EETH_HOLDER); + ERC20(EETH_TOKEN).transfer(address(this), 1000 ether); + vm.stopPrank(); + } + + function test_request_and_claim() public { + bytes memory data = adapter._delegatecall(abi.encodeWithSelector(adapter.requestWithdraw.selector, 100 ether)); + (uint256 tokenId, uint256 amountExpected) = abi.decode(data, (uint256, uint256)); + console.log("tokenId %s", tokenId); + console.log("amountExpected %s", amountExpected); + console.log("totalStaked %s", adapter.totalStaked()); + assertFalse(adapter.isFinalized(tokenId)); + } +} diff --git a/test/adapters/ETHxAdapter.t.sol b/test/adapters/ETHxAdapter.t.sol new file mode 100644 index 0000000..92bc5ac --- /dev/null +++ b/test/adapters/ETHxAdapter.t.sol @@ -0,0 +1,37 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { ETHxAdapter, ETHx_TOKEN } from "@/adapters/ETHx/ETHxAdapter.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { AdapterDelegateCall } from "@/adapters/Adapter.sol"; + +address constant ETHx_HOLDER = 0x9d7eD45EE2E8FC5482fa2428f15C971e6369011d; + +// tokenId 1192 +// amountExpected 102925116334432543526 +// totalStaked 125990931924605434662214 + +contract ETHxAdapterTest is Test, ERC721Receiver { + ETHxAdapter adapter; + + using AdapterDelegateCall for ETHxAdapter; + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895); + adapter = new ETHxAdapter(); + vm.startPrank(ETHx_HOLDER); + ERC20(ETHx_TOKEN).transfer(address(this), 1000 ether); + vm.stopPrank(); + } + + function test_request_and_claim() public { + bytes memory data = adapter._delegatecall(abi.encodeWithSelector(adapter.requestWithdraw.selector, 100 ether)); + (uint256 tokenId, uint256 amountExpected) = abi.decode(data, (uint256, uint256)); + console.log("tokenId %s", tokenId); + console.log("amountExpected %s", amountExpected); + console.log("totalStaked %s", adapter.totalStaked()); + assertFalse(adapter.isFinalized(tokenId)); + } +} diff --git a/test/adapters/METHAdapter.t.sol b/test/adapters/METHAdapter.t.sol new file mode 100644 index 0000000..1b6fa28 --- /dev/null +++ b/test/adapters/METHAdapter.t.sol @@ -0,0 +1,36 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { METHAdapter, METH_TOKEN, STAKING } from "@/adapters/mETH/METHAdapter.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { AdapterDelegateCall } from "@/adapters/Adapter.sol"; + +address constant METH_HOLDER = 0x78605Df79524164911C144801f41e9811B7DB73D; + +// at this block height +// tokenId 1601 +// amountExpected 103241474194764736617 + +contract METHAdapterTest is Test { + METHAdapter adapter; + + using AdapterDelegateCall for METHAdapter; + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895); + adapter = new METHAdapter(); + vm.startPrank(METH_HOLDER); + ERC20(METH_TOKEN).transfer(address(this), 1000 ether); + vm.stopPrank(); + } + + function test_request_and_claim() public { + bytes memory data = adapter._delegatecall(abi.encodeWithSelector(adapter.requestWithdraw.selector, 100 ether)); + (uint256 tokenId, uint256 amountExpected) = abi.decode(data, (uint256, uint256)); + console.log("tokenId %s", tokenId); + console.log("amountExpected %s", amountExpected); + console.log("totalStaked %s", adapter.totalStaked()); + assertFalse(adapter.isFinalized(tokenId)); + } +} diff --git a/test/adapters/StETHAdapter.t.sol b/test/adapters/StETHAdapter.t.sol new file mode 100644 index 0000000..968ccb9 --- /dev/null +++ b/test/adapters/StETHAdapter.t.sol @@ -0,0 +1,37 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { AdapterDelegateCall } from "@/adapters/Adapter.sol"; +import { StETHAdapter, STETH_TOKEN } from "@/adapters/stETH/StETHAdapter.sol"; + +address constant STETH_HOLDER = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + +// tokenId 38290 +// amountExpected 100000000000000000000 +// totalStaked 9363673430668685685376298 + +contract ETHxAdapterTest is Test, ERC721Receiver { + StETHAdapter adapter; + + using AdapterDelegateCall for StETHAdapter; + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895); + adapter = new StETHAdapter(); + vm.startPrank(STETH_HOLDER); + ERC20(STETH_TOKEN).transfer(address(this), 1000 ether); + vm.stopPrank(); + } + + function test_request_and_claim() public { + bytes memory data = adapter._delegatecall(abi.encodeWithSelector(adapter.requestWithdraw.selector, 100 ether)); + (uint256 tokenId, uint256 amountExpected) = abi.decode(data, (uint256, uint256)); + console.log("tokenId %s", tokenId); + console.log("amountExpected %s", amountExpected); + console.log("totalStaked %s", adapter.totalStaked()); + assertFalse(adapter.isFinalized(tokenId)); + } +} diff --git a/test/adapters/SwETHAdapter.t.sol b/test/adapters/SwETHAdapter.t.sol new file mode 100644 index 0000000..1a947da --- /dev/null +++ b/test/adapters/SwETHAdapter.t.sol @@ -0,0 +1,37 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; +import { SwETHAdapter, IswETH, IswEXIT, SWETH_TOKEN, SWEXIT } from "@/adapters/swETH/SwETHAdapter.sol"; +import { ERC721Receiver } from "@/utils/ERC721Receiver.sol"; +import { ERC20 } from "solady/tokens/ERC20.sol"; +import { AdapterDelegateCall } from "@/adapters/Adapter.sol"; + +address constant SWETH_HOLDER = 0x38D43a6Cb8DA0E855A42fB6b0733A0498531d774; + +// tokenId 6550 +// amountExpected 105898225379893452500 +// totalStaked 197304164246212470306573 + +contract SwEthAdapterTest is Test, ERC721Receiver { + SwETHAdapter adapter; + + using AdapterDelegateCall for SwETHAdapter; + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_847_895); + adapter = new SwETHAdapter(); + vm.startPrank(SWETH_HOLDER); + ERC20(SWETH_TOKEN).transfer(address(this), 1000 ether); + vm.stopPrank(); + } + + function test_request_and_claim() public { + bytes memory data = adapter._delegatecall(abi.encodeWithSelector(adapter.requestWithdraw.selector, 100 ether)); + (uint256 tokenId, uint256 amountExpected) = abi.decode(data, (uint256, uint256)); + console.log("tokenId %s", tokenId); + console.log("amountExpected %s", amountExpected); + console.log("totalStaked %s", adapter.totalStaked()); + assertFalse(adapter.isFinalized(tokenId)); + } +} diff --git a/test/helpers/MockERC20.sol b/test/helpers/MockERC20.sol new file mode 100644 index 0000000..ce6efb1 --- /dev/null +++ b/test/helpers/MockERC20.sol @@ -0,0 +1,15 @@ +pragma solidity >=0.8.20; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory _name, string memory _symbol, uint8 _decimals) ERC20(_name, _symbol, _decimals) { } + + function mint(address to, uint256 value) public virtual { + _mint(to, value); + } + + function burn(address from, uint256 value) public virtual { + _burn(from, value); + } +} diff --git a/test/lpETH/FeeGauge.t.sol b/test/lpETH/FeeGauge.t.sol new file mode 100644 index 0000000..6ddacad --- /dev/null +++ b/test/lpETH/FeeGauge.t.sol @@ -0,0 +1,43 @@ +pragma solidity >=0.8.20; + +import { Test, console } from "forge-std/Test.sol"; +import { VmSafe } from "forge-std/Vm.sol"; + +import { LPETH, LpETHEvents, ConstructorConfig } from "@/LPETH.sol"; +import { LPToken } from "@/LPToken.sol"; +import { Registry } from "@/Registry.sol"; + +import { UD60x18, UNIT, ud } from "@prb/math/UD60x18.sol"; + +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract LPETH_FeeGauge_Test is Test { + LPETH lpETH; + + function setUp() public { + ConstructorConfig memory config = ConstructorConfig({ + registry: Registry(address(8)), + lpToken: LPToken(address(9)), + treasury: address(10), + unsETH: address(11), + withdrawQueue: address(12) + }); + LPETH lpETH_impl = new LPETH(config); + lpETH = LPETH(payable(address(new ERC1967Proxy(address(lpETH_impl), "")))); + lpETH.initialize(); + } + + function test_setGauge() public { + lpETH.setFeeGauge(address(this), UD60x18.wrap(0.76e18)); + assertEq(lpETH.getFeeGauge(address(this)).unwrap(), 0.76e18); + } + + function test_setGauge_zero() public { + vm.expectRevert(abi.encodeWithSelector(LpETHEvents.GaugeZero.selector)); + lpETH.setFeeGauge(address(this), ud(0)); + } + + function test_getGauge_unset() public view { + assert(lpETH.getFeeGauge(address(this)).eq(UNIT)); + } +}